Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6116771fd | ||
|
|
def2e79327 | ||
|
|
c30b36fffb | ||
|
|
9ae8c73acb | ||
|
|
8ecc7a239d | ||
|
|
7f57419b38 | ||
|
|
4ca8d6e949 | ||
|
|
22e5e825b0 | ||
|
|
e6dea74b22 | ||
|
|
cd2b377bbf | ||
|
|
ceccb0ef26 | ||
|
|
10ab8ea624 | ||
|
|
c539995cf4 | ||
|
|
92cc6011c8 | ||
|
|
7af1665748 | ||
|
|
2244e8bafc | ||
|
|
bde0fdec9f | ||
|
|
def677bc76 | ||
|
|
51f67f1f87 | ||
|
|
5b298bd00b | ||
|
|
565bc32c2f | ||
|
|
5140aa9e88 | ||
|
|
973cb5a872 | ||
|
|
b82ec93536 | ||
|
|
3560c450a6 |
No files matched your search
@@ -0,0 +1,315 @@
|
||||
# Q175 design: the Console verifies SSO itself (Vikunja #412)
|
||||
|
||||
Author Sage, 2026-10-20. Reviewer Filbert, and the review comes before any code. Line numbers are at refactor
|
||||
4ca8d6e94. This replaces Part B (row 178 proxy proof, #385 comments 345 and 353). #385 stays open until the
|
||||
Console stops trusting `X-authentik-username`.
|
||||
|
||||
## The ruling this implements
|
||||
|
||||
Jason, DECISIONS Q175, verbatim: "Q1: yes. This should be a requirement for SSO usage. Whether we are using
|
||||
Pocket ID, Authentik, WorkOS, or another provider (we should support multiple), the system should use best
|
||||
practices for jwt or other methods of validation that do not rely on hacks to pass the auth." He added "Zero
|
||||
trust with verification" and confirmed five points (mos-0970):
|
||||
|
||||
1. The stack verifies every SSO login itself. It never takes identity from a proxy header or a shared secret.
|
||||
2. The Console is an OIDC relying party: discovery from the issuer URL, authorization code with PKCE, the ID
|
||||
token checked against the provider JWKS (signature, iss, aud, exp, nonce), then its own session cookie.
|
||||
3. Several providers can be configured side by side.
|
||||
4. Only asymmetric signing counts. HS256 is refused.
|
||||
5. A proxy-forwarded token such as `X-authentik-jwt` counts only through the same checks. It is optional and
|
||||
never the only way in.
|
||||
|
||||
## What exists today
|
||||
|
||||
The live Console runs from copies/490e2771d3d0 with `--public-host mosaic.woltje.com --auth-header
|
||||
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`.
|
||||
|
||||
- `previewOptions` (packages/webui/src/serve.mjs:205) builds `{ host, header, user }`.
|
||||
- A request is `named` when its Host equals the public name (:493). A named request passes sign-in when it
|
||||
carries exactly one auth header value equal to the user (:517-521). That header value is the whole identity.
|
||||
- Requests with a loopback Host on 7332 get the unauthenticated read path (:509-512). Writes need `named` (:526-546).
|
||||
- `proofOptions` (:217) is the row 178 shared-secret header. Q175 point 1 rules it out, so it retires with the
|
||||
auth header.
|
||||
- The tester listener (`--tester-port`, row 176) binds 127.0.0.1. Its writer is fixed as
|
||||
`{ tester: true, user: 'tester' }` (:522), it allows only the instance, route, name and chat writes (:523),
|
||||
and it refuses the auth and proof headers (:486-487). 7332 refuses `x-mosaic-tester` (:485).
|
||||
- The public path is Traefik, then the Authentik proxy outpost (provider mosaic-preview, HS256, empty JWKS),
|
||||
then chisel `R:0.0.0.0:7332:127.0.0.1:7332`, then the Console. Every public request reaches the Console from
|
||||
127.0.0.1 with whatever Host the outpost forwards.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. The Console becomes an OIDC relying party
|
||||
|
||||
New module `packages/webui/src/sso.mjs`, used only by the human listener (7332). Routes:
|
||||
|
||||
| Route | Method | What it does |
|
||||
|---|---|---|
|
||||
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. |
|
||||
| `/auth/callback` | GET | Finish the flow, create the session, 303 to the saved return path. |
|
||||
| `/auth/logout` | POST | Same-origin checks, delete the session, 303 to the provider's `end_session_endpoint`. |
|
||||
| `/auth/signed-out` | GET | Static "signed out" page with a sign-in link. |
|
||||
|
||||
The flow per provider:
|
||||
|
||||
1. Start. Generate a state, a nonce and a PKCE verifier (`generateRandomState`, `generateRandomNonce`,
|
||||
`generateRandomCodeVerifier`), and a challenge with `calculatePKCECodeChallenge` (S256 only). Put them, the
|
||||
provider id and the return path in a server-side login record keyed by a random id. Set
|
||||
`__Host-mosaic_login` to that id. 303 to `authorization_endpoint` with `response_type=code`,
|
||||
`scope=openid profile email`, `code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
|
||||
2. Callback. Read exactly one `__Host-mosaic_login` cookie, then take and delete its record (single use, so a
|
||||
replayed callback finds nothing). Expire the cookie in the response. The provider comes from the record,
|
||||
never from the query, which stops a mix-up between providers.
|
||||
3. `validateAuthResponse(as, client, url, record.state)` checks state, refuses an error response, and checks
|
||||
the `iss` parameter when the provider advertises RFC 9207.
|
||||
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)`, then
|
||||
`processAuthorizationCodeResponse(as, client, response, { expectedNonce: record.nonce, requireIdToken: true })`.
|
||||
That checks iss, aud (and azp when aud has several values), exp, iat and nonce, and checks `alg` against
|
||||
`client.id_token_signed_response_alg`.
|
||||
5. Signature. oauth4webapi does not verify the signature of an ID token that came straight from the token
|
||||
endpoint over TLS (OIDC Core 3.1.3.7 allows that, and the library docs for
|
||||
`validateApplicationLevelSignature` say so). Point 2 asks for the JWKS check, so the callback always calls
|
||||
`validateApplicationLevelSignature(as, response)` after step 4. It fetches the JWKS from the discovered
|
||||
`jwks_uri`, picks the key by `kid` and `alg`, and refuses HS* outright.
|
||||
6. Our own checks on top of the library: `header.alg` is in the provider's configured `algs`, the token's
|
||||
`iss` equals the configured issuer exactly, and `(iss, sub)` is in the provider's `subjects` list. Anything
|
||||
else is a 403 that logs `refused sign-in iss=... sub=...` (never a token, code or cookie).
|
||||
7. Create the session (section 3) and 303 to the return path. The return path must start with one `/` and
|
||||
is otherwise replaced with `/`.
|
||||
|
||||
Every refusal in steps 2-6 ends at a 403 page saying sign-in failed, with a link back to `/auth/login`. No
|
||||
step falls back to a header.
|
||||
|
||||
### 2. Library: oauth4webapi 3.8.8, pinned exactly
|
||||
|
||||
Mos's steer (mos-0971) was to prefer oauth4webapi over hand-rolled verification on `node:crypto` unless I
|
||||
could name a defect or a dependency cost that outweighs it. I found neither.
|
||||
|
||||
- MIT, no dependencies, one ESM file (build/index.js, 99,856 bytes). Tarball sha256 `bac088aecc7c...`,
|
||||
integrity `sha512-8N28E+a/oxfXW...` (the full values go in package-lock.json).
|
||||
- `supported()` covers PS/ES/RS 256/384/512, Ed25519/EdDSA and ML-DSA. No HS*. RSA keys under 2048 bits are
|
||||
refused.
|
||||
- It does discovery with an issuer equality check (`processDiscoveryResponse(expectedIssuer, ...)`), PKCE,
|
||||
state, the RFC 9207 `iss` parameter, nonce, and JWKS caching.
|
||||
- The one gap is the default skip of the token-endpoint signature check (section 1 step 5). Calling one
|
||||
exported function closes it, so it isn't a reason to hand-roll.
|
||||
|
||||
Hand-rolling would mean writing JOSE parsing, JWK import, key selection by `kid`, the claim checks and the
|
||||
discovery checks ourselves, with the same tests and more places to get it wrong.
|
||||
|
||||
Pin: `"oauth4webapi": "3.8.8"` in the root package.json `dependencies`, next to the pi pin, with the lockfile
|
||||
entry. Root is right because `scripts/stack-copy.sh:23-31` copies only the root package.json and lock and runs
|
||||
`npm ci` there (:55-56). packages/webui/src resolves the import from the copy's root node_modules. This is the
|
||||
webui's first runtime dependency. The root dependency rule (exact pins, never unversioned) applies to it.
|
||||
Upgrades are reviewed commits.
|
||||
|
||||
### 3. Sessions, cookies and logout
|
||||
|
||||
- Session id: 32 random bytes, base64url. The store keys on its sha256, so a heap dump doesn't hold usable ids.
|
||||
- Cookie `__Host-mosaic_session=<id>; Secure; HttpOnly; SameSite=Lax; Path=/`, no Domain, no Max-Age (a
|
||||
browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over https on
|
||||
this exact host.
|
||||
- Login cookie `__Host-mosaic_login`, same attributes plus `Max-Age=600`. The login record also expires at
|
||||
10 minutes server-side.
|
||||
- Lax, not Strict, because the callback is a top-level navigation from the provider's site, and a Strict
|
||||
cookie set there would be missing on the redirect to `/`. Lax still keeps the session off cross-site POSTs.
|
||||
The write routes keep their exact `Origin` and `Sec-Fetch-Site: same-origin` checks (:171, :250, :297, :340,
|
||||
:387, :425), so cross-site writes are refused twice.
|
||||
- Lifetime: 8 h absolute, 1 h idle, both server-side. The store is in memory with a cap of 64 sessions
|
||||
(oldest goes first), so a Console restart signs everyone out. That's acceptable for one human and keeps
|
||||
sessions off disk.
|
||||
- The session holds `{ provider, iss, sub, name, idToken, created, seen }`. `name` is the subject entry's
|
||||
label. It goes where `preview.user` goes today (send logs, settings, seat routes), so the downstream writer
|
||||
shape stays as it is. `idToken` is kept only as the logout hint and never leaves the server.
|
||||
- Every request on 7332 except `/healthz` and `/auth/*` needs a valid session. Without one, `/api/*` gets
|
||||
401 JSON and other GETs get a 303 to `/auth/login?return=<path>`.
|
||||
- Logout: `POST /auth/logout` with the same-origin checks. It deletes the session, expires the cookie, and
|
||||
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=https://mosaic.woltje.com/auth/signed-out&client_id=...`.
|
||||
If the provider has no `end_session_endpoint`, it goes straight to `/auth/signed-out`.
|
||||
- Limit: signing out at the provider does not end a Console session until idle or absolute expiry. Back-channel
|
||||
logout is a follow-up, not phase 1.
|
||||
|
||||
### 4. Provider config: shape and location
|
||||
|
||||
No secret value goes in this file. The client secret is a slot reference.
|
||||
|
||||
```json
|
||||
"console": {
|
||||
"sso": {
|
||||
"publicOrigin": "https://mosaic.woltje.com",
|
||||
"providers": [
|
||||
{
|
||||
"id": "authentik",
|
||||
"issuer": "https://auth.diversecanvas.com/application/o/mosaic-stack/",
|
||||
"clientId": "<from Jason>",
|
||||
"clientSecret": { "slot": "console-sso-authentik" },
|
||||
"algs": ["RS256"],
|
||||
"subjects": [ { "sub": "<from Jason>", "name": "jason.woltje" } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Location: a new optional `console` key in the business file (`~/.config/mosaic-dev/businesses/<id>.json`). The
|
||||
Console already serves one business (`--business`), the identities map to that business's human, and Q173
|
||||
lets agents edit the test business with a backup first. Adding the key means adding `console` to `TOP_KEYS`
|
||||
(packages/business/src/business.mjs:21) with a strict validator. It's additive, and existing files without
|
||||
it still load. The alternative is `~/.config/mosaic-dev/config.json`, which is user-authored and never
|
||||
auto-written. That fits a deployment setting well, but it puts per-business identity in system config.
|
||||
**Filbert, rule on this one.**
|
||||
|
||||
The slot is `~/.config/mosaic-dev/secrets/mosaic-stack/<slot>`, read at start. The Console refuses to start if
|
||||
the file isn't a regular file, isn't owned by the Console's uid, has any group or other mode bits, or
|
||||
resolves under the repo or a stack copy. The value is never logged or put in a refusal message.
|
||||
|
||||
`stack-release.sh rehearse` copies a backup onto a scratch root. A rehearsed Console must not use the live
|
||||
redirect URI, so rehearse strips `console.sso` from the scratch business file and the rehearsed Console runs
|
||||
without SSO (tester listener only). That's a row 163 follow-up, named in the build brief.
|
||||
|
||||
Startup checks for each provider, all fail-closed (the Console doesn't start, and the message names the
|
||||
provider and the check):
|
||||
|
||||
- `issuer` is https with no query or fragment. Discovery returns that issuer exactly.
|
||||
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`, and `algs` holds only
|
||||
asymmetric algs from the library's list. mosaic-preview advertises HS256 only, so the Console refuses it at
|
||||
start.
|
||||
- `code_challenge_methods_supported` includes `S256`.
|
||||
- `jwks_uri` returns at least one key usable for one of `algs`. The empty mosaic-preview JWKS fails here too.
|
||||
- `subjects` is non-empty, and each `sub` is 1..255 visible characters.
|
||||
- `client.id_token_signed_response_alg` is set to `algs[0]`, so the library enforces it per token.
|
||||
- Provider ids are unique and `[a-z0-9-]{1,32}`.
|
||||
|
||||
Starting while the provider is unreachable refuses the start. A Console with no working sign-in serves
|
||||
nothing, so refusing loudly costs nothing, and the unit restarts it. The tradeoff is that an IdP outage
|
||||
keeps the Console down until the IdP is back. With every route behind a session, it would be unusable anyway.
|
||||
|
||||
Several providers: Pocket ID, WorkOS and other compliant OPs use the same entry shape. The login page lists
|
||||
them. Each entry has its own issuer, client, algs and subjects, and a session records which provider made it.
|
||||
|
||||
### 5. Retiring `--auth-header`, `--allow-user` and the proof header
|
||||
|
||||
1. **Phase 1 (code row).** Add `--sso` to `cli.mjs serve` and `webui-service.sh`. It reads `console.sso` from
|
||||
the business file. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
|
||||
`--sso`, `named` means "has a session", and `writer` comes from the session. `--send` needs `--sso` or the
|
||||
old preview set. Nothing live changes until the re-pin.
|
||||
2. **Phase 2 (Jason-gated window).** Re-pin to the phase 1 commit with `--sso --send --tester-port 7333`
|
||||
instead of the preview flags, after the Jason steps below. Backup first, STATE after (Q173). Acceptance:
|
||||
Jason signs in through the Console flow, sends one message, signs out, and a forged-header request with no
|
||||
cookie gets 401.
|
||||
3. **Phase 3 (code row).** Remove `previewOptions`, `proofOptions` and their tests. `cli.mjs` and
|
||||
`webui-service.sh` refuse the old flags with `--auth-header was retired by Q175; use --sso`. Close #385
|
||||
with the phase 2 STATE and the phase 3 commit.
|
||||
|
||||
The Authentik proxy outpost can stay in front. It still sends `X-authentik-*` headers, and the Console never
|
||||
reads them. A test proves that a request carrying them gets nothing without a session cookie.
|
||||
|
||||
### 6. The tester port and the N3 tester identity
|
||||
|
||||
The tester listener exists so agents can test as the tester and never as Jason (mos-0870 N3). Under SSO:
|
||||
|
||||
- It never mounts `/auth/*` (404), never reads a cookie, and refuses any request carrying `Authorization` or a
|
||||
`__Host-mosaic_session` / `__Host-mosaic_login` cookie (403 `tester listener takes no sign-in`). The same
|
||||
goes for `X-authentik-jwt` and the old auth and proof header names.
|
||||
- Its writer stays the fixed `{ tester: true, user: 'tester' }`, and it allows only the row 175 writes, as
|
||||
today. No session or header can change it.
|
||||
- Its bus calls stay on `TESTER_CLI`. The bus checks the tester proof and refuses human verbs from it, and the
|
||||
broker refuses a business whose human is `tester` and any role named `tester`. So nothing that reaches 7333
|
||||
can become the human.
|
||||
- 7332 keeps refusing `x-mosaic-tester` for every Host (:485).
|
||||
- `subjects` validation refuses an entry named `tester`.
|
||||
|
||||
### 7. Point 5, the proxy-forwarded token
|
||||
|
||||
Not in phase 1. Point 5 makes it optional and the code flow covers the ruling. If it's built later:
|
||||
|
||||
- Verify with jose (MIT, exact pin), against the JWKS from the configured issuer's own discovery. Never use
|
||||
the `X-authentik-meta-jwks` header, because it arrives with the request and the forger controls it.
|
||||
- Use the same alg allow list, iss, aud (the proxy provider's client id), exp and subject list.
|
||||
- The mosaic-preview provider signs with HS256 and has an empty JWKS, so its `X-authentik-jwt` fails every
|
||||
check until Jason gives it an RSA or EC key (Jason step 1).
|
||||
|
||||
### 8. Interim exposure until phase 2
|
||||
|
||||
Today, with `--send` live, any same-uid process on dragon-lin can connect to 127.0.0.1:7332 with
|
||||
`Host: mosaic.woltje.com`, `X-authentik-username: jason.woltje`, the matching Origin and
|
||||
`Sec-Fetch-Site: same-origin`, and send as Jason. That includes agent seats. Through the tunnel, the outpost
|
||||
strips client copies of the header, so a remote forger needs local code execution first. Users on other uids
|
||||
can reach the loopback port too (P19 333).
|
||||
|
||||
Recommendation: Mos restarts the Console without `--send` until phase 2. Reads stay up, the header path stops
|
||||
carrying writes, and only Jason's sends through the Console stop. That's a Mos call (Console start belongs to
|
||||
Jason or Mos). Until then, no agent sends a request with the auth header to 7332. The tests use fixture servers.
|
||||
|
||||
To be clear about the limit: SSO protects Console ingress. It doesn't protect the bus's human boundary. The
|
||||
human proof is path-bound (#225), so a same-uid process can already run the tree's `human-cli.mjs` and act
|
||||
as the human without touching the Console. Q175 doesn't change that. P19 333 owns it.
|
||||
|
||||
### 9. Tests
|
||||
|
||||
All tests run against a fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. It's a loopback https
|
||||
server with a self-signed cert. Each test passes the library a `customFetch` that trusts only that cert, so
|
||||
production code needs no insecure-request flag. The fake OP has discovery, a
|
||||
JWKS, and authorization, token and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an
|
||||
HMAC secret for the forgeries). No test reaches a real provider. Each refusal test asserts the status, that no
|
||||
`__Host-mosaic_session` was set, and that the bus send stub was never called. Each test also passes once
|
||||
unmutated, as the control.
|
||||
|
||||
Refusals Mos named:
|
||||
|
||||
1. Forged header. `X-authentik-username: jason.woltje` with `Host: mosaic.woltje.com`, the right Origin and
|
||||
Fetch Metadata, and no cookie: 401 on `/api/bus/message`, `/api/settings` and `/api/config`, and a 303 to
|
||||
login on `/`.
|
||||
2. HS256 ID token. The fake OP signs the ID token with HS256, using the client secret as the key (the classic
|
||||
confusion) and `kid` set to a real RSA kid: refused at the callback.
|
||||
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused.
|
||||
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's clock tolerance):
|
||||
refused.
|
||||
|
||||
More refusals:
|
||||
|
||||
5. Wrong `iss`, both a different issuer and a trailing-slash variant.
|
||||
6. Nonce mismatch or missing nonce.
|
||||
7. State mismatch, a missing login cookie, a replayed callback (record already taken), an expired login record,
|
||||
and two login cookies.
|
||||
8. A provider that demands no PKCE. The token request must carry `code_verifier`, and the fake OP refuses a
|
||||
wrong one.
|
||||
9. `alg: none`, and an RS256 token signed by a key not in the JWKS.
|
||||
10. A valid token for a `sub` not in `subjects`: 403 and one log line with iss and sub only.
|
||||
11. Startup refusals: HS-only discovery (the mosaic-preview shape), an empty JWKS, no S256, an issuer mismatch,
|
||||
a slot with mode 0644, and `--sso` together with `--auth-header`.
|
||||
12. Tester port: a valid session cookie (taken from a 7332 sign-in), `Authorization: Bearer <valid id token>`,
|
||||
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404.
|
||||
13. Session: idle expiry, absolute expiry, a logout followed by reuse of the old cookie, and logout without
|
||||
same-origin (refused, session kept).
|
||||
14. Open redirect: `return=//evil.example` and `return=https://evil.example` both land on `/`.
|
||||
|
||||
Positive paths: an RS256 and an ES256 provider each complete sign-in and one send, and two providers configured
|
||||
together each work with their own subjects.
|
||||
|
||||
Mutants for the gate: drop the `validateApplicationLevelSignature` call (test 2 or 9 must fail), drop the
|
||||
`subjects` check, accept HS* in `algs`, take the provider from the query, let the tester listener read
|
||||
cookies, and skip the login record delete.
|
||||
|
||||
### 10. Jason-gated steps
|
||||
|
||||
1. Authentik mosaic-preview: give it an RSA or EC signing key or retire it, and confirm by an admin read that
|
||||
client_id `GEQ84kBbt2RG6fhYQGgZl2J9jgjw6wRJ3oSWxqZQ` belongs to it. (Ask B matched it only on scopes and
|
||||
redirect_uri.)
|
||||
2. Authentik mosaic-stack provider: register redirect URI `https://mosaic.woltje.com/auth/callback` and
|
||||
post-logout URI `https://mosaic.woltje.com/auth/signed-out`, keep RS256, and give the client id. Put the
|
||||
client secret into slot `console-sso-authentik` (or say an agent may create the slot file from a value he
|
||||
puts in place, per the Q173 secrets rule). Agents never see the value.
|
||||
3. His `sub` for that provider, or a go to read it from the Console's one refusal log line on his first
|
||||
sign-in attempt.
|
||||
4. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
|
||||
later, so the Console is the only gate, changes who can reach the Console from outside and is his call.
|
||||
5. The phase 2 re-pin window go.
|
||||
|
||||
## Questions for review
|
||||
|
||||
- Q-A: the config location, business file `console` key or config.json (section 4).
|
||||
- Q-B: refuse to start when the IdP is unreachable, or start and refuse sign-ins (section 4).
|
||||
- Q-C: should the loopback-Host read path on 7332 also go behind the session, as proposed? Public traffic
|
||||
arrives from 127.0.0.1 too, and only Host keeps it off that path today.
|
||||
- Q-D: 8 h absolute and 1 h idle, or shorter.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Row 163 (D2): `stack-release.sh rehearse` and `bus start --no-launch`
|
||||
|
||||
Gitea #1660. Brief: docs/plans/2026-10-18_stack-release.md, "Stack rehearsal on a scratch root
|
||||
(D2)", blob b1500d96ad27. Reviewer: Filbert. Rulings: mos-0961, mos-0962, mos-0963.
|
||||
Base: 4f02486d5 (origin/refactor). D2 lands after the Part B window (P19 385), because Part B's
|
||||
window file names the stack-release.sh blob (mos-0958, mos-0959).
|
||||
|
||||
## What it does
|
||||
|
||||
`rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]` builds
|
||||
`<dataRoot>/rehearsals/<UTC second>-<copy 12>/` (0700) from a backup, runs a guard on the scratch
|
||||
config with the target copy's code, runs the migration checks when the schema differs, starts the
|
||||
target's `bus start BUSINESS --no-launch` and Console on the scratch root, gates them with D1's
|
||||
checks minus the PM's, checks that no session started, stops both, and compares the backup and the
|
||||
live config and business files with sums taken first. One PASS or FAIL line per check, then
|
||||
REHEARSAL_PASS (0) or REHEARSAL_FAIL (1). Usage exits 4. docs/TOOLS.md has the operator text.
|
||||
|
||||
`bus start --no-launch` (brief item 4): `start()` refuses `launches-disabled`, so every launch path
|
||||
spawns nothing; a session's launch request is refused and logged; `listen()` starts no thread
|
||||
ticker; `recover()` signals and ends nothing and logs what it left; the Registry is read-only, so
|
||||
sessions.json stays as an earlier host left it and `add()` refuses. The host-up line ends
|
||||
`, launches disabled`. `--pm` with it is a usage error. No unit passes it.
|
||||
|
||||
## Rulings carried
|
||||
|
||||
- mos-0961: `data-root-live` refuses a scratch dataRoot that resolves to the live data root;
|
||||
`sessions-live` refuses when a sessions.json in the scratch data root names a running pid. The run
|
||||
never deletes the file or retries. recover() under --no-launch is covered by the launcher test
|
||||
"bus start --no-launch leaves an earlier host's live session alone: no signal, no end,
|
||||
sessions.json as it was".
|
||||
- mos-0962: every tracker.* variable is stripped, business and role level; `business.diff` keeps the
|
||||
change; `tracker-configured` fails if bootConfig still sets trackers; notify.json is asserted to be
|
||||
exactly {"notifyVersion":1,"binding":null} at 0600.
|
||||
- mos-0963: every role credential reference and @sync's is moved to its own 0600 file under
|
||||
`<rehearsal>/placeholders/` holding `rehearsal-placeholder-token`; `credential-ref-live` fails for
|
||||
an env reference or a file whose real directory is not the placeholders directory. Test R9 leaves
|
||||
one role ref on a file outside placeholders and gets the FAIL; mutant K1 drops the check and R9
|
||||
fails.
|
||||
|
||||
## Outbound calls
|
||||
|
||||
The rehearsal reaches nothing off the host:
|
||||
|
||||
- reads: the live config.json, the live business file, the pointer, the backup, the copies
|
||||
directory, the target copy's roles/, git in the canonical checkout (`copy_ok`'s merge-base);
|
||||
- writes: only `<dataRoot>/rehearsals/<stamp>/`;
|
||||
- processes: the target's bus host (`--no-launch`) and Console (`serve --port N --business B`, no
|
||||
`--send`), both with `env -i HOME PATH MOSAIC_CONFIG=<scratch config>`;
|
||||
- network: 127.0.0.1:N for /healthz, and the host's sockets under the scratch data root.
|
||||
|
||||
No model call: launches are disabled and no session starts (`no-session`). No Vikunja: tracker.*
|
||||
is stripped and `tracker-configured` checks bootConfig. No Discord: notify binding null. No Gitea:
|
||||
every credential ref is a placeholder file, and the git socket serves only bound sessions, of
|
||||
which there are none.
|
||||
|
||||
`projects.stack.root` stays at the live checkout, /mnt/storage/src/mosaic-stack. The business
|
||||
file's projects are copied unchanged. Under --no-launch nothing makes a checkout from it, because
|
||||
checkouts are made only in `start()`, which refuses.
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
1. Item 1 says to copy "the business file, roles and organization file from the live config
|
||||
directory". Roles come from the target copy's `roles/`, as the live bus reads them (rolesDir is
|
||||
`<REPO>/roles` unless MOSAIC_ROLES_DIR is set, and `env -i` leaves it unset). The live config
|
||||
directory has no organization file; bootConfig's organization entry is the config dir itself,
|
||||
which here is the scratch config dir.
|
||||
2. The business file is rewritten, not copied (mos-0962, mos-0963), and `business.diff` records
|
||||
the change.
|
||||
3. The `tracker` key stays and `tracker.sync.credentials` becomes a placeholder: the business
|
||||
schema requires both, so stripping the key (mos-0962's wording) makes the loader refuse.
|
||||
bootConfig sets trackers only with tracker.baseUrl and a project's tracker.project, and both are
|
||||
stripped; `tracker-configured` checks the result. Authority: mos-0964 (2026-10-20T14:35:30Z),
|
||||
which accepts this and replaces the "@sync still present" clause of mos-0963 item 2:
|
||||
business.mjs:197 calls checkTracker on every load, and checkTracker requires tracker and
|
||||
tracker.sync (:57, :59) and checks sync.credentials (:63). @sync stays with its ref on a
|
||||
placeholder, and `credential-ref-live` covers it with the role refs. Evidence: R10 (a tracker
|
||||
still set fails `tracker-configured`), K2 (the check dropped, R10 fails), K10 (the tracker.*
|
||||
strip dropped, R1 fails on `tracker-configured`), and K9 (the @sync swap dropped, R1 fails on `credential-ref-live`).
|
||||
4. mosaic-config already refuses a scratch config that names the live data root lexically, because
|
||||
the rehearsal directory sits inside the live data root and a dataRoot may not contain the config
|
||||
dir. The guard's `data-root-live` catches the form that reaches the live root through a link
|
||||
(R11).
|
||||
5. Files owned: the brief lists stack-release.sh, its test, TOOLS.md and the packet. Item 4's flag
|
||||
adds packages/cli/src/cli.mjs, packages/cli/src/launcher.mjs, packages/seat/src/session.mjs and
|
||||
their tests (host.test.mjs, launcher.test.mjs, session.test.mjs).
|
||||
6. The brief's "free loopback port" is an operator-given `--port`, refused when reserved (7332,
|
||||
7333, the pointer's --port and --tester-port) or in use, never replaced by another port.
|
||||
7. Brief item 3's "in the foreground": the host runs as a child of the script for the length of
|
||||
the gate, then gets SIGTERM; it is not a unit and not detached.
|
||||
|
||||
## Tests
|
||||
|
||||
packages/cli/tests/stack-release.test.mjs, rehearse section (stand-in host, Console, migrate and
|
||||
Store in four fixture copies; real scripts/mosaic, mosaic-config, cli config and the business
|
||||
loader):
|
||||
|
||||
- R1 passes on the same schema: files, modes, placeholders, business.diff, the host's meta row in
|
||||
the scratch store and not in the backup, args logs, steps.log equal to the output, live files
|
||||
byte-identical.
|
||||
- R2 migration path 3g to 3h; R3 migrate fails.
|
||||
- R4 host fails, status fails, status names another pid, host launches, host keeps writer.lock.
|
||||
- R5 SIGTERM ignored: SIGKILL and FAIL host-stopped. R6 healthz. R7 copy-match.
|
||||
- R8 port in use, and 7332, 7333, the pointer's --port and --tester-port reserved.
|
||||
- R9 credential-ref-live (mos-0963). R10 tracker-configured (mos-0962). R11 data-root-live through
|
||||
a link and sessions-live on a running pid (mos-0961). R12 notify-off.
|
||||
- R13 old copy, backup missing, backup sums, no pointer, usage.
|
||||
|
||||
packages/cli/tests/launcher.test.mjs: recover() under --no-launch leaves an earlier host's live
|
||||
session alone; every launch is refused and logged; no thread tick (and the same host with
|
||||
launches runs it). packages/cli/tests/host.test.mjs: the host-up line, no sessions.json, --pm
|
||||
with --no-launch is usage, and no unit renders --no-launch. packages/seat/tests/session.test.mjs:
|
||||
a read-only Registry never writes and refuses add().
|
||||
|
||||
## Mutants
|
||||
|
||||
33, one per behaviour, each killed (mutants.py, mutants-run.log and mutants-rerun.log beside this file). The first run left K11
|
||||
(bus-status pid), K21 (scratch store linked to the backup), K27 (ticker under --no-launch) and
|
||||
K31 (cli drops launches); each got a test and the rerun killed all four. K21 now dies on
|
||||
FAIL backup-unchanged, the check it exists for.
|
||||
|
||||
## Round 2 (Filbert r1, comment 29445: F1-F3 changes, N1)
|
||||
|
||||
Filbert's six mutants M1-M6 survived the R tests. Each named a check that worked but that no test
|
||||
drove to FAIL. Round 2 adds those tests. stack-release.sh is unchanged (blob 0053f35d1360).
|
||||
|
||||
- F1 (M1, M2): R14. A new fixture fault, host-touches, has the host append to a file it lists.
|
||||
Three cases (the live config, the live business file, the backup's bus.sqlite) each give exactly
|
||||
one FAIL, live-config-unchanged or backup-unchanged, after the gate passed and the host stopped.
|
||||
- F2 (M3): R9 gains a pm gitea reference left on env REHEARSAL_GITEA_TOKEN, which fails
|
||||
credential-ref-live with "pm gitea is env REHEARSAL_GITEA_TOKEN" before the host starts.
|
||||
- F2 (M4): R11 gains a scratch config whose dataRoot is another existing directory ("resolves to
|
||||
X, not R/data") and one whose dataRoot is not there ("X does not exist"). Both fail
|
||||
data-root-scratch before the host starts.
|
||||
- F2 (M5): R4 gains host-launches-dir, a host that makes data/launches, which fails no-session
|
||||
with "found launches under".
|
||||
- F3 (M6): R15 sends TERM, INT and HUP to a rehearsal whose gate is still waiting on /healthz.
|
||||
Each gives FAIL signal, then console-stopped, host-stopped, host-released-store,
|
||||
backup-unchanged and live-config-unchanged all PASS, REHEARSAL_FAIL, exit 1, and the host's pid
|
||||
is gone. rehearse() gained an e.during hook that gets the child process.
|
||||
- N1: Registry.remove() wrote sessions.json under readOnly. #write() now returns at once when the
|
||||
registry is read-only, so the constructor, add() and remove() share one rule. The session test
|
||||
calls remove() on a read-only registry and checks that the file is unchanged.
|
||||
|
||||
Mutants K34-K41, one per finding and N1, are all killed (r2/mutants-run.log beside this file):
|
||||
K34 backup-unchanged always passes, K35 live-config-unchanged always passes, K36 env ref
|
||||
accepted, K37 data-root-scratch mismatch dropped, K38 missing root dropped, K39 launches dir
|
||||
ignored, K40 no INT/TERM/HUP trap, K41 read-only Registry writes.
|
||||
@@ -0,0 +1,10 @@
|
||||
04d72e5ef4d8d5fc85a4f11aa51331354696cc7cf66af2ed7540cf6cfc0153b9 agents/sage/work/queue-163/BUILD.md
|
||||
5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md
|
||||
52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs
|
||||
5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs
|
||||
e9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs
|
||||
42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs
|
||||
4dfeefa1bb3c11e8633d75df4f628497ebd427ebf8402ba5efc3e5c7fd030057 packages/cli/tests/stack-release.test.mjs
|
||||
5abdeab8bc6b63d291d0054124a4311a2260b590c68f758daa142a9deb2f4a72 packages/seat/src/session.mjs
|
||||
741883e399d77d429cc29a87beef6db9c51c1759669b025006f037001d101eb6 packages/seat/tests/session.test.mjs
|
||||
5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
base 580500699 dirty 0 DOCKER_HOST=unset
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3051.98676
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1933.965446
|
||||
pkg-cli rc=0 ℹ pass 470 ℹ fail 0 | ℹ duration_ms 337885.04401
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 585.587135
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33296.254589
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2624.332987
|
||||
pkg-harness rc=0 ℹ pass 133 ℹ fail 0 | ℹ duration_ms 11808.615207
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12902.241615
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2051.760841
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 247.493374
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1031.233595
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 727.865986
|
||||
pkg-seat rc=0 ℹ pass 152 ℹ fail 0 | ℹ duration_ms 5858.066276
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1147.333901
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 46548.273469
|
||||
script-auth rc=0 | selftest: 15 passed, 0 failed
|
||||
script-conductor rc=0 | selftest: 17 passed, 0 failed
|
||||
script-config rc=0 | selftest: 24 passed, 0 failed
|
||||
script-discord rc=0 | discord suite: 78 passed, 0 failed
|
||||
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
|
||||
script-foundation rc=0 | selftest: 44 passed, 0 failed
|
||||
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
|
||||
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
|
||||
script-release rc=0 | selftest: 14 passed, 0 failed
|
||||
script-task rc=0 | selftest: 98 passed, 0 failed
|
||||
GATE_PASSED
|
||||
removed network sage-r163_default
|
||||
@@ -0,0 +1,5 @@
|
||||
KILLED K11 bus-status pid check dropped (rc 1)
|
||||
KILLED K21 scratch store links the backup (rc 1)
|
||||
KILLED K27 ticker runs under --no-launch (rc 1)
|
||||
KILLED K31 cli --no-launch not passed to the launcher (rc 1)
|
||||
ALL_KILLED
|
||||
@@ -0,0 +1,34 @@
|
||||
KILLED K1 credential-ref-live dropped (rc 1)
|
||||
KILLED K2 tracker-configured dropped (rc 1)
|
||||
KILLED K3 data-root-live dropped (rc 1)
|
||||
KILLED K4 sessions-live dropped (rc 1)
|
||||
KILLED K5 notify-off dropped (rc 1)
|
||||
KILLED K6 no-session dropped (rc 1)
|
||||
KILLED K7 copy-match dropped (rc 1)
|
||||
KILLED K8 role credential swap dropped (rc 1)
|
||||
KILLED K9 @sync swap dropped (rc 1)
|
||||
KILLED K10 business tracker strip dropped (rc 1)
|
||||
SURVIVED K11 bus-status pid check dropped (rc 0)
|
||||
KILLED K12 host-released-store dropped (rc 1)
|
||||
KILLED K13 SIGKILL counted as a pass (rc 1)
|
||||
KILLED K14 healthz always ok (rc 1)
|
||||
KILLED K15 port-reserved dropped (rc 1)
|
||||
KILLED K16 port-in-use dropped (rc 1)
|
||||
KILLED K17 backup-sums dropped (rc 1)
|
||||
KILLED K18 copy-without-no-launch dropped (rc 1)
|
||||
KILLED K19 migration skipped (rc 1)
|
||||
KILLED K20 migrate failure ignored (rc 1)
|
||||
SURVIVED K21 scratch store links the backup (rc 0)
|
||||
KILLED K22 scratch config keeps the live dataRoot (rc 1)
|
||||
KILLED K23 host started without --no-launch (rc 1)
|
||||
KILLED K24 start() launches under --no-launch (rc 1)
|
||||
KILLED K25 session launch request allowed under --no-launch (rc 1)
|
||||
KILLED K26 recover() ends sessions under --no-launch (rc 1)
|
||||
SURVIVED K27 ticker runs under --no-launch (rc 0)
|
||||
KILLED K28 registry writes under --no-launch (rc 1)
|
||||
KILLED K29 Registry readOnly writes anyway (rc 1)
|
||||
KILLED K30 Registry readOnly add() allowed (rc 1)
|
||||
SURVIVED K31 cli --no-launch not passed to the launcher (rc 0)
|
||||
KILLED K32 cli --pm with --no-launch allowed (rc 1)
|
||||
KILLED K33 cli host-up line drops launches disabled (rc 1)
|
||||
SOME_SURVIVED
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
# Row 163 mutants: each one drops or weakens one behaviour; the named tests must then fail.
|
||||
import hashlib, os, shutil, subprocess, sys, signal
|
||||
W = '/mnt/storage/scratch/sage-b163'
|
||||
OUT = os.environ.get('MUT_OUT', '/mnt/storage/scratch/sage-b163-gate/mutants')
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
SR = 'scripts/stack-release.sh'
|
||||
T = 'packages/cli/tests/stack-release.test.mjs'
|
||||
M = [
|
||||
('K1 credential-ref-live dropped', SR, 'if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live"', 'if(false)fail("credential-ref-live"', T, '^R9 '),
|
||||
('K2 tracker-configured dropped', SR, 'if(config.trackers!==undefined)fail(', 'if(false)fail(', T, '^R10 '),
|
||||
('K3 data-root-live dropped', SR, 'if(root===real(live))fail(', 'if(false)fail(', T, '^R11 '),
|
||||
('K4 sessions-live dropped', SR, 'if(running.length)fail(', 'if(false)fail(', T, '^R11 '),
|
||||
('K5 notify-off dropped', SR, 'fail("notify-off",`${nf} is not {', 'void("notify-off",`${nf} is not {', T, '^R12 '),
|
||||
('K6 no-session dropped', SR, 'if [ -z "$found" ]; then r_pass no-session', 'if true; then r_pass no-session', T, '^R4 '),
|
||||
('K7 copy-match dropped', SR, ' R_GATE=copy-match\n', ' R_GATE=""; return 0\n', T, '^R7 '),
|
||||
('K8 role credential swap dropped', SR, 'strip(r?.vars);swap(name,r?.credentials)}', 'strip(r?.vars)}', T, '^R1 '),
|
||||
('K9 @sync swap dropped', SR, 'swap("@sync",b.tracker?.sync?.credentials);', '', T, '^R1 '),
|
||||
('K10 business tracker strip dropped', SR, '\nstrip(b.vars);\n', '\n', T, '^R1 '),
|
||||
('K11 bus-status pid check dropped', SR, 'String(s.host.pid)!==main?`host pid ${s.host.pid} is not ${main}, the host this run', 'false?`host pid ${s.host.pid} is not ${main}, the host this run', T, '^R4 '),
|
||||
('K12 host-released-store dropped', SR, 'if [ -e "$R/data/bus/writer.lock" ]; then', 'if false; then', T, '^R4 '),
|
||||
('K13 SIGKILL counted as a pass', SR, 'kill -KILL "$pid" 2>/dev/null || true; r_fail "$name-stopped"', 'kill -KILL "$pid" 2>/dev/null || true; r_pass "$name-stopped"', T, '^R5 '),
|
||||
('K14 healthz always ok', SR, '.then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));\' -- "$R_PORT"', '.then(()=>process.exit(0),()=>process.exit(0));\' -- "$R_PORT"', T, '^R6 '),
|
||||
('K15 port-reserved dropped', SR, '[ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved', 'true || r_abort port-reserved', T, '^R8 '),
|
||||
('K16 port-in-use dropped', SR, 'r_port_free "$R_PORT" || r_abort port-in-use', 'true || r_abort port-in-use', T, '^R8 '),
|
||||
('K17 backup-sums dropped', SR, 'sha256sum -c --quiet ../SHA256SUMS) >/dev/null 2>&1 ||', 'true) >/dev/null 2>&1 ||', T, '^R13 '),
|
||||
('K18 copy-without-no-launch dropped', SR, 'grep -qF -- \'"--no-launch"\' "$COPIES/$TO/packages/cli/src/cli.mjs" 2>/dev/null ||', 'true ||', T, '^R13 '),
|
||||
('K19 migration skipped', SR, 'if [ "$v" = "$T_SCHEMA" ]; then r_pass migration', 'if true; then r_pass migration', T, '^R2 '),
|
||||
('K20 migrate failure ignored', SR, '[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate "', 'true || r_abort migrate "', T, '^R3 '),
|
||||
('K21 scratch store links the backup', SR, 'cp "$R_BK"/bus/* "$R/data/bus/" &&', 'ln -s "$R_BK"/bus/* "$R/data/bus/" &&', T, '^R1 '),
|
||||
('K22 scratch config keeps the live dataRoot', SR, 'c.dataRoot=data;', '', T, '^R1 '),
|
||||
('K23 host started without --no-launch', SR, '"$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch >', '"$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" >', T, '^R1 '),
|
||||
('K24 start() launches under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");\n', '', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K25 session launch request allowed under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) {\n await host.op({ op: "refuse", cap, code: "launches-disabled" })', ' if (false) {\n await host.op({ op: "refuse", cap, code: "launches-disabled" })', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K26 recover() ends sessions under --no-launch', 'packages/cli/src/launcher.mjs', ' if (!launches) {\n for (const s of leftover) log(', ' if (false) {\n for (const s of leftover) log(', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K27 ticker runs under --no-launch', 'packages/cli/src/launcher.mjs', 'if (b.launch && launches) ticker', 'if (b.launch) ticker', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K28 registry writes under --no-launch', 'packages/cli/src/launcher.mjs', 'new Registry(dataRoot, { readOnly: !launches })', 'new Registry(dataRoot)', 'packages/cli/tests/launcher.test.mjs', None),
|
||||
('K29 Registry readOnly writes anyway', 'packages/seat/src/session.mjs', ' if (!readOnly) this.#write();', ' this.#write();', 'packages/seat/tests/session.test.mjs', None),
|
||||
('K30 Registry readOnly add() allowed', 'packages/seat/src/session.mjs', ' if (this.#readOnly) throw new SeatError("this host keeps no sessions (--no-launch)", 2);\n', '', 'packages/seat/tests/session.test.mjs', None),
|
||||
('K31 cli --no-launch not passed to the launcher', 'packages/cli/src/cli.mjs', 'tracker: Boolean(boot.trackers?.[businessId]), launches, ', 'tracker: Boolean(boot.trackers?.[businessId]), ', 'packages/cli/tests/host.test.mjs', None),
|
||||
('K32 cli --pm with --no-launch allowed', 'packages/cli/src/cli.mjs', ' if (!launches && parsed.flags.has("--pm")) throw usage(', ' if (false) throw usage(', 'packages/cli/tests/host.test.mjs', None),
|
||||
('K33 cli host-up line drops launches disabled', 'packages/cli/src/cli.mjs', '${launches ? "" : ", launches disabled"}', '', 'packages/cli/tests/host.test.mjs', None),
|
||||
('K34 backup-unchanged always passes', SR, 'if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged', 'if true; then r_pass backup-unchanged', T, '^R14 '),
|
||||
('K35 live-config-unchanged always passes', SR, 'if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then', 'if true; then', T, '^R14 '),
|
||||
('K36 credential-ref-live accepts an env ref', SR, 'if(ref?.env!==undefined)fail("credential-ref-live"', 'if(false)fail("credential-ref-live"', T, '^R9 '),
|
||||
('K37 data-root-scratch mismatch dropped', SR, 'if(root!==real(scratch))fail(', 'if(false)fail(', T, '^R11 '),
|
||||
('K38 data-root-scratch missing root dropped', SR, 'if(root===null)fail("data-root-scratch"', 'if(false)fail("data-root-scratch"', T, '^R11 '),
|
||||
('K39 no-session ignores a launches dir', SR, '[ ! -e "$R/data/launches" ] || found+=" launches"', 'true || found+=" launches"', T, '^R4 '),
|
||||
('K40 no INT/TERM/HUP trap', SR, ' trap \'r_fail signal "the rehearsal was interrupted"; r_end\' INT TERM HUP\n', '', T, '^R15 '),
|
||||
('K41 read-only Registry writes', 'packages/seat/src/session.mjs', ' if (this.#readOnly) return;\n', '', 'packages/seat/tests/session.test.mjs', None),
|
||||
]
|
||||
sha = lambda p: hashlib.sha256(open(p, 'rb').read()).hexdigest()
|
||||
env = dict(os.environ, TMPDIR='/mnt/storage/scratch/sb163t')
|
||||
results = []
|
||||
ONLY = set(sys.argv[1:])
|
||||
for name, f, old, new, test, pat in M:
|
||||
if ONLY and name.split()[0] not in ONLY: continue
|
||||
p = os.path.join(W, f); bak = os.path.join(OUT, os.path.basename(f) + '.orig')
|
||||
shutil.copy2(p, bak)
|
||||
if sha(bak) != sha(p): sys.exit(f'backup of {p} failed')
|
||||
src = open(p).read()
|
||||
if src.count(old) != 1: sys.exit(f'{name}: pattern count {src.count(old)} in {f}')
|
||||
open(p, 'w').write(src.replace(old, new))
|
||||
cmd = ['node', '--test'] + (['--test-name-pattern', pat] if pat else []) + [test]
|
||||
log = os.path.join(OUT, name.split()[0] + '.log')
|
||||
try:
|
||||
with open(log, 'w') as lf:
|
||||
pr = subprocess.Popen(cmd, cwd=W, env=env, stdout=lf, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try: rc = pr.wait(timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pr.pid, signal.SIGKILL); pr.wait(); rc = 'timeout'
|
||||
finally:
|
||||
shutil.copy2(bak, p)
|
||||
if sha(bak) != sha(p): sys.exit(f'restore of {p} failed')
|
||||
killed = rc != 0
|
||||
results.append((name, killed, rc))
|
||||
print(f'{"KILLED" if killed else "SURVIVED"} {name} (rc {rc})', flush=True)
|
||||
print('ALL_KILLED' if all(k for _, k, _ in results) else 'SOME_SURVIVED')
|
||||
@@ -0,0 +1,48 @@
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (10205.967212ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 10820.998717
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2062:1
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (10205.967212ms)
|
||||
AssertionError [ERR_ASSERTION]: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/backups/stack/20261020T000000Z-2a52abd7922e/bus/bus.sqlitePASS copy: 2a52abd7922ec2ce073c2b481b490e984630e9ce, schema 3g
|
||||
PASS port: 127.0.0.1:38065 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/backups/stack/20261020T000000Z-2a52abd7922e matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 2a52abd7922ec2ce073c2b481b490e984630e9ce
|
||||
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e/data
|
||||
PASS console-stopped: pid 3546679
|
||||
PASS host-stopped: pid 3546678
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-V3yNfh/data/rehearsals/20261020T150815Z-2a52abd7922e
|
||||
|
||||
|
||||
0 !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2066:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: 0,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (2394.880127ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 3000.67202
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2062:1
|
||||
✖ R14 a host that writes the live config, the live business file or the backup fails the end check by name (2394.880127ms)
|
||||
AssertionError [ERR_ASSERTION]: /mnt/storage/scratch/sb163t/sr-VQM0Df/cfg/config.jsonPASS copy: bf4bac79a205e5010c48897456fc6e665a3cf9aa, schema 3g
|
||||
PASS port: 127.0.0.1:45755 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/backups/stack/20261020T000000Z-bf4bac79a205 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on bf4bac79a205e5010c48897456fc6e665a3cf9aa
|
||||
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205/data
|
||||
PASS console-stopped: pid 3547161
|
||||
PASS host-stopped: pid 3547160
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-VQM0Df/data/rehearsals/20261020T150820Z-bf4bac79a205
|
||||
|
||||
|
||||
0 !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2066:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: 0,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
✖ R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts (609.420853ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1216.913248
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:1971:1
|
||||
✖ R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts (609.420853ms)
|
||||
AssertionError [ERR_ASSERTION]: PASS copy: f2cd6e0875411f82c798b2a541604410cc77d2c7, schema 3g
|
||||
PASS port: 127.0.0.1:41709 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/backups/stack/20261020T000000Z-f2cd6e087541 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
FAIL credential-ref-live: pm gitea is undefined, not a file in /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541/placeholders
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_FAIL
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-TRrjEx/data/rehearsals/20261020T150823Z-f2cd6e087541
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:1990:9)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (605.865597ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1061.260755
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2006:1
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (605.865597ms)
|
||||
AssertionError [ERR_ASSERTION]: other-rootPASS copy: 236c2e27d28711316d96143968ae477b7fa7b7e7, schema 3g
|
||||
PASS port: 127.0.0.1:37319 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/backups/stack/20261020T000000Z-236c2e27d287 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-aZQJrB/other-root is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
FAIL notify-off: /mnt/storage/scratch/sb163t/sr-aZQJrB/other-root/notify/acme/notify.json is missing or not JSON
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_FAIL
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-aZQJrB/data/rehearsals/20261020T150824Z-236c2e27d287
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2021:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (916.097165ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1239.364874
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2006:1
|
||||
✖ R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name (916.097165ms)
|
||||
AssertionError [ERR_ASSERTION]: no-rootPASS copy: 998d46411d6c1d3168396f7bb5932e1e200a9263, schema 3g
|
||||
PASS port: 127.0.0.1:43579 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/backups/stack/20261020T000000Z-998d46411d6c matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-v0EeC4/no-root is not the live data root
|
||||
FAIL guard: The "path" argument must be of type string. Received null
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_FAIL
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-v0EeC4/data/rehearsals/20261020T150825Z-998d46411d6c
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2021:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: '==',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
✖ R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name (18892.096194ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 19506.873236
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:1920:1
|
||||
✖ R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name (18892.096194ms)
|
||||
AssertionError [ERR_ASSERTION]: host-launches-dirPASS copy: 2cb8943db81ed8e914000449977627f0a9d42d00, schema 3g
|
||||
PASS port: 127.0.0.1:43729 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/backups/stack/20261020T000000Z-2cb8943db81e matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 2cb8943db81ed8e914000449977627f0a9d42d00
|
||||
PASS no-session: no sessions.json and no launches under /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e/data
|
||||
PASS console-stopped: pid 3550603
|
||||
PASS host-stopped: pid 3550602
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /mnt/storage/scratch/sb163t/sr-D2Wt2k/data/rehearsals/20261020T150843Z-2cb8943db81e
|
||||
|
||||
|
||||
0 !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:1924:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: 0,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
✖ R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks (1289.845744ms)
|
||||
ℹ tests 1
|
||||
ℹ suites 0
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1749.897862
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/stack-release.test.mjs:2073:1
|
||||
✖ R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks (1289.845744ms)
|
||||
AssertionError [ERR_ASSERTION]: SIGTERMPASS copy: ba3ad437eb120f80d3a1bdc438860968dd038962, schema 3g
|
||||
PASS port: 127.0.0.1:45847 is free
|
||||
PASS backup: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/backups/stack/20261020T000000Z-ba3ad437eb12 matches its SHA256SUMS
|
||||
PASS scratch-root: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/data from the backup, config and business file under /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/config, business.diff beside them
|
||||
PASS data-root-live: /mnt/storage/scratch/sb163t/sr-Bin1lp/data/rehearsals/20261020T150846Z-ba3ad437eb12/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3g
|
||||
|
||||
|
||||
null !== 1
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/cli/tests/stack-release.test.mjs:2079:10)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: null,
|
||||
expected: 1,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
✔ REQ-LIMIT-1: family substring matching is no longer exported (0.993829ms)
|
||||
✔ sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id (0.643651ms)
|
||||
✔ sessionExit gives a runner's signal death back as the signal; a session credential is one whole set of nonempty variables (0.22563ms)
|
||||
✔ newRun: short ids, 0700 directories, and a refusal when the socket path won't fit (1.390202ms)
|
||||
✔ session file and launch log: 0600, the session file written once (0.890354ms)
|
||||
✔ endReason maps the runner's exit codes; a signal is killed (0.110674ms)
|
||||
✔ Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse (1.151527ms)
|
||||
✖ a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove() (1.357741ms)
|
||||
✔ REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread (0.526184ms)
|
||||
✔ REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory (1.243195ms)
|
||||
✔ stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner (0.781332ms)
|
||||
✔ a session runs in its sandbox as pid 1 of its namespace, claims, answers, and stops on mosaic stop (192.679392ms)
|
||||
ℹ tests 12
|
||||
ℹ suites 0
|
||||
ℹ pass 11
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 266.973886
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/seat/tests/session.test.mjs:137:1
|
||||
✖ a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove() (1.357741ms)
|
||||
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
|
||||
+ actual - expected
|
||||
|
||||
+ '{\n "sessionsVersion": 1,\n "sessions": []\n}\n'
|
||||
- '{\n' +
|
||||
- ' "sessionsVersion": 1,\n' +
|
||||
- ' "sessions": [\n' +
|
||||
- ' {\n' +
|
||||
- ' "business": "acme",\n' +
|
||||
- ' "instance": "chief",\n' +
|
||||
- ' "run": "r1",\n' +
|
||||
- ' "runnerPid": 3551219,\n' +
|
||||
- ' "runnerStartTime": "61011768"\n' +
|
||||
- ' }\n' +
|
||||
- ' ]\n' +
|
||||
- '}\n'
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/sage-b163/packages/seat/tests/session.test.mjs:142:10)
|
||||
at Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
at Test.run (node:internal/test_runner/test:1402:25)
|
||||
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
|
||||
at Test.postRun (node:internal/test_runner/test:1542:19)
|
||||
at Test.run (node:internal/test_runner/test:1467:12)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: true,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: '{\n "sessionsVersion": 1,\n "sessions": []\n}\n',
|
||||
expected: '{\n "sessionsVersion": 1,\n "sessions": [\n {\n "business": "acme",\n "instance": "chief",\n "run": "r1",\n "runnerPid": 3551219,\n "runnerStartTime": "61011768"\n }\n ]\n}\n',
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
f04d12a963b3199420f1d9c41fbdae67faf46f710a805cb9358f1fb52f5faed9 agents/sage/work/queue-163/BUILD.md
|
||||
5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md
|
||||
52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs
|
||||
5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs
|
||||
e9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs
|
||||
42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs
|
||||
b01ff656962c6fdb5bc3554cfbb13645b7f25adae5f6cd9608af8de6d6091c6c packages/cli/tests/stack-release.test.mjs
|
||||
448138d12404346989a6751a01c747718cef7d2bec19a0639a29fa6a8b74a82d packages/seat/src/session.mjs
|
||||
cd6277c4e7155a7c93d09f5bc7cca1130708a35a6b75aa02d99af8360d6338b0 packages/seat/tests/session.test.mjs
|
||||
5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
base 2a5f3a3a0 dirty 0 DOCKER_HOST=unset
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 2970.304395
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1953.76274
|
||||
pkg-cli rc=0 ℹ pass 472 ℹ fail 0 | ℹ duration_ms 367258.38335
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 585.394845
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33402.754981
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2610.108892
|
||||
pkg-harness rc=0 ℹ pass 133 ℹ fail 0 | ℹ duration_ms 11612.167166
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12928.162376
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2092.404214
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 250.471164
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1069.844547
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 732.731695
|
||||
pkg-seat rc=0 ℹ pass 152 ℹ fail 0 | ℹ duration_ms 5889.377731
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1159.016661
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 54984.81726
|
||||
script-auth rc=0 | selftest: 15 passed, 0 failed
|
||||
script-conductor rc=0 | selftest: 17 passed, 0 failed
|
||||
script-config rc=0 | selftest: 24 passed, 0 failed
|
||||
script-discord rc=0 | discord suite: 78 passed, 0 failed
|
||||
script-extension-package rc=0 | extension package selftest: 18 passed, 0 failed
|
||||
script-foundation rc=0 | selftest: 44 passed, 0 failed
|
||||
script-land-work rc=0 | land-work selftests: 86 passed, 0 failed
|
||||
script-queue rc=0 ℹ pass 156 ℹ fail 0 | queue suite: 28 passed, 0 failed
|
||||
script-release rc=0 | selftest: 14 passed, 0 failed
|
||||
script-task rc=0 | selftest: 98 passed, 0 failed
|
||||
GATE_PASSED
|
||||
removed network sage-r163_default
|
||||
@@ -0,0 +1,9 @@
|
||||
KILLED K34 backup-unchanged always passes (rc 1)
|
||||
KILLED K35 live-config-unchanged always passes (rc 1)
|
||||
KILLED K36 credential-ref-live accepts an env ref (rc 1)
|
||||
KILLED K37 data-root-scratch mismatch dropped (rc 1)
|
||||
KILLED K38 data-root-scratch missing root dropped (rc 1)
|
||||
KILLED K39 no-session ignores a launches dir (rc 1)
|
||||
KILLED K40 no INT/TERM/HUP trap (rc 1)
|
||||
KILLED K41 read-only Registry writes (rc 1)
|
||||
ALL_KILLED
|
||||
@@ -0,0 +1,86 @@
|
||||
# Row 189: `stack-release.sh activate --no-send`
|
||||
|
||||
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
|
||||
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
|
||||
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
|
||||
brief commit on 9ae8c73ac).
|
||||
|
||||
## What it does
|
||||
|
||||
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
|
||||
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
|
||||
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
|
||||
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
|
||||
(exit 4).
|
||||
|
||||
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
|
||||
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
|
||||
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
|
||||
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
|
||||
refuses `already-active`.
|
||||
|
||||
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
|
||||
|
||||
- `send: dropped; --no-send was given`
|
||||
- `send: off; --no-send was given and the pointer holds no --send`
|
||||
- `send: carried` (the pointer's `--send` goes on unchanged)
|
||||
- `send: off`
|
||||
|
||||
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
|
||||
--send, so there is nothing to drop`.
|
||||
|
||||
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
|
||||
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
|
||||
own row.
|
||||
|
||||
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
|
||||
|
||||
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
|
||||
|
||||
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
|
||||
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
|
||||
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
|
||||
backup sum equals the stopped store's, and the units stop and start in order.
|
||||
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
|
||||
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
|
||||
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
|
||||
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
|
||||
and no drop line.
|
||||
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
|
||||
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
|
||||
keeps it off.
|
||||
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
|
||||
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
|
||||
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
|
||||
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
|
||||
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
|
||||
|
||||
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
|
||||
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
|
||||
|
||||
## Mutants
|
||||
|
||||
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
|
||||
plus T1 and T2.
|
||||
|
||||
- M1: the same-copy exception ignores `--no-send`.
|
||||
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
|
||||
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
|
||||
drops it.
|
||||
- M4: rollback re-adds `--send`.
|
||||
- M5: the parser accepts `--no-send` for rollback.
|
||||
|
||||
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
|
||||
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
|
||||
:828).
|
||||
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
|
||||
|
||||
## Outbound calls
|
||||
|
||||
None. The change edits an array before the existing unit render and pointer write. Every test uses
|
||||
the stand-in systemctl, units, stores and data root the file already builds; no test touches
|
||||
`~/.mosaic-dev`, the live units or a slot file.
|
||||
@@ -0,0 +1,5 @@
|
||||
M1 KILLED fail=5
|
||||
M2 KILLED fail=2
|
||||
M3 KILLED fail=7
|
||||
M4 KILLED fail=2
|
||||
M5 KILLED fail=2
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
|
||||
# the plan-tail tests, and restores the file from a checked backup.
|
||||
set -uo pipefail
|
||||
WT=/mnt/storage/scratch/sage-b189
|
||||
OUT=/mnt/storage/scratch/sage-b189-mut/out
|
||||
S=$WT/scripts/stack-release.sh
|
||||
mkdir -p "$OUT" || exit 1
|
||||
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
|
||||
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
|
||||
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
|
||||
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
|
||||
trap restore EXIT
|
||||
|
||||
mutate() {
|
||||
case "$1" in
|
||||
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
|
||||
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
|
||||
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
|
||||
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
|
||||
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for m in M1 M2 M3 M4 M5; do
|
||||
restore
|
||||
mutate "$m"
|
||||
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
|
||||
bash -n "$S" || { echo "$m SYNTAX"; continue; }
|
||||
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
|
||||
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
|
||||
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
|
||||
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
|
||||
done
|
||||
@@ -1,17 +1,21 @@
|
||||
b99e7f4bf58ac68c8f5fa58eb7b90c0f85b15ca9a80fd093f6aaeafeac931608 adapters/README.md
|
||||
91cdf256bd49e4f32d5df023544d18e0f13635bcb8b744fd84fdf84cdf02b1a7 agents/sage/work/queue-66/BUILD.md
|
||||
7360f270290fbcca7f80480bc28d8f9aac7eb64ff0fdcf8f6b37a8244a76df31 packages/harness/README.md
|
||||
833216ef467bc3be6f0de8c2f82a7270ac67a578226353063688609c8b60e360 packages/harness/src/runner.mjs
|
||||
c32d2a3276db92433d4e3c4f4b45fab048d9a46119d9f69cead6a11cfacfeadb agents/sage/work/queue-66/BUILD.md
|
||||
8777fac3acb89f3af4ac0db2e7c44e309408a8ab8d1b0a0942c81c54ad295fa9 docs/TOOLS.md
|
||||
ea11398dc5751a0f9415079e7c03f413e97f5443f6bd0fc1fc2319ec646c20cb packages/cli/tests/stack-copy.test.mjs
|
||||
b027dc74583217b4151173ecbabfcfbfc7b108ac6b7a8e93dd21a2834051714f packages/harness/README.md
|
||||
bfb2156c609eeaf87c9e209c562576a5ef47535eaeee61d841bb0bd75a76a69d packages/harness/src/runner.mjs
|
||||
0a27f2c8de7b9191f9c0d4a209331c63ff7630923e669838b41715924b0a64b0 packages/harness/tests/bundle.test.mjs
|
||||
a14afae1a0e6b0dc12cfd7fdd5f13dddcc114549b633825eb3a2bfc65ac03111 packages/harness/tests/claude-session.test.mjs
|
||||
b7307b1a4852d48c84807942df11fab4b38c3eba5894052dc24728177ddf8326 packages/harness/tests/fixtures/fake-adapter.mjs
|
||||
880c72809bf6bfd5b71a017fdc4f485a52bf88951607992874be1ebd8fea4a8c packages/harness/tests/pi-session.test.mjs
|
||||
f52d4f6d2fb1ffe775e0dbedd72d39708cc8eb79e7769ff10b313359275aee95 packages/harness/tests/runner.test.mjs
|
||||
81d0352f2dcd41eb2e2ef962365d9b97515d7e8d9668a35bb9ce9dce4f1451ea packages/harness/tests/claude-session.test.mjs
|
||||
b16f33442cefd5b5ae2af85edab33250ee6b31025e437edf0e4eba9fe0afa793 packages/harness/tests/fixtures/fake-adapter.mjs
|
||||
1e187a2e23a197640d139e6975dfaf83dd7e337c360d128833678dc60f3e2046 packages/harness/tests/pi-session.test.mjs
|
||||
37ca6ad6467573658c1ac6807b6cd00e3f9da289107fb689190a1872bff9b89c packages/harness/tests/runner.test.mjs
|
||||
04c24058f37260c9192d5036c8cf28b50faba8e3e3bf57f198d233485d0d57e6 packages/plugins/harness/claude-code/index.mjs
|
||||
2c1252e8e8db3ba4551bf1e34156ddf4e933cc664fc157105bef9a2705d6ce3b packages/plugins/harness/pi/index.mjs
|
||||
7e0bea27d4360a39874fa64b3506a85f83888613a285ec0b087d74bf22b76421 packages/plugins/README.md
|
||||
ec4ec9502ee8e766e3abd04d9f724c0d8ff4ef114394a0f76d5320c2dc715c5c packages/plugins/tests/harness.test.mjs
|
||||
f6141dac9a8c61573e4198480fed85e093992ee9c19fb55e9bc2752381b7b07a packages/seat/tests/sandbox.test.mjs
|
||||
e60bf54c23e04436a982174348857458039a22e1769fc9ad4db8d4f9b57f2e86 packages/webui/tests/pinned-copy.test.mjs
|
||||
3ba3dbd9ff8dd68f0aa85bac92a8960af10166130bbc16df74fd7340776bb3b1 scripts/stack-copy.sh
|
||||
7ee6ed6e6ac55b1348d40a358372e66c86fdebacd90001d447040133e1187882 tools/unslop-hook/claude-hook.js
|
||||
ce1dfb11d0a8e8efd3d6dae67cc259c2e7443a62c7416bdedd4c71306d8c6df1 tools/unslop-hook/extension.ts
|
||||
f07d67806f40763b4be87d31c2976448b5cfc34d7328519fae2d8313b1ca3e20 tools/unslop-hook/README.md
|
||||
|
||||
@@ -33,10 +33,10 @@ index 7636fd8c9..94ae12fb8 100644
|
||||
+switch the check off or swap its lists.
|
||||
diff --git a/agents/sage/work/queue-66/BUILD.md b/agents/sage/work/queue-66/BUILD.md
|
||||
new file mode 100644
|
||||
index 000000000..1031a901a
|
||||
index 000000000..99097f192
|
||||
--- /dev/null
|
||||
+++ b/agents/sage/work/queue-66/BUILD.md
|
||||
@@ -0,0 +1,101 @@
|
||||
@@ -0,0 +1,149 @@
|
||||
+# Row 66: managed sessions run the unslop check on both harnesses
|
||||
+
|
||||
+Sage, 2026-10-20. Brief: `docs/plans/2026-10-11_managed-session-config.md`, section "Managed
|
||||
@@ -65,8 +65,8 @@ index 000000000..1031a901a
|
||||
+
|
||||
+**The operator channel.** The runner sets `MOSAIC_UNSLOP_LOG` to `<run>/unslop.jsonl` after the
|
||||
+module's variables, so a module can't point it elsewhere. After the landing turn and after each message turn it reads what the
|
||||
+log gained (whole lines only, from an offset). The next RESULT it sends ends with a note of at
|
||||
+most 600 characters:
|
||||
+log gained (whole lines only, from an offset). The next RESULT it sends ends with a note. The
|
||||
+whole note, both parts and the blank line before them, is at most 600 characters (r2):
|
||||
+
|
||||
+- `[unslop: N reply|replies of this session flagged since the last note: <details>. The session gets a correction notice on its next turn.]`
|
||||
+- `[unslop: the check is broken, so this session's replies go unchecked: <reason>]`, once a run.
|
||||
@@ -74,13 +74,58 @@ index 000000000..1031a901a
|
||||
+That RESULT is what `mosaic talk` and `mosaic follow` print. A turn that sends no reply (an
|
||||
+incoming RESULT, the landing turn) keeps its note for the next one. `note()` doesn't change
|
||||
+state and `sent()` runs only after `message.send` succeeds, so a failed send keeps the note.
|
||||
+`clip()` caps the body at 32000 characters before the note, so the sum stays under the bus
|
||||
+limit of 32768.
|
||||
+`clip()` caps the body at 32000 characters plus its 33-character cut line before the note, so the
|
||||
+sum is at most 32633, under the bus limit of 32768. The broken reason is cut at 300 where the
|
||||
+runner takes it, from the log or from its own read failure, so the broken part is at most 371
|
||||
+characters and the flagged part is cut to what is left.
|
||||
+
|
||||
+**Counts.** `unslopCounts(path)` reads the log after the session ended:
|
||||
+`{checked, flagged, patterns: {<source>: n}, notices, broken}`. A missing log gives zeros.
|
||||
+Findings now carry `source`, the entry's pattern id (`ms-unslop:7`), so `patterns` is per id.
|
||||
+
|
||||
+**The stack copy (r2).** `scripts/stack-copy.sh` `COPY_PATHS` gains `tools/unslop-hook`. Both
|
||||
+modules join their files under `REPO`, and a session launched from a pinned copy has
|
||||
+`REPO = copies/<sha>`. Without it every managed session from a copy past this row would fail at
|
||||
+bundle build with ENOENT (Dewey P1, comment 29440).
|
||||
+
|
||||
+## Round 2
|
||||
+
|
||||
+Round 1 (candidate 8ec03b45a, manifest e68c6bce5ec4) got changes from Filbert (29439) and Dewey
|
||||
+(29440). Round 2 carries:
|
||||
+
|
||||
+- F1 (both): a note with both parts wasn't bounded. A 300-character reason, a long flagged part
|
||||
+ and a clipped answer came to 32934 (Filbert) or 33004 (Dewey), over 32768, and the broker
|
||||
+ refused the send. `note()` now gives the flagged part only the room the broken part leaves, and
|
||||
+ `fresh()` cuts every reason at 300, the runner's own read-failure reason included. Tests: the
|
||||
+ runner test with an answer of 40000 characters, a 400-character `hook_failed` reason and 30
|
||||
+ flagged replies (note ≤ 600, body ≤ 32768, cut line at 32000), and a unit check that both
|
||||
+ parts come to exactly 600 and that a read failure on a 250-character path is cut.
|
||||
+- F2 (Filbert; Dewey's M3): no test failed a send through `main()`. The new runner test has the
|
||||
+ broker refuse the first RESULT. The note then rides the next RESULT. One correction to the
|
||||
+ F1 consequence as written: the broker marks a received message delivered, so the refused
|
||||
+ message isn't received again in that run. It stays unread and its sender gets no reply; the
|
||||
+ turn doesn't rerun.
|
||||
+- P1 (Dewey): `COPY_PATHS` gains `tools/unslop-hook`. The copy fixtures in
|
||||
+ `stack-copy.test.mjs` and `pinned-copy.test.mjs` copy `tools/unslop-hook` too, and the
|
||||
+ pinned-copy top-level list gains `tools`. A new `stack-copy.test.mjs` test makes a copy, loads
|
||||
+ both harness modules from it, and checks that the adapter, the six unslop paths and the mounts
|
||||
+ are under the copy and exist.
|
||||
+- N1 (Filbert): the two session-test comments say `<dir>/unslop.jsonl`.
|
||||
+- N2 (Filbert), doc-only follow-up: the brief's `<run>/session/unslop.jsonl` (:463) and "print
|
||||
+ mode notifies nobody" (:477) are stale. The brief is Jason's text for this row; the deviation
|
||||
+ below records the path.
|
||||
+- Dewey's notes, no change: the Claude Code Stop timing (finding 3 below) gets a look in the
|
||||
+ landing session; `patterns` keys per entry source, which is the brief's "per pattern".
|
||||
+
|
||||
+r2 mutants, each killed by its intended test (`mutants-r2.log`):
|
||||
+
|
||||
+| Mutant | Killed by |
|
||||
+|---|---|
|
||||
+| K1 the r1 `note()` cut | the clipped-answer runner test (the send is refused and it times out) and the unit check |
|
||||
+| K2 `fresh()` doesn't cut the reason | the unit check |
|
||||
+| K3 `sent()` before `message.send` | the refused-send runner test |
|
||||
+| K4 `COPY_PATHS` without `tools/unslop-hook` | the copy test |
|
||||
+
|
||||
+## Ship list against the brief
|
||||
+
|
||||
+| Brief item | Where |
|
||||
@@ -121,6 +166,9 @@ index 000000000..1031a901a
|
||||
+- **`packages/seat/tests/sandbox.test.mjs`** isn't an owned file. Its pin
|
||||
+ `binds(args).length === list.length - 1` broke because the modules now mount three more files.
|
||||
+ It is now `- 4`, and the test asserts the three unslop read-only binds by name.
|
||||
+- **`scripts/stack-copy.sh`** and the two copy tests aren't owned files. The copy change is P1
|
||||
+ above. PB3 of the Part B window (`repin-partB-proof.md`) holds `scripts/stack-copy.sh` still
|
||||
+ until B7, so this row lands after the Part B window has run.
|
||||
+- `MOSAIC_EXTENSIONS` is set through the pi module's `env`, not in the runner.
|
||||
+- `unslop-check.js` gained the `source` field on findings (needed for per-pattern counts).
|
||||
+
|
||||
@@ -138,8 +186,54 @@ index 000000000..1031a901a
|
||||
+Recorded with the candidate in the review request: test-unslop-check, the harness, plugins,
|
||||
+seat and cli suites, and every `scripts/test-*.sh`. At landing Sage runs one managed session per
|
||||
+harness and attaches the notice, the correction and the counts.
|
||||
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
|
||||
index ba59079d9..bec1f1f32 100644
|
||||
--- a/docs/TOOLS.md
|
||||
+++ b/docs/TOOLS.md
|
||||
@@ -334,6 +334,7 @@ One read-only tree supplies the bus, Console and human CLI (row 85,
|
||||
`copy` requires a canonical checkout (.git directory); --allow-worktree
|
||||
is a test override. COMMIT must be reachable from both local refactor and
|
||||
origin/refactor. It archives the runtime/import closure, adapters, roles,
|
||||
+`tools/unslop-hook` (the unslop check both harness modules mount, row 66),
|
||||
RELEASE, package.json and package-lock.json; refuses missing manifests;
|
||||
runs npm ci inside the temporary copy; removes write permissions; and
|
||||
renames beside the target. A published existing copy stays unchanged.
|
||||
diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs
|
||||
index 95bb4fc6c..426a31461 100644
|
||||
--- a/packages/cli/tests/stack-copy.test.mjs
|
||||
+++ b/packages/cli/tests/stack-copy.test.mjs
|
||||
@@ -14,7 +14,7 @@ export function setup(t, realPin=false){
|
||||
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
|
||||
t.after(()=>{execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true})});
|
||||
mkdirSync(repo);mkdirSync(bin);
|
||||
- const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
+ const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
|
||||
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
|
||||
const pkg={name:'copy-fixture',version:'1.0.0'};
|
||||
@@ -31,6 +31,20 @@ export function setup(t, realPin=false){
|
||||
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
|
||||
return {root,repo,copies,bin,id,env,run,fake,search,analyze};
|
||||
}
|
||||
+// Row 66: a session launched from a pinned copy reads each harness module's files from the copy,
|
||||
+// so every path a module names under its REPO (adapter, mounts, bundle sources) is in COPY_PATHS.
|
||||
+test('a copy carries every file the harness modules read from it',async t=>{
|
||||
+ const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
|
||||
+ const copy=join(s.copies,s.id),paths=[];
|
||||
+ for(const kind of ['pi','claude-code']){
|
||||
+ const m=await import(pathToFileURL(join(copy,'packages','plugins','harness',kind,'index.mjs')).href);
|
||||
+ // pi's mounts() resolves node_modules, which this fixture copy lacks; its unslop mounts are its
|
||||
+ // bundle sources, so the sources stand in for them.
|
||||
+ paths.push(m.adapter(),...(kind==='pi'?Object.values(m.bundle().sources):m.mounts({env:{}}).map(x=>x.src)));
|
||||
+ }
|
||||
+ assert.equal(paths.filter(p=>p.includes('/tools/unslop-hook/')).length,6,paths.join('\n'));
|
||||
+ for(const p of paths){assert.ok(p.startsWith(copy+'/'),p);assert.ok(existsSync(p),p);}
|
||||
+});
|
||||
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
|
||||
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
|
||||
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
||||
diff --git a/packages/harness/README.md b/packages/harness/README.md
|
||||
index 21007bca3..5eaed57d0 100644
|
||||
index 21007bca3..a64bf4364 100644
|
||||
--- a/packages/harness/README.md
|
||||
+++ b/packages/harness/README.md
|
||||
@@ -68,9 +68,13 @@ so an existing bundle is never overwritten):
|
||||
@@ -158,7 +252,7 @@ index 21007bca3..5eaed57d0 100644
|
||||
|
||||
`harness` is the route's module from `packages/plugins/harness/`, which the
|
||||
launcher loaded; the bundle calls it for its own files and names and never
|
||||
@@ -258,6 +262,26 @@ it never touches the disk and isn't in the environment or argv.
|
||||
@@ -258,6 +262,28 @@ it never touches the disk and isn't in the environment or argv.
|
||||
header after it names when the message was sent. A failed read fails the
|
||||
turn without running the adapter: the sender gets `The turn failed: could
|
||||
not read the current state: <code>`, and the message is read.
|
||||
@@ -172,8 +266,10 @@ index 21007bca3..5eaed57d0 100644
|
||||
+ when the next turn gets the correction notice, and `broken_lists`,
|
||||
+ `skipped_broken` or `hook_failed` when there is no verdict. After each
|
||||
+ turn the runner reads what the log gained, and the next RESULT it sends
|
||||
+ ends with a note of at most 600 characters naming the flagged replies;
|
||||
+ a broken check is told once a run. A turn that sends no reply (an
|
||||
+ ends with a note naming the flagged replies; a broken check is told
|
||||
+ once a run. The whole note, both parts together, is at most 600
|
||||
+ characters, and the answer before it is clipped at 32000, so the RESULT
|
||||
+ stays under the bus's 32768. A turn that sends no reply (an
|
||||
+ incoming RESULT, a landing turn) keeps its note for the next reply, and
|
||||
+ so does a send that failed. That RESULT is what `mosaic talk` and
|
||||
+ `mosaic follow` print, so the operator sees the note there.
|
||||
@@ -186,7 +282,7 @@ index 21007bca3..5eaed57d0 100644
|
||||
exits 0. The handlers go in before anything else, because pid 1 of a
|
||||
PID namespace never sees a signal it has no handler for; a stop before
|
||||
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
|
||||
index c2108c4db..76b1b7fb1 100644
|
||||
index c2108c4db..c10e5fe58 100644
|
||||
--- a/packages/harness/src/runner.mjs
|
||||
+++ b/packages/harness/src/runner.mjs
|
||||
@@ -21,6 +21,12 @@
|
||||
@@ -202,7 +298,7 @@ index c2108c4db..76b1b7fb1 100644
|
||||
//
|
||||
// Exit codes: 0 stopped, 20 founder credentials, 21 claim refused, 22 the
|
||||
// capability or the claim stopped working (ended, revoked), 23 the broker
|
||||
@@ -120,6 +126,109 @@ function clip(text) {
|
||||
@@ -120,6 +126,113 @@ function clip(text) {
|
||||
return text.length > BODY_MAX ? text.slice(0, BODY_MAX) + "\n[answer cut at 32000 characters]" : text;
|
||||
}
|
||||
|
||||
@@ -266,7 +362,7 @@ index c2108c4db..76b1b7fb1 100644
|
||||
+ let broken = null;
|
||||
+ const fresh = (reason) => {
|
||||
+ if (toldBroken || broken !== null) return;
|
||||
+ broken = reason;
|
||||
+ broken = cut(reason, 300);
|
||||
+ log(`runner: unslop: the check is broken: ${broken}`);
|
||||
+ };
|
||||
+ return {
|
||||
@@ -292,13 +388,17 @@ index c2108c4db..76b1b7fb1 100644
|
||||
+ if (UNSLOP_BROKEN.has(e.ev)) fresh(unslopReason(e));
|
||||
+ }
|
||||
+ },
|
||||
+ // The whole note, both parts and the blank line, is at most UNSLOP_NOTE_MAX, so a clipped body
|
||||
+ // plus its note stays under the bus's body limit. The broken part is at most 371 characters
|
||||
+ // (the reason is cut at 300), and the flagged part gets what is left.
|
||||
+ note() {
|
||||
+ const parts = [];
|
||||
+ if (broken !== null && !toldBroken) parts.push(`[unslop: the check is broken, so this session's replies go unchecked: ${broken}]`);
|
||||
+ if (flagged.length) {
|
||||
+ const n = flagged.length;
|
||||
+ parts.push(cut(`[unslop: ${n} ${n === 1 ? "reply" : "replies"} of this session flagged since the last note: ${[...new Set(flagged)].join("; ")}`, UNSLOP_NOTE_MAX - 60) +
|
||||
+ ". The session gets a correction notice on its next turn.]");
|
||||
+ const tail = ". The session gets a correction notice on its next turn.]";
|
||||
+ const room = UNSLOP_NOTE_MAX - 2 - parts.reduce((sum, p) => sum + p.length + 1, 0) - tail.length;
|
||||
+ parts.push(cut(`[unslop: ${n} ${n === 1 ? "reply" : "replies"} of this session flagged since the last note: ${[...new Set(flagged)].join("; ")}`, room) + tail);
|
||||
+ }
|
||||
+ return parts.length ? `\n\n${parts.join("\n")}` : "";
|
||||
+ },
|
||||
@@ -312,7 +412,7 @@ index c2108c4db..76b1b7fb1 100644
|
||||
const readLine = (stream) => new Promise((resolve, reject) => {
|
||||
let input = "";
|
||||
stream.setEncoding("utf8");
|
||||
@@ -274,6 +383,8 @@ export function adapterEnv(session, env) {
|
||||
@@ -274,6 +387,8 @@ export function adapterEnv(session, env) {
|
||||
MOSAIC_PROVIDER: session.provider ?? "",
|
||||
MOSAIC_MODEL: session.model,
|
||||
...session.harnessEnv,
|
||||
@@ -321,7 +421,7 @@ index c2108c4db..76b1b7fb1 100644
|
||||
};
|
||||
}
|
||||
|
||||
@@ -358,6 +469,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
@@ -358,6 +473,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
},
|
||||
});
|
||||
const turnEnv = adapterEnv(session, env);
|
||||
@@ -329,7 +429,7 @@ index c2108c4db..76b1b7fb1 100644
|
||||
|
||||
let code = EXIT.stopped;
|
||||
let failures = 0;
|
||||
@@ -392,6 +504,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
@@ -392,6 +508,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
try { await client.call("usage.begin", { turn: e.decision }); }
|
||||
catch (err) { log(`runner: usage baseline unavailable: ${err.code ?? "failed"}`); }
|
||||
const turn = await runTurn({ session, request: `${block}\n${landingRequest(e)}`, n, env: turnEnv, onChild: (c) => (child = c) });
|
||||
@@ -337,7 +437,7 @@ index c2108c4db..76b1b7fb1 100644
|
||||
try { await client.call("usage.end", { turn: e.decision }); }
|
||||
catch (err) { log(`runner: usage outcome unavailable: ${err.code ?? "failed"}`); }
|
||||
log(`runner: landing turn ${n} for decision ${e.decision} (${e.task_ref}): ${turn.ok ? "ok" : `failed: ${turn.reason}`}`);
|
||||
@@ -439,6 +552,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
@@ -439,6 +556,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
const turn = unread
|
||||
? { ok: false, reason: unread }
|
||||
: await runTurn({ session, request: `${block}\n${turnRequest(m)}`, n, env: turnEnv, onChild: (c) => (child = c) });
|
||||
@@ -345,7 +445,7 @@ index c2108c4db..76b1b7fb1 100644
|
||||
// A failed/stopped harness can still have used tokens. Missing data remains unknown.
|
||||
try { await client.call("usage.end", { turn: m.id }); }
|
||||
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
|
||||
@@ -447,7 +561,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
@@ -447,7 +565,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
try {
|
||||
if (m.class !== "RESULT") {
|
||||
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
|
||||
@@ -399,7 +499,7 @@ index 7690d9761..174c2858b 100644
|
||||
|
||||
// Row 89 (#1583): the prompt names the built-ins as the session's harness names them.
|
||||
diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs
|
||||
index 19e9f2f6d..8327e5f94 100644
|
||||
index 19e9f2f6d..86bd03a18 100644
|
||||
--- a/packages/harness/tests/claude-session.test.mjs
|
||||
+++ b/packages/harness/tests/claude-session.test.mjs
|
||||
@@ -10,7 +10,7 @@ import { spawn, spawnSync } from "node:child_process";
|
||||
@@ -432,7 +532,7 @@ index 19e9f2f6d..8327e5f94 100644
|
||||
sessionDir: join(dir, "session"),
|
||||
toolSocket: sock.path,
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
+ // Row 66: the run directory, so the unslop hooks log to <dir>/session/unslop.jsonl, which the
|
||||
+ // Row 66: the run directory, so the unslop hooks log to <dir>/unslop.jsonl, which the
|
||||
+ // runner makes before the first turn.
|
||||
+ runDir: dir,
|
||||
};
|
||||
@@ -495,32 +595,34 @@ index 19e9f2f6d..8327e5f94 100644
|
||||
+ assert.match(counts.broken, /^lists\.json invalid: empty file/);
|
||||
+});
|
||||
diff --git a/packages/harness/tests/fixtures/fake-adapter.mjs b/packages/harness/tests/fixtures/fake-adapter.mjs
|
||||
index d5811b241..9b21d5c4e 100644
|
||||
index d5811b241..82ce6e68e 100644
|
||||
--- a/packages/harness/tests/fixtures/fake-adapter.mjs
|
||||
+++ b/packages/harness/tests/fixtures/fake-adapter.mjs
|
||||
@@ -5,6 +5,8 @@
|
||||
@@ -5,6 +5,9 @@
|
||||
// FAIL exit 3
|
||||
// NOMARKER answer without writing the turn marker
|
||||
// ENV <name> answer "env: <value of name>"
|
||||
+// UNSLOP <json> append each event of the JSON array to $MOSAIC_UNSLOP_LOG, as the harness's
|
||||
+// unslop check does (row 66), and answer "unslop: <count>"
|
||||
+// UNSLOPLONG <json> the same, with an answer of 40000 more characters, over the runner's clip
|
||||
// anything else answer "echo: <request>"
|
||||
// Every turn appends its pid to $FAKE_PIDS when set.
|
||||
|
||||
@@ -33,6 +35,11 @@ if (body.startsWith("TOOL ")) {
|
||||
@@ -33,6 +36,12 @@ if (body.startsWith("TOOL ")) {
|
||||
} else if (body.startsWith("ENV ")) {
|
||||
process.stdout.write(`env: ${process.env[body.slice(4)]}\n`);
|
||||
done();
|
||||
+} else if (body.startsWith("UNSLOP ")) {
|
||||
+ const events = JSON.parse(body.slice(7));
|
||||
+} else if (body.startsWith("UNSLOP ") || body.startsWith("UNSLOPLONG ")) {
|
||||
+ const long = body.startsWith("UNSLOPLONG ");
|
||||
+ const events = JSON.parse(body.slice(long ? 11 : 7));
|
||||
+ for (const e of events) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
|
||||
+ process.stdout.write(`unslop: ${events.length}\n`);
|
||||
+ process.stdout.write(`unslop: ${events.length}${long ? " " + "x".repeat(40000) : ""}\n`);
|
||||
+ done();
|
||||
} else if (body === "NOMARKER") {
|
||||
process.stdout.write("half an answer");
|
||||
} else {
|
||||
diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs
|
||||
index ceda82beb..1963bc9b1 100644
|
||||
index ceda82beb..b88285696 100644
|
||||
--- a/packages/harness/tests/pi-session.test.mjs
|
||||
+++ b/packages/harness/tests/pi-session.test.mjs
|
||||
@@ -4,17 +4,17 @@
|
||||
@@ -557,7 +659,7 @@ index ceda82beb..1963bc9b1 100644
|
||||
sessionDir: join(dir, "session"),
|
||||
toolSocket: sock.path,
|
||||
turnMarker: join(dir, "turn.done"),
|
||||
+ // Row 66: the run directory, so the unslop check logs to <dir>/session/unslop.jsonl, which the
|
||||
+ // Row 66: the run directory, so the unslop check logs to <dir>/unslop.jsonl, which the
|
||||
+ // runner makes before the first turn.
|
||||
+ runDir: dir,
|
||||
};
|
||||
@@ -620,7 +722,7 @@ index ceda82beb..1963bc9b1 100644
|
||||
+ assert.match(counts.broken, /^lists\.json invalid: empty file/);
|
||||
+});
|
||||
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
|
||||
index b739ed770..0063717cd 100644
|
||||
index b739ed770..fbaf7a646 100644
|
||||
--- a/packages/harness/tests/runner.test.mjs
|
||||
+++ b/packages/harness/tests/runner.test.mjs
|
||||
@@ -13,7 +13,7 @@ import { join } from "node:path";
|
||||
@@ -632,7 +734,7 @@ index b739ed770..0063717cd 100644
|
||||
import { typedTools } from "../src/tools.mjs";
|
||||
import { resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
@@ -836,3 +836,78 @@ test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn m
|
||||
@@ -836,3 +836,131 @@ test("row 65 harnessEnv reaches the adapter, and an exit harness needs no turn m
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.trim()), ["env: xhigh", "half an answer"]);
|
||||
});
|
||||
@@ -710,6 +812,59 @@ index b739ed770..0063717cd 100644
|
||||
+ many.read();
|
||||
+ assert.ok(many.note().length <= 600, many.note().length);
|
||||
+ assert.match(many.note(), /^\n\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
|
||||
+ // Both parts together are bounded too, and a reason the runner makes itself is cut like one the
|
||||
+ // log gives: here the log path is a directory with a long name, so the read fails with EISDIR.
|
||||
+ const deep = join(dir, "d".repeat(250));
|
||||
+ mkdirSync(deep);
|
||||
+ const both = unslopNotes(deep);
|
||||
+ both.read();
|
||||
+ assert.match(both.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: the runner can't read .*\.\.\.\]$/);
|
||||
+ assert.equal(both.note().length, 2 + 71 + 300);
|
||||
+ const both2 = unslopNotes(join(dir, "both.jsonl"));
|
||||
+ writeFileSync(join(dir, "both.jsonl"), [JSON.stringify({ ev: "broken_lists", reason: "b".repeat(400) }), ...Array.from({ length: 50 }, (_, i) => JSON.stringify(tell(`banned word "w${i}" x1`.padEnd(40, "x"))))].join("\n") + "\n");
|
||||
+ both2.read();
|
||||
+ assert.equal(both2.note().length, 600);
|
||||
+ assert.match(both2.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: b{297}\.\.\.\]\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
|
||||
+});
|
||||
+
|
||||
+test("row 66 a clipped answer with both note parts stays under the bus's body limit, and the note under 600", async (t) => {
|
||||
+ const ctx = await setup(t);
|
||||
+ ctx.inbox = [];
|
||||
+ const r = startRunner(ctx.runDir, ctx.cap);
|
||||
+ t.after(() => r.child.kill("SIGKILL"));
|
||||
+ await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
||||
+ const events = [{ ev: "hook_failed", reason: "r".repeat(400) }, ...Array.from({ length: 30 }, (_, i) => tell(`banned word "w${i}" x1`.padEnd(60, "x")))];
|
||||
+ ctx.call(ctx.pm, "message.send", { to: "coder", body: `UNSLOPLONG ${JSON.stringify(events)}`, class: "REQUEST" });
|
||||
+ await until(() => replies(ctx)() && ctx.inbox.length >= 1);
|
||||
+ const body = ctx.inbox[0].body;
|
||||
+ const at = body.indexOf("\n[answer cut at 32000 characters]");
|
||||
+ assert.equal(at, 32000);
|
||||
+ const note = body.slice(at + "\n[answer cut at 32000 characters]".length);
|
||||
+ assert.ok(note.length <= 600, note.length);
|
||||
+ assert.ok(body.length <= 32768, body.length);
|
||||
+ assert.match(note, /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: r+\.\.\.\]\n\[unslop: 30 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
|
||||
+});
|
||||
+
|
||||
+test("row 66 a refused send keeps the note: it rides the next RESULT the session sends", async (t) => {
|
||||
+ const ctx = await setup(t);
|
||||
+ ctx.inbox = [];
|
||||
+ // The broker refuses the session's first RESULT once, as it would a body it can't take.
|
||||
+ const request = ctx.broker.request.bind(ctx.broker);
|
||||
+ let refused = 0;
|
||||
+ ctx.broker.request = (cap, r) => {
|
||||
+ if (r.verb === "message.send" && r.args?.class === "RESULT" && refused++ === 0) throw new BusError("invalid-request");
|
||||
+ return request(cap, r);
|
||||
+ };
|
||||
+ const r = startRunner(ctx.runDir, ctx.cap);
|
||||
+ t.after(() => r.child.kill("SIGKILL"));
|
||||
+ await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
||||
+ const sent = unslopAsk(ctx, [tell('banned word "delve" x1')]);
|
||||
+ await until(() => r.stderr().includes(`runner: reply to ${sent.id} failed: invalid-request\n`));
|
||||
+ // The refused message was delivered and isn't read; its reply never reached the bus.
|
||||
+ assert.ok(!ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
|
||||
+ const next = await ask(ctx, "ENV MOSAIC_MODEL");
|
||||
+ assert.equal(next.body, 'env: fake-model\n\n[unslop: 1 reply of this session flagged since the last note: banned word "delve" x1. The session gets a correction notice on its next turn.]');
|
||||
+ assert.equal(ctx.inbox.length, 1);
|
||||
+});
|
||||
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
|
||||
index 9b6cf91f6..2b30708b0 100644
|
||||
@@ -907,6 +1062,41 @@ index 4fcf7e8fd..b42d81d82 100644
|
||||
});
|
||||
|
||||
// Row 121 (#1615): a thread run gets only its own thread's two directories,
|
||||
diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs
|
||||
index f2bdfa025..e9a2d3109 100644
|
||||
--- a/packages/webui/tests/pinned-copy.test.mjs
|
||||
+++ b/packages/webui/tests/pinned-copy.test.mjs
|
||||
@@ -33,7 +33,7 @@ function tmp(t, prefix) {
|
||||
function fixtureRepo(t) {
|
||||
const dir = tmp(t, 'webui-copy-');
|
||||
const repo = join(dir, 'repo');
|
||||
- const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
||||
+ const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
||||
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
|
||||
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
|
||||
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
|
||||
@@ -72,7 +72,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
|
||||
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
|
||||
- assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
|
||||
+ assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
|
||||
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic', 'mosaic-config.mjs']);
|
||||
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
|
||||
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
|
||||
diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh
|
||||
index 24bbb02a6..9ea9bf485 100755
|
||||
--- a/scripts/stack-copy.sh
|
||||
+++ b/scripts/stack-copy.sh
|
||||
@@ -30,6 +30,8 @@ COPY_PATHS=(
|
||||
packages/queue/src
|
||||
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
|
||||
packages/plugins
|
||||
+ # The unslop check both harness modules mount and list in the bundle (row 66).
|
||||
+ tools/unslop-hook
|
||||
)
|
||||
# stack-release.sh sources this file and calls reachable before an activate (D1).
|
||||
reachable() {
|
||||
diff --git a/tools/unslop-hook/README.md b/tools/unslop-hook/README.md
|
||||
index ef908a639..2f5ca166b 100644
|
||||
--- a/tools/unslop-hook/README.md
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
base 5487ed39d dirty 19 DOCKER_HOST=unset
|
||||
base 8ec03b45a dirty 11 DOCKER_HOST=unset
|
||||
unslop rc=0 | 28/28 passed
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3061.393351
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 1980.731646
|
||||
pkg-cli rc=0 ℹ pass 453 ℹ fail 0 | ℹ duration_ms 295260.434062
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 595.44639
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33410.059334
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2607.483772
|
||||
pkg-harness rc=0 ℹ pass 140 ℹ fail 0 | ℹ duration_ms 11430.754901
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12897.7941
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2020.071836
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 250.102521
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1087.373203
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 746.064857
|
||||
pkg-seat rc=0 ℹ pass 151 ℹ fail 0 | ℹ duration_ms 5803.820998
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1136.245483
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 54812.118042
|
||||
pkg-bus rc=0 ℹ pass 451 ℹ fail 0 | ℹ duration_ms 3039.978
|
||||
pkg-business rc=0 ℹ pass 149 ℹ fail 0 | ℹ duration_ms 2119.713943
|
||||
pkg-cli rc=0 ℹ pass 454 ℹ fail 0 | ℹ duration_ms 294959.155129
|
||||
pkg-control-board rc=0 ℹ pass 124 ℹ fail 0 | ℹ duration_ms 590.346847
|
||||
pkg-conversation rc=0 ℹ pass 182 ℹ fail 0 | ℹ duration_ms 33735.620305
|
||||
pkg-discord rc=0 ℹ pass 183 ℹ fail 0 | ℹ duration_ms 2607.173768
|
||||
pkg-harness rc=0 ℹ pass 142 ℹ fail 0 | ℹ duration_ms 11398.667142
|
||||
pkg-ledger rc=0 ℹ pass 89 ℹ fail 0 | ℹ duration_ms 12920.093134
|
||||
pkg-mosaic rc=0 ℹ pass 69 ℹ fail 0 | ℹ duration_ms 2060.508354
|
||||
pkg-plugins rc=0 ℹ pass 111 ℹ fail 0 | ℹ duration_ms 245.928975
|
||||
pkg-project rc=0 ℹ pass 40 ℹ fail 0 | ℹ duration_ms 1052.940331
|
||||
pkg-runs rc=0 ℹ pass 43 ℹ fail 0 | ℹ duration_ms 741.129125
|
||||
pkg-seat rc=0 ℹ pass 151 ℹ fail 0 | ℹ duration_ms 5795.650378
|
||||
pkg-tasks rc=0 ℹ pass 97 ℹ fail 0 | ℹ duration_ms 1148.870662
|
||||
pkg-webui rc=0 ℹ pass 269 ℹ fail 0 | ℹ duration_ms 55714.69432
|
||||
script-auth rc=0 | selftest: 15 passed, 0 failed
|
||||
script-conductor rc=0 | selftest: 17 passed, 0 failed
|
||||
script-config rc=0 | selftest: 24 passed, 0 failed
|
||||
@@ -28,3 +28,4 @@ script-task rc=0 | selftest: 98 passed, 0 failed
|
||||
GATE_PASSED
|
||||
removed network sage-b66_default
|
||||
exit=0
|
||||
2026-10-20T13:41:45Z
|
||||
@@ -0,0 +1,5 @@
|
||||
K1-note-r1 rc=1 KILLED ℹ pass 37 ℹ fail 2
|
||||
K2-reason-uncut rc=1 KILLED ℹ pass 38 ℹ fail 1
|
||||
K3-sent-first rc=1 KILLED ℹ pass 38 ℹ fail 1
|
||||
K4-no-copy-path rc=1 KILLED ℹ pass 12 ℹ fail 1
|
||||
MUTANTS_DONE
|
||||
@@ -0,0 +1,10 @@
|
||||
packages/harness/tests/runner.test.mjs rc=0 ℹ pass 39 ℹ fail 0
|
||||
packages/harness/tests/pi-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
packages/harness/tests/claude-session.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
packages/cli/tests/stack-copy.test.mjs rc=0 ℹ pass 13 ℹ fail 0
|
||||
packages/webui/tests/pinned-copy.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
packages/cli/tests/stack-release.test.mjs rc=0 ℹ pass 151 ℹ fail 0
|
||||
packages/cli/tests/thread-launch.test.mjs rc=0 ℹ pass 11 ℹ fail 0
|
||||
packages/seat/tests/sandbox.test.mjs rc=0 ℹ pass 10 ℹ fail 0
|
||||
unslop rc=0 28/28 passed
|
||||
SINGLES_DONE
|
||||
@@ -158,3 +158,41 @@ Ruling:
|
||||
auth.
|
||||
|
||||
Mos tells Jason in the morning summary.
|
||||
|
||||
## 2026-10-20T17:48Z correction: on a pinned host Jason uses plain `mosaic` (Mos mos-0968)
|
||||
|
||||
This supersedes the 10-12 line at 18:56Z, "Until row 85 lands, Jason uses
|
||||
the full path, `/mnt/storage/src/mosaic-stack/scripts/mosaic talk pm
|
||||
"..."`". Row 85 landed, and the host, bus and Console now run from the
|
||||
pinned copy 490e2771d3d0. The bus takes human commands only from the tree
|
||||
it runs from. The canonical checkout is not that tree.
|
||||
|
||||
Jason ran the full-path command Mos gave him from that line. It refused
|
||||
with human-required: "the bus accepts these commands only from the tree it
|
||||
runs from; on a pinned host run `mosaic` on PATH". ~/.npm-global/bin/mosaic
|
||||
now execs copies/490e2771d3d0/scripts/mosaic, which I read at 17:48Z, so
|
||||
Jason uses plain `mosaic talk pm "..."` or the Console. Friction is on
|
||||
Vikunja #205, comment 369.
|
||||
|
||||
Nothing else changes. The refused command never reached pm, so the
|
||||
reachability check (mos-0960) has not happened and piece 1 is not open.
|
||||
|
||||
## 2026-10-20T17:54Z S7 reachability pass (Mos mos-0969)
|
||||
|
||||
Jason ran the plain PATH command from the 17:48Z entry. His output, as Mos
|
||||
relayed it in mos-0969:
|
||||
|
||||
```
|
||||
[jwoltje@dragon-lin winA]$ mosaic talk pm "Can you hear me?"
|
||||
sent 51cebff7 to pm
|
||||
pm isn't running, chief has been told
|
||||
pm (RESULT, re 51cebff7) cff1e9ad:
|
||||
Yes, I'm getting your messages. I haven't changed anything on the tracker. What do you need?
|
||||
```
|
||||
|
||||
pm was not running when 51cebff7 arrived. The chief launched it, and pm
|
||||
answered with cff1e9ad. Under mos-0960 this is the reachability check, not
|
||||
a piece. It passed, so the "pm doesn't answer" branch does not apply and
|
||||
no defect is filed.
|
||||
|
||||
Piece 1 is Jason's next real Mosaic Stack request to pm. It is not open yet.
|
||||
@@ -0,0 +1,50 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) round 2 approved. One comment on P19 337 (D2), per mos-0882 (every
|
||||
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
|
||||
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
|
||||
// Usage: node comment-337-r163-approved.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
|
||||
"Row 163 (Gitea #1660) round 2 is approved by Filbert (comment 29447, queue rev 2234, committed cd2b377bb). Candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest digest c6f4f4c12596. Filbert's own run: 249 of 249 single-file tests pass, and their seven mutants (M1-M6 from round 1, plus one for the read-only registry) are all killed.",
|
||||
"Not landed yet. Mos's landing order holds (mos-0965, mos-0967): the candidate moves scripts/stack-release.sh off blob 2586a2a37cd2, which the Part B A2 check rests on, so D2 lands only after the Part B window closes (B7). If refactor moves past 4f02486d5 on any manifest path or otherwise before then, the full gate runs again on the new base before landing.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) in review, round 2. One comment on P19 337 (D2), per mos-0882 (every
|
||||
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
|
||||
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
|
||||
// Usage: node comment-337-r163-inreview-2.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
|
||||
"Row 163 (Gitea #1660) is in review, round 2, with Filbert as reviewer (request comment 29446, queue revs 2231-2233, committed c539995cf). Round 1 got changes from Filbert (comment 29445, rev 2230): six mutants survived because no test drove those checks to fail, plus a non-blocking note that a read-only session registry still wrote on remove().",
|
||||
"Round 2 adds the tests: R14 (a host that writes the live config, business file or backup fails live-config-unchanged or backup-unchanged), R15 (TERM, INT and HUP during the gate fail signal and still stop the host and Console and run the end checks), an env-ref case in R9, other-root and missing-root cases in R11, and a launches directory case in R4. The read-only registry now never writes its file. stack-release.sh is unchanged from round 1.",
|
||||
"Candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5; manifest digest c6f4f4c12596, 10 files, +1024/-13 (round 1 to round 2: 4 files, +85/-7). The gate passed on 2a5f3a3a0; mutants K34-K41 are all killed. Mos checked the manifest before the request (mos-0967). Packet: agents/sage/work/queue-163/r2/ in refactor 7af166574. Landing order is unchanged: after the Part B window closes (B7), with a new gate if the base moves.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) in review, round 1. One comment on P19 337 (D2), per mos-0882 (every
|
||||
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
|
||||
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
|
||||
// Usage: node comment-337-r163-inreview.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
|
||||
"Row 163 (Gitea #1660) is in review, round 1, with Filbert as reviewer (request comment 29444, queue revs 2228-2229, committed def677bc7). Candidate 4be3f6aec on 580500699, base 4f02486d5; manifest digest 430212299beb, 10 files, +947/-14. It adds `stack-release.sh rehearse`, which runs the D2 rehearsal on a scratch root from a backup, and `bus start --no-launch`, a host that launches no sessions, runs no thread tick and writes no sessions.json.",
|
||||
"The gate passed on 580500699 (all package and script suites; Docker network removed, 0 containers left). Mutants K1-K33 are all killed; four survived the first run (K11, K21, K27, K31) and each got a test. Packet: agents/sage/work/queue-163/ in refactor 51f67f1f8.",
|
||||
"Mos accepted deviations 1-7 in mos-0965; deviation 3 (the scratch business keeps its tracker key, with @sync on a placeholder ref) rests on mos-0964, since business.mjs:197 refuses a file without it. Landing order (mos-0965): the candidate moves stack-release.sh off blob 2586a2a37cd2, which the Part B A2 check rests on, so D2 lands only after the Part B window closes (B7). If the base moves before then, the gate runs again.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"at": "2026-10-20T15:41:54.066Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 368,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"at": "2026-10-20T15:26:21.265Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 367,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"at": "2026-10-20T14:49:31.177Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 366,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -1348,3 +1348,13 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T11:08:34Z | Filbert (Opus 5.5, 048f3c63) | row 188 r1 review (#1686, manifest 86aa549bad75) | approve, comment 29433, rev 2212; stack-release 151/151 on my slot run; M8-M10 and my M11 killed by T54; N1 REPO logical-path gap in stack-release.sh and webui-service.sh (mos-0959, filed after Part B)
|
||||
2026-10-20T11:14:11Z | Sage (Opus 5.5, 1ef1e4f8) | row 188 landing (#1686, P19 409/410), N1 to P19 | landed 8fc0ee216 (patch-id c9a56b167c07, stack-release.sh blob 2586a2a37cd2 unchanged), queue 31e328958 revs 2209-2214, pushed b9c1c9fe2..31e328958 with Filbert's 23a609c5c; #1686 comment 29435, closed; P19 411 (N1, after Part B per mos-0959), comments 361-363, 409/410 done; my done update blanked 409/410 descriptions, restored 11:13Z (restore-409-410-desc.mjs), script fixed
|
||||
2026-10-20T13:14:25Z | Sage (Opus 5.5, 1ef1e4f8) | row 66 build (#1559), row 42 trigger (mos-0960), row 163 brief | row 66 r1 in review: candidate 8ec03b45a (sage/row66, base 5487ed39d), manifest e68c6bce5ec4, revs 2215-2217, request 29437, Dewey and Filbert told (sage-1286-1289); gate r1 failed cli 452/453 (thread-launch.test.mjs:179, <run>/session made for the log), fixed to <run>/unslop.jsonl, gate r2 GATE_PASSED; row 42 run log: trigger (a) met, mos-0960 first pm message is a reachability check; row 163 brief owner sage, lands after Part B (6020bb43a), revs 2218-2219 (263ea8a9a)
|
||||
2026-10-20T13:18:38Z | Filbert (Opus 5.5, 048f3c63) | row 66 (#1559) r1 review of 8ec03b45a (manifest e68c6bce5ec4) | changes, comment 29439, rev 2221: F1 note with broken+flagged parts overflows the 32768 bus limit (probe 32934), F2 mutant M2 sent-before-send survives; 402/402 suites, M1 and M3 killed
|
||||
2026-10-20T13:52:08Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) r1 review of 8ec03b45a (manifest e68c6bce5ec4) | changes, comment 29440, rev 2222: P1 stack copies lack tools/unslop-hook (12 paths missing at candidate, 0 at base); concur F1 (note 971, sum 33004 > 32768) and F2 (sent() before send survives); N1 stale session/ comments; M1 M2 M4 M5 killed; singles 109/109, harness 140/140, plugins 111/111
|
||||
2026-10-20T13:56:35Z | Filbert (Opus 5.5, 048f3c63) | row 66 (#1559) r2 review of 0f4a57956 (manifest cee63e06fdea) | approve, comment 29442, rev 2226: F1/F2/N1 fixed, P1 copy path checked; 427/427 suites, M1-M5 killed
|
||||
2026-10-20T14:11:02Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) r2 review of 0f4a57956 (manifest cee63e06fdea) | approve, comment 29443, rev 2227: P1/F1/F2/N1 fixed (copy probe 0 of 16 missing; note 600, sum 32633); mutants N1-N4 killed, N5 survives (copy test skips claude-code bundle sources, non-blocking follow-up); singles 285/285, harness 142, plugins 111, cli 454, webui 269 + 1 unrelated skip
|
||||
2026-10-20T14:49:39Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) D2 rehearse + bus --no-launch: build, gate, mutants, in-review r1 | in-review, Filbert reviewer: candidate 4be3f6aec on 580500699, manifest 430212299beb, gate passed, K1-K33 killed; request comment 29444, revs 2228-2229 (def677bc7); P19 337 comment 366; lands after Part B B7 (mos-0965)
|
||||
2026-10-20T15:06:03Z | Filbert (Opus 5.5, 048f3c63) | row 163 r1 review (D2 rehearse, candidate 4be3f6aec, manifest 430212299beb) | changes, comment 29445 rev 2230: 247/247 singles pass; my mutants M1-M6 survive (F1 backup/live-config after-checks, F2 env ref, data-root-scratch, launches dir, F3 signal trap); N1 Registry.remove read-only
|
||||
2026-10-20T15:26:33Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 for Filbert's r1 changes (F1-F3, N1) | in-review r2, Filbert reviewer: candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest c6f4f4c12596; tests R14 R15 + R4/R9/R11 cases, read-only Registry #write() returns; gate passed, K34-K41 killed; Mos checked manifest (mos-0967); revs 2230 (Filbert) and 2231-2233 committed 92cc6011c c539995cf; request comment 29446; P19 337 comment 367; packet 7af166574; lands after B7
|
||||
2026-10-20T15:40:36Z | Filbert (Opus 5.5, 048f3c63) | row 163 r2 review (D2 rehearse, candidate 2c090eefd, manifest c6f4f4c12596) | approve, comment 29447 rev 2234: 249/249 singles pass; my M1-M6 and N1 mutants all killed (R14, R9, R11, R4, R15, session test)
|
||||
2026-10-20T15:42:00Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 verdict intake | approved by Filbert (comment 29447, rev 2234 committed cd2b377bb); P19 337 comment 367 -> 368; candidate 2c090eefd manifest c6f4f4c12596 waits to land after B7, fresh gate if the base moves
|
||||
2026-10-20T18:19:06Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r1 (#412, DESIGN.md blob 91e5b1b5e737 at 7f57419b3), static read incl. oauth4webapi 3.8.8 | changes: B1 config in config.json not business file, B2 return-path backslash, B3 log redaction of library error causes, B4 login-record cap, B5 test 9 not 2 kills signature mutant, B6 phase 2 live forged-header probe; rulings Q-B start degraded, Q-C yes, Q-D 8h abs + idle ignores polls; step 5 reading confirmed; verdict sent to Sage, no Gitea/queue record
|
||||
+71
-1
@@ -368,7 +368,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
|
||||
```
|
||||
scripts/stack-release.sh adopt BUSINESS
|
||||
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh status BUSINESS
|
||||
@@ -480,6 +480,14 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
|
||||
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
|
||||
and only the Console flags change.
|
||||
|
||||
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
|
||||
Console flags and the unit, so the Console serves reads and no human sends. It
|
||||
may name the live copy while the pointer holds `--send`; once `--send` is gone a
|
||||
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
|
||||
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
|
||||
`--plan` prints one `send:` line: `dropped`, `off` with or without the
|
||||
`--no-send` note, or `carried`.
|
||||
|
||||
#### A target past c216f47db: the PM after the harness kind
|
||||
|
||||
From c216f47db on, the bus launches the PM only with a `harness` kind in the
|
||||
@@ -528,6 +536,68 @@ on PATH; the checkout's `scripts/mosaic` exits 3 with `human-required` or
|
||||
`tester-required`, and queue writes stay on it ([packages/cli/README.md, One
|
||||
pinned copy](../packages/cli/README.md#one-pinned-copy-row-85-1579)).
|
||||
|
||||
#### Rehearsal on a scratch root (D2, row 163)
|
||||
|
||||
```
|
||||
scripts/stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
||||
```
|
||||
|
||||
`rehearse` starts the target copy's bus host and Console on a copy of a
|
||||
backup and runs `activate`'s gate checks on them, all but the PM's, while the
|
||||
live stack keeps running. It reads the live config and a backup of the live
|
||||
store, so Jason or Mos runs it, as a window's pre-step. No development seat
|
||||
runs it against live data. It stops and starts no unit and writes no pointer
|
||||
or log line.
|
||||
|
||||
`--backup` takes an activation id under `<dataRoot>/backups/stack/` or an
|
||||
absolute path. `--port` is the scratch Console's loopback port, 1024 to
|
||||
65535. The run refuses `port-reserved` for 7332, 7333 and the pointer's
|
||||
`--port` and `--tester-port`, and `port-in-use` for a port something holds;
|
||||
it never picks another port. `--timeout` (default 60 seconds) bounds the
|
||||
gate and each stop.
|
||||
|
||||
Before it makes anything, it checks the pointer, the copy (present,
|
||||
reachable from refactor, a readable schema, a bus host with `--no-launch`),
|
||||
the port and the backup's `SHA256SUMS`. Then it makes
|
||||
`<dataRoot>/rehearsals/<UTC second>-<copy's first 12>/`, mode 0700:
|
||||
|
||||
- `data/bus/` holds a copy of the backup's store files, and
|
||||
`data/notify/BUSINESS/notify.json` is `{"notifyVersion":1,"binding":null}`.
|
||||
- `config/config.json` is the live config with `dataRoot` set to `data/`.
|
||||
- `config/businesses/BUSINESS.json` is the live business file with every
|
||||
`tracker.*` variable removed and every credential reference, the roles'
|
||||
and `tracker.sync`'s, pointed at its own 0600 file under `placeholders/`
|
||||
holding `rehearsal-placeholder-token`. `business.diff` shows the changes.
|
||||
Roles come from the target copy's `roles/`, as the live bus reads them.
|
||||
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
|
||||
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
|
||||
|
||||
A guard then loads the scratch config with the target's code and refuses
|
||||
with a named check: `data-root-live` when the scratch data root resolves to
|
||||
the live one, `sessions-live` when a `sessions.json` there names a running
|
||||
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
|
||||
`tracker-configured` when `bootConfig` still sets a tracker, and
|
||||
`credential-ref-live` for an env reference or a file outside `placeholders/`.
|
||||
When the backup's schema differs from the target's, the run checks that the
|
||||
target refuses the store, migrates it, opens it, migrates it again as a
|
||||
no-op, and that the pointer's copy refuses the migrated store.
|
||||
|
||||
It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
|
||||
`serve --port N --business BUSINESS` from the target copy, without `--send`.
|
||||
The gate needs the host's `launches disabled` line, a live `bus status` host
|
||||
with this run's pid and a boot from this start, the Console's address line,
|
||||
`/healthz` 200, and both command lines on the target copy. Then it checks
|
||||
that the host wrote no `sessions.json` and no `launches`, stops the Console
|
||||
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
|
||||
the host released `writer.lock`, and compares the backup and the live config
|
||||
and business files with sums it took before. The last line is
|
||||
`REHEARSAL_PASS` (exit 0) or `REHEARSAL_FAIL` (exit 1), then the directory.
|
||||
Usage exits 4. Nothing prunes rehearsal directories.
|
||||
|
||||
`bus start --no-launch` refuses every launch with `launches-disabled`,
|
||||
starts no thread ticker, and leaves an earlier host's `sessions.json` and
|
||||
its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
||||
|
||||
## Console service (`scripts/webui-service.sh`)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Brief: activate --no-send drops the Console's --send
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
|
||||
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
|
||||
the first draft took them before row 163.
|
||||
|
||||
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
|
||||
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
|
||||
live units or a slot file. Every test uses the stand-in `systemctl`, units, stores and data root that
|
||||
`packages/cli/tests/stack-release.test.mjs` already builds.
|
||||
|
||||
## Why
|
||||
|
||||
Mos ruled that the Console runs without `--send` until Q175 phase 2, because with `--send` live any same-uid
|
||||
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
|
||||
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
|
||||
|
||||
- `console_for_target` (scripts/stack-release.sh:664-693) carries the pointer's flags unchanged. Only
|
||||
`--tester-port` and the row 187 proof pair can change.
|
||||
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
|
||||
- `adopt` refuses once a pointer exists (:828).
|
||||
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
|
||||
`unit-flags-drift` on every later activate, pm-on and rollback.
|
||||
|
||||
So the tool can't drop `--send` today. This row adds that one path.
|
||||
|
||||
## The change
|
||||
|
||||
1. `activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE`, so `install_stack` leaves it out of the
|
||||
Console unit and `write_pointer` leaves it out of the pointer. Only `activate` takes it. It combines with
|
||||
`--tester-port`, the proof pair and `--no-proof`.
|
||||
2. An activate with `--no-send` may name the pointer's copy when the pointer's Console flags hold `--send`. It
|
||||
re-pins the live copy with only the Console flags changed, as row 187 does for the pair, including the
|
||||
live-copy plan note and the backup of the stopped store. Without `--send` in the pointer, a same-copy
|
||||
`--no-send` still refuses `already-active`.
|
||||
3. The steps log records `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
|
||||
--send, so there is nothing to drop`.
|
||||
4. `--plan` prints one `send:` line for an activate or rollback: `send: dropped; --no-send was given`,
|
||||
`send: off; --no-send was given and the pointer holds no --send`, `send: carried` when the pointer's
|
||||
`--send` goes on unchanged, or `send: off`.
|
||||
5. `rollback` and `pm-on` don't change: they carry the pointer's flags. So once `--no-send` has run, a rollback
|
||||
to an earlier copy keeps send off. There is no flag to turn send back on. Q175 phase 2 changes the Console
|
||||
flags in its own row.
|
||||
6. `docs/TOOLS.md` "Pinned stack update" gets `--no-send` in the usage block and one sentence.
|
||||
|
||||
## Tests (packages/cli/tests/stack-release.test.mjs)
|
||||
|
||||
1. Seeded preview pointer with `--send` and `--tester-port 7333`: a same-copy `activate B acme --no-send`
|
||||
passes. The pointer and the unit ExecStart lack `--send` and keep the preview flags and
|
||||
`--tester-port 7333` in order. Status says `unit flags: match the pointer`. The steps log has the drop
|
||||
line. The log line has from = to = B, and the stopped store was backed up.
|
||||
2. A plain same-copy activate still refuses `already-active`. So does a same-copy `--no-send` once `--send` is
|
||||
gone.
|
||||
3. `--plan` prints the four `send:` lines in their cases, with the live-copy note on the same-copy run.
|
||||
4. After `--no-send`, a rollback to the earlier copy and `pm-on` both leave `--send` out of the pointer and
|
||||
the unit.
|
||||
5. `--no-send` on `rollback`, `pm-on`, `status` or `adopt` is a usage error (exit 4).
|
||||
6. `--no-send` with the proof pair, and with `--no-proof`, on a pointer holding both: each drops what it
|
||||
names and keeps the rest.
|
||||
|
||||
## Mutants (each must fail at least one test)
|
||||
|
||||
- M1: the same-copy exception ignores `--no-send`.
|
||||
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
|
||||
- M3: `--no-send` drops `--send` from the pointer but not from the unit (it writes the pointer from
|
||||
`P_CONSOLE`).
|
||||
- M4: rollback re-adds `--send`.
|
||||
- M5: the arg parser accepts `--no-send` for rollback.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
|
||||
mutants are killed.
|
||||
+3
-3
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2219 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2236 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -102,7 +102,7 @@ Generated from `docs/plans/queue.json` revision 2219 by `queue render`. Do not e
|
||||
| 63 | Multi-project verbs: task verbs across the tree | darkwing; reviewers dewey, filbert | #1556 | done | 62 done | dewey and filbert approve on the issue naming the candidate manifest; tasks, harness and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_multi-project-sync.md` § Multi-project verbs: task verbs across the tree (row B) | lands before row 90 (decision 125): both share packages/tasks/README.md and tests/verbs.test.mjs, so row 90 rebases after this lands |
|
||||
| 64 | Managed sessions get their role's skills on both harnesses | sage; reviewers darkwing, filbert | #1557 | done | 65 done | darkwing and filbert approve on #1557 naming the candidate manifest; business, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the role's skills (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions get their role's skills on both harnesses | Landed (mos-0863/0864): 9f0039711 = c6d7d538f (range-diff =, patch-id 7e1948e2f2e9), F1 doc 6b6567b8b, packet ebfc23c43; gate-land GATE_PASSED 2384/0 on 6b6567b8b. D2 on #1557 comment 29228: claude-code coder rc13bcd555652 replied with its 4 skills, pi coder r3455505dd5da launch only (no pi credential on host, to Mos); both manifests list the 4 coder skills, sha256 = landed files. D3 window step 46de60893, D4 #1672 row 174. |
|
||||
| 65 | Model routing table: sessions resolve harness, model and thinking from one table | sage; reviewers darkwing, dewey | #1558 | done | 49 done, 131 done | Mos clears the routing table's public/ diff (decision 123, mos-0840) before in-review; darkwing and dewey approve on #1558; plugins, bus, business, harness, seat, cli, webui, test-*.sh green on Sage's rerun; landing session shows module, route, counted model, thinking; report names the mos-0840 view (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Model routing table: sessions resolve harness, model and thinking from one table | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 1, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 2, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 67 | Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | filbert; reviewers darkwing, dewey | #1560 | done | — | darkwing and dewey approve on #1560 naming the candidate manifest; bus, webui and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_decisions-read-follow-up.md` § Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | — |
|
||||
| 68 | Bus host runs an explicit Claude Code binary | sage; reviewers dewey, filbert | #1561 | done | — | Filbert and Dewey approve; cli, seat, harness suites and every scripts/test-*.sh green on Sage's gate rerun; unit reinstalled and opus probe through MOSAIC_CLAUDE_BIN (sage) | `docs/plans/2026-10-11_bus-claude-binary.md` § Bus host runs an explicit Claude Code binary | — |
|
||||
| 69 | Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | darkwing; reviewers dewey, filbert | #1562 | done | 55 done | dewey and filbert approve on the issue naming the candidate manifest; cli and queue node suites plus every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_terminal-app-follow-up.md` § Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | Sage accepts the two shortened texts in round 1 (hidden box way out, status line), both forced by the 40x10 frame. Follow-up at landing: when help is cut to fit, draw() points at mosaic inbox --json though help has no whole text; open it as its own row (build.md Found, not fixed) |
|
||||
@@ -199,7 +199,7 @@ Generated from `docs/plans/queue.json` revision 2219 by `queue render`. Do not e
|
||||
| 160 | I5 managed sessions as containers | researcher; reviewers dewey, filbert | #1655 | queued | 158 done | Filbert and Dewey approve on #1655 naming the candidate manifest; Sage's full gate green on the landing tree (sage) | `docs/plans/2026-10-18_containerized-install-rows.md` § I5 managed sessions as containers | Decision 96 orders I2-I6 after D1 and D2, which have no rows yet; the goal order sets priority |
|
||||
| 161 | I6 install mode, the pi worker's move and the documented limits | dewey; reviewers darkwing, filbert | #1656 | queued | 159 done, 160 done | Filbert and Darkwing approve on #1656 naming the candidate manifest; Sage's full gate green on the landing tree (sage) | `docs/plans/2026-10-18_containerized-install-rows.md` § I6 install mode, the pi worker's move and the documented limits | Decision 96 orders I2-I6 after D1 and D2, which have no rows yet. B2 is decision 148. The documented-limits section waits on Jason's answer to P19 333 (mos-0657) |
|
||||
| 162 | D1: health-gated stack activate and rollback (stack-release.sh) | darkwing; reviewers filbert | #1659 | done | — | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack activate and rollback, health-gated (D1) | P19 task 336 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json) |
|
||||
| 163 | D2: stack rehearsal on a scratch root (stack-release.sh rehearse) | sage; reviewers filbert | #1660 | briefed | 162 done | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack rehearsal on a scratch root (D2) | P19 task 337 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json) |
|
||||
| 163 | D2: stack rehearsal on a scratch root (stack-release.sh rehearse) | sage; reviewers filbert | #1660 | done | 162 done | Filbert approves the candidate manifest; cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release.md` § Stack rehearsal on a scratch root (D2) | Landed 8ecc7a239 (author Sage) on 7f57419b3; patch-id b8d5f8a37c24 matches candidate c6f4f4c12596 and eaa0f2666, the commit the gate ran on (base 4ca8d6e94; 7f57419b3 adds only agents/sage/work/q175-sso/DESIGN.md, which no test reads). Sage gate passed 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. Filbert approve 29447, rev 2234. Not live until a re-pin; Q173 backup and STATE apply there (mos-0971). |
|
||||
| 164 | Channel plug-in kind: cli and webui as its first modules (Q156 item 4, decision 149) | rocko; reviewers dewey | #1661 | done | 129 done | Dewey approves the candidate manifest; plugins, cli, bus and webui suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_channel-kind.md` § The channel kind, with cli and webui as its first modules | Design packet first, Sage sends it to Mos (human proof adjacent). Nothing goes between row 129 and this row (Mos mos-0726). |
|
||||
| 165 | The human adds and changes role instances: a CLI command and a Console view (P19 332) | darkwing; reviewers dewey, filbert | #1662 | done | 155 done | Mos relays Jason's confirmation before briefed; Dewey and Filbert approve on #1662 naming the candidate manifest; Mos's decision 123 go on the public/ diff; business, cli and webui suites and every scripts/test-*.sh green on Sage's gate (sage) (sage) | `docs/plans/2026-10-18_role-instance-verb.md` § The human adds and changes role instances | Briefed after 155 (b2f57ca70); hold lifted by mos-0805. Chief of staff stays out (mos-0787). sage-0918: lead approved file expansion (bus broker/runtime, webui cli.mjs, scripts/mosaic, commented channel-proof pin bump); new verbs human-only. Credentials/tracker stay out; second-reviewer gap with Mos (sage-0920). Acceptance 314 keeps the hand edit until this lands. Mos's decision 123 go on public/. |
|
||||
| 166 | stack-release.sh hardening after D1: drop-in precheck, is-active race, copy check against the target, quoted Environment= (row 162 follow-up) | darkwing; reviewers filbert | #1663 | done | 162 done | Filbert approves the candidate manifest; cli suite and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-18_stack-release-hardening.md` § Brief: stack-release.sh hardening after D1 | Landed 4b738e2da on 64922106d (pushed). It is Darkwing r2 build.patch, manifest 4fc0613f OK, patch-id 7a4fdc3bf53b equal to build.patch. Sage gate GATE_PASSED: 15 pkg suites 2399/0 (cli 364), 10 script suites green. Mos R1 (mos-0867): no quotes or drop-ins on live units. stack-release.sh blob now ed5b41d4677e; Window B P3 reruns on it (mos-0867 R2). Row 169 rebases onto 4b738e2da. |
|
||||
|
||||
+566
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
|
||||
"revision": 2219,
|
||||
"revision": 2236,
|
||||
"rows": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -5515,7 +5515,72 @@
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": []
|
||||
"receipts": [
|
||||
{
|
||||
"reviewer": "filbert",
|
||||
"op": "filbert-66-r1",
|
||||
"at": "2026-10-20T13:18:21.785Z",
|
||||
"verdict": "changes",
|
||||
"comment": 29439,
|
||||
"candidate": "e68c6bce5ec4b61e7d47262857dd3ee42893979aa864d68208636fb45ed3f909"
|
||||
},
|
||||
{
|
||||
"reviewer": "dewey",
|
||||
"op": "dewey-66-r1",
|
||||
"at": "2026-10-20T13:23:54.665Z",
|
||||
"verdict": "changes",
|
||||
"comment": 29440,
|
||||
"candidate": "e68c6bce5ec4b61e7d47262857dd3ee42893979aa864d68208636fb45ed3f909"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 2,
|
||||
"op": "sage-r66-inreview-2",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T13:51:18.316Z",
|
||||
"issue": 1559,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c",
|
||||
"text": "b99e7f4bf58ac68c8f5fa58eb7b90c0f85b15ca9a80fd093f6aaeafeac931608 adapters/README.md\nc32d2a3276db92433d4e3c4f4b45fab048d9a46119d9f69cead6a11cfacfeadb agents/sage/work/queue-66/BUILD.md\n8777fac3acb89f3af4ac0db2e7c44e309408a8ab8d1b0a0942c81c54ad295fa9 docs/TOOLS.md\nea11398dc5751a0f9415079e7c03f413e97f5443f6bd0fc1fc2319ec646c20cb packages/cli/tests/stack-copy.test.mjs\nb027dc74583217b4151173ecbabfcfbfc7b108ac6b7a8e93dd21a2834051714f packages/harness/README.md\nbfb2156c609eeaf87c9e209c562576a5ef47535eaeee61d841bb0bd75a76a69d packages/harness/src/runner.mjs\n0a27f2c8de7b9191f9c0d4a209331c63ff7630923e669838b41715924b0a64b0 packages/harness/tests/bundle.test.mjs\n81d0352f2dcd41eb2e2ef962365d9b97515d7e8d9668a35bb9ce9dce4f1451ea packages/harness/tests/claude-session.test.mjs\nb16f33442cefd5b5ae2af85edab33250ee6b31025e437edf0e4eba9fe0afa793 packages/harness/tests/fixtures/fake-adapter.mjs\n1e187a2e23a197640d139e6975dfaf83dd7e337c360d128833678dc60f3e2046 packages/harness/tests/pi-session.test.mjs\n37ca6ad6467573658c1ac6807b6cd00e3f9da289107fb689190a1872bff9b89c packages/harness/tests/runner.test.mjs\n04c24058f37260c9192d5036c8cf28b50faba8e3e3bf57f198d233485d0d57e6 packages/plugins/harness/claude-code/index.mjs\n2c1252e8e8db3ba4551bf1e34156ddf4e933cc664fc157105bef9a2705d6ce3b packages/plugins/harness/pi/index.mjs\n7e0bea27d4360a39874fa64b3506a85f83888613a285ec0b087d74bf22b76421 packages/plugins/README.md\nec4ec9502ee8e766e3abd04d9f724c0d8ff4ef114394a0f76d5320c2dc715c5c packages/plugins/tests/harness.test.mjs\nf6141dac9a8c61573e4198480fed85e093992ee9c19fb55e9bc2752381b7b07a packages/seat/tests/sandbox.test.mjs\ne60bf54c23e04436a982174348857458039a22e1769fc9ad4db8d4f9b57f2e86 packages/webui/tests/pinned-copy.test.mjs\n3ba3dbd9ff8dd68f0aa85bac92a8960af10166130bbc16df74fd7340776bb3b1 scripts/stack-copy.sh\n7ee6ed6e6ac55b1348d40a358372e66c86fdebacd90001d447040133e1187882 tools/unslop-hook/claude-hook.js\nce1dfb11d0a8e8efd3d6dae67cc259c2e7443a62c7416bdedd4c71306d8c6df1 tools/unslop-hook/extension.ts\nf07d67806f40763b4be87d31c2976448b5cfc34d7328519fae2d8313b1ca3e20 tools/unslop-hook/README.md\ndbe3d5c9ae654d2401e3c496a6b03757a438e4a551fd8e460df8c82902bb80a7 tools/unslop-hook/test-unslop-check.js\n76be78178a0703b9d20a7df2278ecb13f429dea90cea45180f22302e267ea25e tools/unslop-hook/unslop-check.js\n"
|
||||
},
|
||||
"request": "comment",
|
||||
"attempts": [
|
||||
{
|
||||
"op": "sage-r66-inreview-2",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T13:51:18.316Z",
|
||||
"state": "posted",
|
||||
"comment": 29441,
|
||||
"transport": {
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29441,
|
||||
"detail": "created"
|
||||
},
|
||||
"resolutions": []
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": [
|
||||
{
|
||||
"reviewer": "filbert",
|
||||
"op": "filbert-66-r2",
|
||||
"at": "2026-10-20T13:56:19.376Z",
|
||||
"verdict": "approve",
|
||||
"comment": 29442,
|
||||
"candidate": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c"
|
||||
},
|
||||
{
|
||||
"reviewer": "dewey",
|
||||
"op": "dewey-66-r2",
|
||||
"at": "2026-10-20T14:10:43.258Z",
|
||||
"verdict": "approve",
|
||||
"comment": 29443,
|
||||
"candidate": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -5526,8 +5591,8 @@
|
||||
"note": "Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091.",
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-11T19:53:27.579Z",
|
||||
"updatedAt": "2026-10-20T13:10:43.972Z",
|
||||
"updatedBy": "sage"
|
||||
"updatedAt": "2026-10-20T14:10:43.258Z",
|
||||
"updatedBy": "dewey"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
@@ -17172,7 +17237,7 @@
|
||||
"closes": [
|
||||
1660
|
||||
],
|
||||
"state": "briefed",
|
||||
"state": "done",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
@@ -17192,12 +17257,95 @@
|
||||
"reviewers": [
|
||||
"filbert"
|
||||
],
|
||||
"review": null,
|
||||
"review": {
|
||||
"rounds": [
|
||||
{
|
||||
"n": 1,
|
||||
"op": "sage-163-inreview-1",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T14:46:22.020Z",
|
||||
"issue": 1660,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "430212299bebcac52013332ce9fd0ba43033e105bc61f53415ad1000f3f52c62",
|
||||
"text": "04d72e5ef4d8d5fc85a4f11aa51331354696cc7cf66af2ed7540cf6cfc0153b9 agents/sage/work/queue-163/BUILD.md\n5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md\n52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs\n5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs\ne9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs\n42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs\n4dfeefa1bb3c11e8633d75df4f628497ebd427ebf8402ba5efc3e5c7fd030057 packages/cli/tests/stack-release.test.mjs\n5abdeab8bc6b63d291d0054124a4311a2260b590c68f758daa142a9deb2f4a72 packages/seat/src/session.mjs\n741883e399d77d429cc29a87beef6db9c51c1759669b025006f037001d101eb6 packages/seat/tests/session.test.mjs\n5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh\n"
|
||||
},
|
||||
"request": "comment",
|
||||
"attempts": [
|
||||
{
|
||||
"op": "sage-163-inreview-1",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T14:46:22.020Z",
|
||||
"state": "posted",
|
||||
"comment": 29444,
|
||||
"transport": {
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29444,
|
||||
"detail": "created"
|
||||
},
|
||||
"resolutions": []
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": [
|
||||
{
|
||||
"reviewer": "filbert",
|
||||
"op": "filbert-163-r1",
|
||||
"at": "2026-10-20T15:05:43.690Z",
|
||||
"verdict": "changes",
|
||||
"comment": 29445,
|
||||
"candidate": "430212299bebcac52013332ce9fd0ba43033e105bc61f53415ad1000f3f52c62"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 2,
|
||||
"op": "sage-163-inreview-2",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T15:24:17.566Z",
|
||||
"issue": 1660,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "c6f4f4c125967525f771d0c39445c131916443cbd2754ff58fe8353876e303ce",
|
||||
"text": "f04d12a963b3199420f1d9c41fbdae67faf46f710a805cb9358f1fb52f5faed9 agents/sage/work/queue-163/BUILD.md\n5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md\n52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs\n5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs\ne9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs\n42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs\nb01ff656962c6fdb5bc3554cfbb13645b7f25adae5f6cd9608af8de6d6091c6c packages/cli/tests/stack-release.test.mjs\n448138d12404346989a6751a01c747718cef7d2bec19a0639a29fa6a8b74a82d packages/seat/src/session.mjs\ncd6277c4e7155a7c93d09f5bc7cca1130708a35a6b75aa02d99af8360d6338b0 packages/seat/tests/session.test.mjs\n5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh\n"
|
||||
},
|
||||
"request": "comment",
|
||||
"attempts": [
|
||||
{
|
||||
"op": "sage-163-inreview-2",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T15:24:17.566Z",
|
||||
"state": "posted",
|
||||
"comment": 29446,
|
||||
"transport": {
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29446,
|
||||
"detail": "created"
|
||||
},
|
||||
"resolutions": []
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": [
|
||||
{
|
||||
"reviewer": "filbert",
|
||||
"op": "filbert-163-r2",
|
||||
"at": "2026-10-20T15:40:19.458Z",
|
||||
"verdict": "approve",
|
||||
"comment": 29447,
|
||||
"candidate": "c6f4f4c125967525f771d0c39445c131916443cbd2754ff58fe8353876e303ce"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"claim": null,
|
||||
"note": "P19 task 337 (agents/sage/work/vikunja-setup/d1-d2-rows-2026-10-18.json)",
|
||||
"note": "Landed 8ecc7a239 (author Sage) on 7f57419b3; patch-id b8d5f8a37c24 matches candidate c6f4f4c12596 and eaa0f2666, the commit the gate ran on (base 4ca8d6e94; 7f57419b3 adds only agents/sage/work/q175-sso/DESIGN.md, which no test reads). Sage gate passed 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. Filbert approve 29447, rev 2234. Not live until a re-pin; Q173 backup and STATE apply there (mos-0971).",
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-18T15:14:08.410Z",
|
||||
"updatedAt": "2026-10-20T13:13:04.706Z",
|
||||
"updatedAt": "2026-10-20T18:17:45.831Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
@@ -78198,6 +78346,416 @@
|
||||
"receipt": "ok sage-163-briefed rev 2219 row 163 queued→briefed"
|
||||
},
|
||||
"viewSha": "cfbfed778d1ce071f30fcf14c353861b15957ed1c287c549c7854d1c43e475c2"
|
||||
},
|
||||
{
|
||||
"rev": 2220,
|
||||
"op": "sage-163-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T13:14:51.925Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-163-start rev 2220 row 163 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "2de5c25999f9d49888b1283c1b2da72033894a479ac0a4cb79afbf0c29eec4e8"
|
||||
},
|
||||
{
|
||||
"rev": 2221,
|
||||
"op": "filbert-66-r1",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 66,
|
||||
"verdict": "changes",
|
||||
"comment": 29439,
|
||||
"candidate": "e68c6bce5ec4b61e7d47262857dd3ee42893979aa864d68208636fb45ed3f909"
|
||||
},
|
||||
"by": "filbert",
|
||||
"at": "2026-10-20T13:18:21.785Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 66,
|
||||
"round": 1,
|
||||
"verdict": "changes",
|
||||
"receipt": "ok filbert-66-r1 rev 2221 row 66 round 1 changes by filbert (comment 29439)"
|
||||
},
|
||||
"viewSha": "c77e08d366600e792460fd04a0b4cf7189f0d244cbad369a819f5af02f3f6697"
|
||||
},
|
||||
{
|
||||
"rev": 2222,
|
||||
"op": "dewey-66-r1",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 66,
|
||||
"verdict": "changes",
|
||||
"comment": 29440,
|
||||
"candidate": "e68c6bce5ec4b61e7d47262857dd3ee42893979aa864d68208636fb45ed3f909"
|
||||
},
|
||||
"by": "dewey",
|
||||
"at": "2026-10-20T13:23:54.665Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 66,
|
||||
"round": 1,
|
||||
"verdict": "changes",
|
||||
"receipt": "ok dewey-66-r1 rev 2222 row 66 round 1 changes by dewey (comment 29440)"
|
||||
},
|
||||
"viewSha": "cea1d0b0d416b20541d896b98061bc6a5f5691725753ec1cef215fc422755154"
|
||||
},
|
||||
{
|
||||
"rev": 2223,
|
||||
"op": "sage-r66-r2-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 66,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T13:51:01.706Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 66,
|
||||
"from": "in-review",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-r66-r2-start rev 2223 row 66 in-review→in-progress"
|
||||
},
|
||||
"viewSha": "78585e61c6bf1355b3d593b087ccc27dcbabf5e9c8c373c5906d60783a1602dd"
|
||||
},
|
||||
{
|
||||
"rev": 2224,
|
||||
"op": "sage-r66-inreview-2",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 66,
|
||||
"to": "in-review",
|
||||
"reason": null,
|
||||
"candidate": "agents/sage/work/queue-66/candidate-manifest.sha256",
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T13:51:18.316Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 66,
|
||||
"from": "in-progress",
|
||||
"to": "in-review",
|
||||
"round": 2,
|
||||
"issue": 1559,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c",
|
||||
"text": "b99e7f4bf58ac68c8f5fa58eb7b90c0f85b15ca9a80fd093f6aaeafeac931608 adapters/README.md\nc32d2a3276db92433d4e3c4f4b45fab048d9a46119d9f69cead6a11cfacfeadb agents/sage/work/queue-66/BUILD.md\n8777fac3acb89f3af4ac0db2e7c44e309408a8ab8d1b0a0942c81c54ad295fa9 docs/TOOLS.md\nea11398dc5751a0f9415079e7c03f413e97f5443f6bd0fc1fc2319ec646c20cb packages/cli/tests/stack-copy.test.mjs\nb027dc74583217b4151173ecbabfcfbfc7b108ac6b7a8e93dd21a2834051714f packages/harness/README.md\nbfb2156c609eeaf87c9e209c562576a5ef47535eaeee61d841bb0bd75a76a69d packages/harness/src/runner.mjs\n0a27f2c8de7b9191f9c0d4a209331c63ff7630923e669838b41715924b0a64b0 packages/harness/tests/bundle.test.mjs\n81d0352f2dcd41eb2e2ef962365d9b97515d7e8d9668a35bb9ce9dce4f1451ea packages/harness/tests/claude-session.test.mjs\nb16f33442cefd5b5ae2af85edab33250ee6b31025e437edf0e4eba9fe0afa793 packages/harness/tests/fixtures/fake-adapter.mjs\n1e187a2e23a197640d139e6975dfaf83dd7e337c360d128833678dc60f3e2046 packages/harness/tests/pi-session.test.mjs\n37ca6ad6467573658c1ac6807b6cd00e3f9da289107fb689190a1872bff9b89c packages/harness/tests/runner.test.mjs\n04c24058f37260c9192d5036c8cf28b50faba8e3e3bf57f198d233485d0d57e6 packages/plugins/harness/claude-code/index.mjs\n2c1252e8e8db3ba4551bf1e34156ddf4e933cc664fc157105bef9a2705d6ce3b packages/plugins/harness/pi/index.mjs\n7e0bea27d4360a39874fa64b3506a85f83888613a285ec0b087d74bf22b76421 packages/plugins/README.md\nec4ec9502ee8e766e3abd04d9f724c0d8ff4ef114394a0f76d5320c2dc715c5c packages/plugins/tests/harness.test.mjs\nf6141dac9a8c61573e4198480fed85e093992ee9c19fb55e9bc2752381b7b07a packages/seat/tests/sandbox.test.mjs\ne60bf54c23e04436a982174348857458039a22e1769fc9ad4db8d4f9b57f2e86 packages/webui/tests/pinned-copy.test.mjs\n3ba3dbd9ff8dd68f0aa85bac92a8960af10166130bbc16df74fd7340776bb3b1 scripts/stack-copy.sh\n7ee6ed6e6ac55b1348d40a358372e66c86fdebacd90001d447040133e1187882 tools/unslop-hook/claude-hook.js\nce1dfb11d0a8e8efd3d6dae67cc259c2e7443a62c7416bdedd4c71306d8c6df1 tools/unslop-hook/extension.ts\nf07d67806f40763b4be87d31c2976448b5cfc34d7328519fae2d8313b1ca3e20 tools/unslop-hook/README.md\ndbe3d5c9ae654d2401e3c496a6b03757a438e4a551fd8e460df8c82902bb80a7 tools/unslop-hook/test-unslop-check.js\n76be78178a0703b9d20a7df2278ecb13f429dea90cea45180f22302e267ea25e tools/unslop-hook/unslop-check.js\n"
|
||||
},
|
||||
"receipt": "ok sage-r66-inreview-2 rev 2224 row 66 in-progress→in-review round 2 on #1559; request sage-r66-inreview-2 requesting"
|
||||
},
|
||||
"viewSha": "d94d093d161d5a80633d33499652bf2343366e30a0a3c61d835d6dc915d48f01"
|
||||
},
|
||||
{
|
||||
"rev": 2225,
|
||||
"op": "sage-r66-inreview-2.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 66,
|
||||
"attempt": "sage-r66-inreview-2",
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29441,
|
||||
"detail": "created"
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T13:51:35.785Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 66,
|
||||
"round": 2,
|
||||
"attempt": "sage-r66-inreview-2",
|
||||
"state": "posted",
|
||||
"receipt": "ok sage-r66-inreview-2.outcome rev 2225 row 66 round 2 request sage-r66-inreview-2 posted comment 29441"
|
||||
},
|
||||
"viewSha": "1fe95c522167fb29ebc1b2ac2d765f8a6e4ad3d72ca784dfd9ec7a6877704c22"
|
||||
},
|
||||
{
|
||||
"rev": 2226,
|
||||
"op": "filbert-66-r2",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 66,
|
||||
"verdict": "approve",
|
||||
"comment": 29442,
|
||||
"candidate": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c"
|
||||
},
|
||||
"by": "filbert",
|
||||
"at": "2026-10-20T13:56:19.376Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 66,
|
||||
"round": 2,
|
||||
"verdict": "approve",
|
||||
"receipt": "ok filbert-66-r2 rev 2226 row 66 round 2 approve by filbert (comment 29442)"
|
||||
},
|
||||
"viewSha": "8a180cd3e1f7abc082a396811907fc3d4d9ad0ced95e0e495225361a7cfe4124"
|
||||
},
|
||||
{
|
||||
"rev": 2227,
|
||||
"op": "dewey-66-r2",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 66,
|
||||
"verdict": "approve",
|
||||
"comment": 29443,
|
||||
"candidate": "cee63e06fdea9896ac6ad841ba8a548fe2e0cff60dabe25e81ba928c0f51ff2c"
|
||||
},
|
||||
"by": "dewey",
|
||||
"at": "2026-10-20T14:10:43.258Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 66,
|
||||
"round": 2,
|
||||
"verdict": "approve",
|
||||
"receipt": "ok dewey-66-r2 rev 2227 row 66 round 2 approve by dewey (comment 29443)"
|
||||
},
|
||||
"viewSha": "f9e9e20d0304ad7781f343738f7966ac8d632933ee267e17870a0eca418dcd6d"
|
||||
},
|
||||
{
|
||||
"rev": 2228,
|
||||
"op": "sage-163-inreview-1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"to": "in-review",
|
||||
"reason": null,
|
||||
"candidate": "agents/sage/work/queue-163/candidate-manifest.sha256",
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T14:46:22.020Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"from": "in-progress",
|
||||
"to": "in-review",
|
||||
"round": 1,
|
||||
"issue": 1660,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "430212299bebcac52013332ce9fd0ba43033e105bc61f53415ad1000f3f52c62",
|
||||
"text": "04d72e5ef4d8d5fc85a4f11aa51331354696cc7cf66af2ed7540cf6cfc0153b9 agents/sage/work/queue-163/BUILD.md\n5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md\n52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs\n5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs\ne9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs\n42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs\n4dfeefa1bb3c11e8633d75df4f628497ebd427ebf8402ba5efc3e5c7fd030057 packages/cli/tests/stack-release.test.mjs\n5abdeab8bc6b63d291d0054124a4311a2260b590c68f758daa142a9deb2f4a72 packages/seat/src/session.mjs\n741883e399d77d429cc29a87beef6db9c51c1759669b025006f037001d101eb6 packages/seat/tests/session.test.mjs\n5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh\n"
|
||||
},
|
||||
"receipt": "ok sage-163-inreview-1 rev 2228 row 163 in-progress→in-review round 1 on #1660; request sage-163-inreview-1 requesting"
|
||||
},
|
||||
"viewSha": "05dc005f96eb767708faf26064cfc3bea0b3c804472826890c85927199f01bda"
|
||||
},
|
||||
{
|
||||
"rev": 2229,
|
||||
"op": "sage-163-inreview-1.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"attempt": "sage-163-inreview-1",
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29444,
|
||||
"detail": "created"
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T14:46:39.862Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"round": 1,
|
||||
"attempt": "sage-163-inreview-1",
|
||||
"state": "posted",
|
||||
"receipt": "ok sage-163-inreview-1.outcome rev 2229 row 163 round 1 request sage-163-inreview-1 posted comment 29444"
|
||||
},
|
||||
"viewSha": "8efcd553a4a63e78b6229ef17c18f3af189a5ffff406a0592cfd8a1d85fd597c"
|
||||
},
|
||||
{
|
||||
"rev": 2230,
|
||||
"op": "filbert-163-r1",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"verdict": "changes",
|
||||
"comment": 29445,
|
||||
"candidate": "430212299bebcac52013332ce9fd0ba43033e105bc61f53415ad1000f3f52c62"
|
||||
},
|
||||
"by": "filbert",
|
||||
"at": "2026-10-20T15:05:43.690Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"round": 1,
|
||||
"verdict": "changes",
|
||||
"receipt": "ok filbert-163-r1 rev 2230 row 163 round 1 changes by filbert (comment 29445)"
|
||||
},
|
||||
"viewSha": "a4fd1545793c24a4eaf9876388e8cdfe54befe786f592823a6e7dc8cc9b82664"
|
||||
},
|
||||
{
|
||||
"rev": 2231,
|
||||
"op": "sage-163-r2-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T15:23:54.631Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"from": "in-review",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-163-r2-start rev 2231 row 163 in-review→in-progress"
|
||||
},
|
||||
"viewSha": "a8fa98b005a3adcdf70f032dd4f047555b8e53773525df31fe7bb8d90167dfdd"
|
||||
},
|
||||
{
|
||||
"rev": 2232,
|
||||
"op": "sage-163-inreview-2",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"to": "in-review",
|
||||
"reason": null,
|
||||
"candidate": "agents/sage/work/queue-163/r2/candidate-manifest.sha256",
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T15:24:17.566Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"from": "in-progress",
|
||||
"to": "in-review",
|
||||
"round": 2,
|
||||
"issue": 1660,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "c6f4f4c125967525f771d0c39445c131916443cbd2754ff58fe8353876e303ce",
|
||||
"text": "f04d12a963b3199420f1d9c41fbdae67faf46f710a805cb9358f1fb52f5faed9 agents/sage/work/queue-163/BUILD.md\n5bd1bc697cd56e57b00e60c0e80be72cd1043a55a6d2fc0b9e2ccdd0c5cf9802 docs/TOOLS.md\n52597d7fa1469510fcf8e6dfd63664ad4c492bc17039885aa839231798693058 packages/cli/src/cli.mjs\n5492ebb5aa3c9c85874e4c7de7b65fc37733e2db636690c11fe2d2fdf72c2dd0 packages/cli/src/launcher.mjs\ne9ce0e0f7368d3fb2e1edbd4ebc724513ec19d7bac0c993f8be0d9494427be07 packages/cli/tests/host.test.mjs\n42519898b5bcf84c21088647e161a86934d4fe351207f736b9b120b6e1c2b91b packages/cli/tests/launcher.test.mjs\nb01ff656962c6fdb5bc3554cfbb13645b7f25adae5f6cd9608af8de6d6091c6c packages/cli/tests/stack-release.test.mjs\n448138d12404346989a6751a01c747718cef7d2bec19a0639a29fa6a8b74a82d packages/seat/src/session.mjs\ncd6277c4e7155a7c93d09f5bc7cca1130708a35a6b75aa02d99af8360d6338b0 packages/seat/tests/session.test.mjs\n5bb3ac40102ce515f1acdcbc5670feb161ce40a193e94e2f8b104ebf98eddc38 scripts/stack-release.sh\n"
|
||||
},
|
||||
"receipt": "ok sage-163-inreview-2 rev 2232 row 163 in-progress→in-review round 2 on #1660; request sage-163-inreview-2 requesting"
|
||||
},
|
||||
"viewSha": "d5b60362859a3eb13c9154584a496b3295cb4e02d7bd4f2ecc5fddf308a2567f"
|
||||
},
|
||||
{
|
||||
"rev": 2233,
|
||||
"op": "sage-163-inreview-2.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"attempt": "sage-163-inreview-2",
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29446,
|
||||
"detail": "created"
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T15:24:35.735Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"round": 2,
|
||||
"attempt": "sage-163-inreview-2",
|
||||
"state": "posted",
|
||||
"receipt": "ok sage-163-inreview-2.outcome rev 2233 row 163 round 2 request sage-163-inreview-2 posted comment 29446"
|
||||
},
|
||||
"viewSha": "aa2b9e6a56cb4cd997b4dce98e377aae1e7ab716fa82c7ef01f051f9d5a9cd0d"
|
||||
},
|
||||
{
|
||||
"rev": 2234,
|
||||
"op": "filbert-163-r2",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"verdict": "approve",
|
||||
"comment": 29447,
|
||||
"candidate": "c6f4f4c125967525f771d0c39445c131916443cbd2754ff58fe8353876e303ce"
|
||||
},
|
||||
"by": "filbert",
|
||||
"at": "2026-10-20T15:40:19.458Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"round": 2,
|
||||
"verdict": "approve",
|
||||
"receipt": "ok filbert-163-r2 rev 2234 row 163 round 2 approve by filbert (comment 29447)"
|
||||
},
|
||||
"viewSha": "2cde9426b532b3f1082cb23c1e659470b73d735cbbbd9350b57966bc15629f7a"
|
||||
},
|
||||
{
|
||||
"rev": 2235,
|
||||
"op": "sage-163-landnote",
|
||||
"verb": "note",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"text": "Landed 8ecc7a239 (author Sage) on 7f57419b3; patch-id b8d5f8a37c24 matches candidate c6f4f4c12596 and eaa0f2666, the commit the gate ran on (base 4ca8d6e94; 7f57419b3 adds only agents/sage/work/q175-sso/DESIGN.md, which no test reads). Sage gate passed 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. Filbert approve 29447, rev 2234. Not live until a re-pin; Q173 backup and STATE apply there (mos-0971)."
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:17:27.937Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"field": "note",
|
||||
"receipt": "ok sage-163-landnote rev 2235 row 163 note"
|
||||
},
|
||||
"viewSha": "32521551fc8fdafc3ca5c187f3f6e18aff72c61ede4ab11fe7bc2ccfc91ea897"
|
||||
},
|
||||
{
|
||||
"rev": 2236,
|
||||
"op": "sage-163-done",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 163,
|
||||
"to": "done",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:17:45.831Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 163,
|
||||
"from": "in-review",
|
||||
"to": "done",
|
||||
"round": 2,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-163-done rev 2236 row 163 in-review→done round 2"
|
||||
},
|
||||
"viewSha": "e44815d32d46b91ee37d4c3c4701716956ba35d29c16c24c89020cce345bd61b"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export const USAGE = `usage:
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
mosaic bus start <business> [--pm]
|
||||
mosaic bus start <business> [--pm | --no-launch]
|
||||
mosaic bus stop
|
||||
mosaic bus status [--json]`;
|
||||
|
||||
@@ -482,8 +482,11 @@ async function launches(rest, io, deps) {
|
||||
async function bus(rest, io, deps) {
|
||||
const [sub, ...args] = rest;
|
||||
if (sub === "start") {
|
||||
const parsed = parse(args, { flags: ["--pm"] });
|
||||
const parsed = parse(args, { flags: ["--pm", "--no-launch"] });
|
||||
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
|
||||
// Row 163: --no-launch is the D2 rehearsal's host. No unit passes it.
|
||||
const launches = !parsed.flags.has("--no-launch");
|
||||
if (!launches && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
|
||||
const businessId = parsed.positional[0];
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
// Fail closed, and say so (vikunja:19/302): a refused load is recorded where the Console and
|
||||
@@ -500,9 +503,9 @@ async function bus(rest, io, deps) {
|
||||
const binding = readNotifyConfig(system.dataRoot, businessId);
|
||||
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
|
||||
try {
|
||||
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), ...(deps.launcher ?? {}) });
|
||||
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(deps.launcher ?? {}) });
|
||||
await launcher.listen();
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}${launches ? "" : ", launches disabled"}\n`);
|
||||
if (parsed.flags.has("--pm")) {
|
||||
const pm = await launcher.launchPm();
|
||||
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
|
||||
|
||||
@@ -153,7 +153,11 @@ function call(name, what, fn) {
|
||||
}
|
||||
|
||||
// host: startHost()'s handle. tracker: true when the broker has task verbs for
|
||||
// the business. makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// the business. launches: false for `bus start --no-launch` (row 163, the D2
|
||||
// rehearsal): start() refuses launches-disabled, listen() starts no ticker, a
|
||||
// session's launch request is refused, recover() kills and ends nothing, and
|
||||
// sessions.json stays as it was.
|
||||
// makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
|
||||
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
|
||||
// the tests; the command line never sets them.
|
||||
@@ -162,6 +166,7 @@ export function createLauncher({
|
||||
system,
|
||||
env = process.env,
|
||||
tracker = false,
|
||||
launches = true,
|
||||
makeLoader = createLoader,
|
||||
bwrap = BWRAP,
|
||||
readOnly = [],
|
||||
@@ -187,7 +192,7 @@ export function createLauncher({
|
||||
} catch (e) {
|
||||
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
|
||||
}
|
||||
const registry = new Registry(dataRoot);
|
||||
const registry = new Registry(dataRoot, { readOnly: !launches });
|
||||
const children = new Map();
|
||||
let chain = Promise.resolve();
|
||||
let closed = false;
|
||||
@@ -323,6 +328,8 @@ export function createLauncher({
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
|
||||
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
|
||||
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
|
||||
// A thread's store is checked first, so a bad id or an unsafe directory
|
||||
// launches nothing.
|
||||
let store = null;
|
||||
@@ -627,6 +634,11 @@ export function createLauncher({
|
||||
await host.op({ op: "refuse", cap, code: "not-a-role" }).catch(() => {});
|
||||
return { ok: false, error: "not-a-role" };
|
||||
}
|
||||
if (!launches) {
|
||||
await host.op({ op: "refuse", cap, code: "launches-disabled" }).catch(() => {});
|
||||
record({ event: "refused", by: who.role, instance, code: "launches-disabled" });
|
||||
return { ok: false, error: "launches-disabled" };
|
||||
}
|
||||
let checked;
|
||||
try {
|
||||
if (typeof instance !== "string") throw new Refusal("invalid-request");
|
||||
@@ -675,6 +687,13 @@ export function createLauncher({
|
||||
// This broker never bound those runs, so each is bound again first with
|
||||
// its identical launch record, which the broker accepts.
|
||||
async function recover() {
|
||||
// Row 163 (mos-0961): a --no-launch host never signals or ends an earlier host's session. Its
|
||||
// data root may hold a sessions.json that names live processes, so it only says what it left.
|
||||
if (!launches) {
|
||||
for (const s of leftover) log(`run ${s.run} (${s.instance}) from an earlier host: left as it is, this host was started with --no-launch`);
|
||||
leftover = [];
|
||||
return;
|
||||
}
|
||||
for (const s of leftover) {
|
||||
if (startTimeOf(s.pid) === s.startTime && (cmdlineOf(s.pid) ?? [])[0] === bwrap) {
|
||||
try {
|
||||
@@ -744,7 +763,7 @@ export function createLauncher({
|
||||
});
|
||||
chmodSync(path, 0o600);
|
||||
await gitSocket.listen();
|
||||
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
if (b.launch && launches) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
}
|
||||
|
||||
// SIGTERM every runner, wait, then end what's left. Runs before the host
|
||||
|
||||
@@ -811,6 +811,29 @@ test("bus start runs until bus stop; status reports it while it runs", async (t)
|
||||
assert.match(again.stdout, /no host runs/);
|
||||
});
|
||||
|
||||
// Row 163 (mos-0961): the D2 rehearsal's host.
|
||||
test("bus start --no-launch says launches are disabled in its host-up line and stops cleanly; with --pm it is a usage error", async (t) => {
|
||||
const f = fixture(tmp(t));
|
||||
notifyConfig(f.dataRoot, "acme", null);
|
||||
const both = spawnSync(process.execPath, [CLI, "bus", "start", "acme", "--pm", "--no-launch"], { env: f.env, encoding: "utf8", timeout: 40000 });
|
||||
assert.equal(both.status, 4, both.stderr);
|
||||
assert.match(both.stderr, /bus start takes --pm or --no-launch, not both/);
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
|
||||
const child = spawn(process.execPath, [CLI, "bus", "start", "acme", "--no-launch"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
t.after(() => child.exitCode === null && child.kill("SIGKILL"));
|
||||
let out = "";
|
||||
child.stdout.on("data", (b) => (out += b));
|
||||
child.stderr.on("data", (b) => (out += b));
|
||||
await until(() => /bus host up: business acme.*\n/.test(out), 30000);
|
||||
assert.match(out, /bus host up: business acme, socket \S+, notifier off, launches disabled\n/);
|
||||
// The launcher got launches false: its Registry is read-only, so no sessions.json appears.
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus-host", "sessions.json")), false);
|
||||
const exit = once(child, "exit");
|
||||
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
|
||||
assert.equal(stop.status, 0, stop.stderr);
|
||||
assert.equal((await exit)[0], 0, out);
|
||||
});
|
||||
|
||||
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
|
||||
const dir = tmp(t);
|
||||
const script = join(REPO, "scripts", "bus-service.sh");
|
||||
@@ -818,6 +841,8 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
|
||||
assert.equal(render.status, 0, render.stderr);
|
||||
assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
|
||||
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
|
||||
// Row 163: --no-launch is for the D2 rehearsal's own host only; no unit starts a bus with it.
|
||||
assert.doesNotMatch(render.stdout, /--no-launch/);
|
||||
// One session credential file, optional so Pi sessions start without it.
|
||||
// The README's paragraph names the same file; nothing else does.
|
||||
const files = render.stdout.match(/^EnvironmentFile=.*$/gm);
|
||||
|
||||
@@ -15,11 +15,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, runsDir, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { childrenOf } from "../../seat/src/proc.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { cmdlineOf, startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { mockAnthropic, REPO } from "../../harness/tests/helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
@@ -901,6 +902,56 @@ for (const exited of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
// Row 163 (mos-0961): the D2 rehearsal host. A live bwrap process the test owns stands in for an
|
||||
// earlier host's session; its outer bwrap is what recover() would kill, and its child stands in
|
||||
// for the runner that close() would signal.
|
||||
test("bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was", { skip, timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
const { f } = given;
|
||||
const box = spawn(BWRAP, ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--unshare-pid", "--die-with-parent", "sleep", "60"], { stdio: "ignore" });
|
||||
t.after(() => box.kill("SIGKILL"));
|
||||
const pid = box.pid;
|
||||
const startTime = await until(() => startTimeOf(pid));
|
||||
const runnerPid = await until(() => childrenOf(pid)[0]);
|
||||
const runnerStartTime = await until(() => startTimeOf(runnerPid));
|
||||
assert.equal(cmdlineOf(pid)[0], BWRAP, "recover() would take it for a session");
|
||||
new Registry(f.dataRoot).add({ business: "acme", instance: "chief", run: "run-left", harness: "pi", pid, startTime, runnerPid, runnerStartTime });
|
||||
const before = readFileSync(sessionsFile(f.dataRoot), "utf8");
|
||||
|
||||
const s = await setup(t, undefined, given, { launches: false });
|
||||
assert.ok(s.logs.includes("run run-left (chief) from an earlier host: left as it is, this host was started with --no-launch"), s.logs.join("\n"));
|
||||
assert.equal(startTimeOf(pid), startTime, "listen() sent no signal");
|
||||
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
|
||||
assert.deepEqual((await s.client.call("trail", { subject: "run-left" })).filter((r) => r.kind === "session.ended"), []);
|
||||
assert.equal(await s.close(), 0);
|
||||
assert.equal(startTimeOf(pid), startTime, "close() sent no signal either");
|
||||
assert.equal(startTimeOf(runnerPid), runnerStartTime);
|
||||
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
|
||||
});
|
||||
|
||||
test("bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned", { skip, timeout: 30000 }, async (t) => {
|
||||
const s = await setup(t, undefined, undefined, { launches: false });
|
||||
await assert.rejects(s.launcher.launchPm(), (e) => e.exitCode === 3 && /^PM launch refused: launches-disabled/.test(e.message));
|
||||
const sock = launchSocketPath(s.f.dataRoot);
|
||||
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: s.cto.cap, instance: "coder" })}\n`), { ok: false, error: "launches-disabled" });
|
||||
assert.deepEqual(log(s.f).map((e) => [e.event, e.instance, e.code]), [["refused", "chief", "launches-disabled"], ["refused", "coder", "launches-disabled"]]);
|
||||
assert.deepEqual(readSessions(s.f.dataRoot), []);
|
||||
const runs = runsDir(s.f.dataRoot, "acme");
|
||||
assert.deepEqual(existsSync(runs) ? readdirSync(runs) : [], [], "no run directory");
|
||||
assert.equal(await s.close(), 0);
|
||||
});
|
||||
|
||||
test("bus start --no-launch starts no thread tick; the same host with launches runs it", { skip, timeout: 30000 }, async (t) => {
|
||||
for (const launches of [false, true]) {
|
||||
const asked = [];
|
||||
const wrap = (host) => Object.create(host, { op: { value: async (m) => (["threadsState", "landHolders"].includes(m.op) && asked.push(m.op), host.op(m)) } });
|
||||
const s = await setup(t, undefined, undefined, { launches, threadTickMs: 50, wrap });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
assert.equal(asked.length > 0, launches, `launches ${launches}: ${asked.join(", ")}`);
|
||||
assert.equal(await s.close(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
const file = sessionsFile(given.f.dataRoot);
|
||||
|
||||
@@ -381,7 +381,7 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
|
||||
const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
|
||||
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
|
||||
const planNoBackup='backup: none; the run rolls back before the backup';
|
||||
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
|
||||
// --- The T table ---
|
||||
|
||||
@@ -1030,17 +1030,17 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s
|
||||
return r.out.split('\n');
|
||||
};
|
||||
let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan');
|
||||
assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
|
||||
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
|
||||
biz(e,HARNESS);
|
||||
out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan');
|
||||
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
|
||||
assert.deepEqual(out.slice(-8),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
|
||||
out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan');
|
||||
assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm');
|
||||
biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0);
|
||||
out=await quiet(3,'activate',C,'acme','--plan');
|
||||
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
|
||||
assert.deepEqual(out.slice(-8),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
|
||||
out=await quiet(0,'rollback','acme','--plan');
|
||||
assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
|
||||
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
|
||||
});
|
||||
|
||||
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
|
||||
@@ -1705,3 +1705,489 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
|
||||
await noLeak(e);
|
||||
});
|
||||
});
|
||||
|
||||
// Row 189 (#1687, mos-0972/0973): activate --no-send drops the pointer's --send; nothing turns it back on.
|
||||
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
|
||||
const noSend=(c)=>c.filter((x)=>x!=='--send');
|
||||
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
||||
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
|
||||
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
|
||||
assert.deepEqual(want.slice(-2),['--tester-port','7333']);
|
||||
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
|
||||
let r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
||||
const act=pointer(e).activation,l=logLines(e).at(-1);
|
||||
assert.deepEqual([l.op,l.from,l.to,l.result,l.backup],['activate',B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
|
||||
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 2: a plain live-copy activate still refuses already-active, and so does --no-send once --send is gone',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
|
||||
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
|
||||
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
|
||||
const r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
await refused(e,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
||||
const g=await mk(t);await seedPreview(g);
|
||||
await refused(g,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('test 3: --plan prints one send: line in each case, with the live-copy note on a same-copy run',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
||||
let out=await planOnly(e,0,'activate',B,'acme','--no-send');
|
||||
assert.deepEqual(sendLines(out),['send: dropped; --no-send was given']);
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
|
||||
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
|
||||
out=await planOnly(e,0,'activate',C1,'acme');
|
||||
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
|
||||
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
|
||||
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme','--no-send')),['send: off; --no-send was given and the pointer holds no --send']);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme')),['send: off']);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'rollback','acme')),['send: off']);
|
||||
r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(stepText(e),/Z --no-send was given and the pointer holds no --send, so there is nothing to drop\n/);
|
||||
assert.doesNotMatch(stepText(e),/drop --send/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 4: after --no-send, a rollback to the earlier copy leaves --send out of the pointer and the unit',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
||||
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
assert.doesNotMatch(stepText(e),/--send/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 4: pm-on after --no-send keeps --send off',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,{pm:true},['--send']);
|
||||
let r=await prun(e,'activate',C1,'acme','--pm-after-harness','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
biz(e,HARNESS);
|
||||
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 5: --no-send on rollback, pm-on, status, adopt or rehearse is a usage error',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
||||
for(const args of [['rollback','acme','--no-send'],['rollback','acme','--plan','--no-send'],['pm-on','acme','--no-send'],['status','acme','--no-send'],['adopt','acme','--no-send'],
|
||||
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
|
||||
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
|
||||
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
|
||||
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
|
||||
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
|
||||
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
|
||||
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
|
||||
const steps=stepText(h);
|
||||
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
|
||||
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
for(const x of [e,h])await noLeak(x);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
// migrate-schema.mjs. A rehearsal starts its processes with only HOME, PATH and MOSAIC_CONFIG, so
|
||||
// the stand-ins take their faults from $HOME/faults and record what they did in $HOME.
|
||||
|
||||
// The bus host. bus start writes the store's writer.lock, a meta row in the store, and its state
|
||||
// beside the scratch store, and prints the host-up line; without --no-launch it starts the PM, which here means a line in
|
||||
// $HOME/starts.log and a sessions.json. bus status --json answers from that state.
|
||||
function rHost(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
|
||||
const data=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot,state=path.join(data,'fixture-host.json');
|
||||
const flags=["--pm","--no-launch"];
|
||||
if(a.join(' ')==='bus status --json'){
|
||||
if(fault(`status-${id}`))process.exit(1);
|
||||
let s={host:null,boot:null};try{s=JSON.parse(fs.readFileSync(state,'utf8'));s.host.live=alive(s.host.pid)}catch{}
|
||||
if(fault('status-other-host')&&s.host)s.host.pid=process.ppid;
|
||||
console.log(JSON.stringify(s));process.exit(0);
|
||||
}
|
||||
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
|
||||
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
|
||||
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
|
||||
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
|
||||
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
|
||||
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
|
||||
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('fixture_host_start',?)").run(at);db.close();
|
||||
fs.writeFileSync(state,JSON.stringify({host:{business:b,pid:process.pid,live:true,startedAt:at,notifier:'off'},boot:{ok:true,business:b,at}}));
|
||||
if(fault('host-launches-dir'))fs.mkdirSync(path.join(data,'launches'));
|
||||
// host-touches lists files a careless host would write: the live config, business file or backup.
|
||||
if(fault('host-touches'))for(const f of fs.readFileSync(path.join(home,'faults','host-touches'),'utf8').split('\n').filter(Boolean))fs.appendFileSync(f,'x');
|
||||
if(!off||fault('host-launches')){
|
||||
fs.appendFileSync(path.join(home,'starts.log'),`pm-${b}\n`);
|
||||
fs.mkdirSync(path.join(data,'bus-host'),{recursive:true});
|
||||
fs.writeFileSync(path.join(data,'bus-host','sessions.json'),JSON.stringify({sessionsVersion:1,sessions:[{pid:process.pid}]}));
|
||||
}
|
||||
console.log(`bus host up: business ${b}, socket ${path.join(data,'bus','broker.sock')}, notifier off${off?', launches disabled':''}`);
|
||||
process.on('SIGTERM',()=>{if(fault('host-ignores-term'))return;if(!fault('host-keeps-lock'))fs.rmSync(lock,{force:true});process.exit(0)});
|
||||
setInterval(()=>{},1<<30);
|
||||
}
|
||||
|
||||
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
|
||||
function rConsole(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
|
||||
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
|
||||
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
|
||||
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
|
||||
process.on('SIGTERM',()=>process.exit(0));
|
||||
}
|
||||
|
||||
// migrate-schema.mjs: sets its copy's SCHEMA_VERSION, or exit 1 under migrate-fails.
|
||||
function rMigrate(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
|
||||
const home=process.env.HOME,data=process.argv[2];
|
||||
const v=/^export const SCHEMA_VERSION = '([A-Za-z0-9]+)';$/m.exec(fs.readFileSync(path.join(import.meta.dirname,'store.mjs'),'utf8'))[1];
|
||||
fs.appendFileSync(path.join(home,'migrate.log'),`${data}\n`);
|
||||
if(fs.existsSync(path.join(home,'faults','migrate-fails'))){console.error('fixture migrate: failed');process.exit(1)}
|
||||
const db=new DatabaseSync(path.join(data,'bus','bus.sqlite'));db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(v);db.close();
|
||||
console.log(`bus schema ${v} ready`);
|
||||
}
|
||||
|
||||
// The copy's Store: refuses a store at another version with schema-mismatch, as the real one does.
|
||||
const rStore=(v,froms)=>`${storeText(v,froms)}import {DatabaseSync} from 'node:sqlite';
|
||||
import {join} from 'node:path';
|
||||
export class Store {
|
||||
constructor(root) {
|
||||
this.db = new DatabaseSync(join(root, 'bus', 'bus.sqlite'));
|
||||
if (this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value !== SCHEMA_VERSION) { this.db.close(); throw Error('schema-mismatch'); }
|
||||
}
|
||||
get(sql, ...args) { return this.db.prepare(sql).get(...args); }
|
||||
close() { this.db.close(); }
|
||||
}
|
||||
`;
|
||||
|
||||
// B (3g) and C (3h) have --no-launch. A is a copy from before row 163, whose host has no such flag.
|
||||
// C1's scripts/mosaic runs B's host, as a copy-check failure needs.
|
||||
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
|
||||
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
|
||||
const d=join(RCOPIES,id);
|
||||
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
||||
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
|
||||
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
|
||||
const TESTER='7499';
|
||||
const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
const rh=(e,...p)=>join(e.env.HOME,...p);
|
||||
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')};
|
||||
|
||||
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
|
||||
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
|
||||
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
|
||||
async function rmk(t,{copy=B,store='3g'}={}){
|
||||
const e=await mk(t);
|
||||
t.after(()=>{
|
||||
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
|
||||
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
|
||||
});
|
||||
const db=new DatabaseSync(e.store);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
|
||||
db.prepare("INSERT INTO meta VALUES ('schema_version', ?)").run(store);
|
||||
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
|
||||
db.close();
|
||||
mkdirSync(e.state,{recursive:true});
|
||||
writeFileSync(e.pointer,JSON.stringify({copy,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme','--tester-port',TESTER],schema:store,activation:'manual',at:'2026-10-20T00:00:00Z'})+'\n');
|
||||
e.act=`20261020T000000Z-${copy.slice(0,12)}`;e.backup=join(e.data,'backups','stack',e.act);
|
||||
mkdirSync(join(e.backup,'bus'),{recursive:true,mode:0o700});
|
||||
cpSync(e.store,join(e.backup,'bus','bus.sqlite'));
|
||||
writeFileSync(join(e.backup,'SHA256SUMS'),`${sha(e.store)} bus.sqlite\n`);
|
||||
for(const [f,text] of [['DATA_ROOT',e.data],['SCHEMA',store],['STAMP','2026-10-20T00:00:00.000Z']])writeFileSync(join(e.backup,f),`${text}\n`);
|
||||
e.biz=join(dirname(e.cfg),'businesses','acme.json');
|
||||
return e;
|
||||
}
|
||||
|
||||
// What a rehearsal may never change: the live store, config, business file, pointer and log, and
|
||||
// the backup, by sha256 of every file.
|
||||
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
|
||||
|
||||
async function rehearse(e,id,...args){
|
||||
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
|
||||
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
|
||||
e.during?.(ch);
|
||||
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
|
||||
const [code]=await once(ch,'close');clearTimeout(timer);
|
||||
const m=/\nrehearsal directory: (.*)\n$/.exec(out);
|
||||
return {code,out,err,dir:m?m[1]:null,names:out.split('\n').filter((l)=>/^(PASS|FAIL) /.test(l)).map((l)=>l.replace(/^(PASS|FAIL) ([^:]*).*/,'$1 $2'))};
|
||||
}
|
||||
// A script of its own whose text has `from` replaced, for the guard tests. The replacement is a
|
||||
// function so a shell "$$" in `to` stays as written.
|
||||
function ownScript(e,from,to){
|
||||
const own=join(e.root,'repo');cpSync(repo,own,{recursive:true});
|
||||
e.sr=join(own,'scripts','stack-release.sh');
|
||||
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
|
||||
}
|
||||
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
|
||||
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
|
||||
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
|
||||
|
||||
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS migration','PASS gate','PASS no-session',...POST]);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_PASS\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(r.err,'');
|
||||
assert.equal(untouched(e),before);
|
||||
const R=r.dir;assert.equal(dirname(R),join(e.data,'rehearsals'));assert.match(basename(R),new RegExp(`^\\d{8}T\\d{6}Z-${B.slice(0,12)}$`));
|
||||
for(const d of [dirname(R),R,join(R,'placeholders'),join(R,'config')])assert.equal(statSync(d).mode&0o777,0o700,d);
|
||||
for(const f of ['steps.log','backup.before','live.before','business.diff','host.log','console.log','config/config.json','data/bus/bus.sqlite'])assert.ok(existsSync(join(R,f)),f);
|
||||
// The host wrote the scratch store, a copy: the backup has no such row.
|
||||
const metaOf=(f)=>{const db=new DatabaseSync(f,{readOnly:true});try{return db.prepare("SELECT value FROM meta WHERE key='fixture_host_start'").get()?.value}finally{db.close()}};
|
||||
assert.match(metaOf(join(R,'data','bus','bus.sqlite')),/^\d{4}-/);assert.equal(metaOf(join(e.backup,'bus','bus.sqlite')),undefined);
|
||||
// The scratch config differs from the live one only in dataRoot.
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(R,'config','config.json'),'utf8')),{...JSON.parse(readFileSync(e.cfg,'utf8')),dataRoot:join(R,'data')});
|
||||
// The notifier config: binding null at 0600.
|
||||
const nf=join(R,'data','notify','acme','notify.json');
|
||||
assert.deepEqual(JSON.parse(readFileSync(nf,'utf8')),{notifyVersion:1,binding:null});assert.equal(statSync(nf).mode&0o777,0o600);
|
||||
// The scratch business: no tracker.* variable, and every reference a 0600 placeholder with its date kept.
|
||||
const live=JSON.parse(readFileSync(e.biz,'utf8')),sb=JSON.parse(readFileSync(join(R,'config','businesses','acme.json'),'utf8'));
|
||||
assert.ok(Object.keys(live.vars).some((k)=>k.startsWith('tracker.')));
|
||||
assert.deepEqual(Object.keys(sb.vars).filter((k)=>k.startsWith('tracker.')),[]);
|
||||
const refs=[...Object.entries(sb.roles).flatMap(([n,r])=>Object.entries(r.credentials??{}).map(([s,ref])=>[`${n}-${s}`,ref,live.roles[n].credentials[s]])),...Object.entries(sb.tracker.sync.credentials).map(([s,ref])=>[`@sync-${s}`,ref,live.tracker.sync.credentials[s]])];
|
||||
assert.ok(refs.length>=2);
|
||||
for(const [name,ref,old] of refs){
|
||||
const f=join(R,'placeholders',name);
|
||||
assert.deepEqual(ref,{...old,file:f});assert.equal(readFileSync(f,'utf8'),'rehearsal-placeholder-token');assert.equal(statSync(f).mode&0o777,0o600);
|
||||
}
|
||||
assert.deepEqual(readdirSync(join(R,'placeholders')).sort(),refs.map(([n])=>n).sort());
|
||||
// Everything else in the business is as it was.
|
||||
const strip=(b)=>{const c=structuredClone(b);for(const k of Object.keys(c.vars))if(k.startsWith('tracker.'))delete c.vars[k];for(const r of Object.values(c.roles))delete r.credentials;delete c.tracker.sync.credentials;return c};
|
||||
assert.deepEqual(strip(sb),strip(live));
|
||||
const diff=readFileSync(join(R,'business.diff'),'utf8');assert.match(diff,/^-.*"tracker\.baseUrl"/m);assert.match(diff,/^\+.*placeholders\/pm-/m);
|
||||
// The host ran with launches disabled and started no PM; the Console got no --send.
|
||||
assert.deepEqual(lines(rh(e,'host-args.log')),['bus start acme --no-launch']);
|
||||
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme`]);
|
||||
assert.equal(existsSync(rh(e,'starts.log')),false);
|
||||
assert.equal(existsSync(join(R,'data','bus','writer.lock')),false);
|
||||
assert.deepEqual(lines(join(R,'steps.log')).map((l)=>l.replace(/^\S+ /,'')),r.out.split('\n').filter((l)=>/^(PASS|FAIL) |^REHEARSAL_/.test(l)));
|
||||
});
|
||||
|
||||
test('R2 rehearse runs the migration checks when the schema differs; the live store stays at 3g',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const r=await rehearse(e,C,'--backup',e.backup,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','PASS migrate','PASS new-opens-migrated','PASS migrate-again','PASS old-refuses-migrated','PASS gate','PASS no-session',...POST]);
|
||||
assert.ok(r.out.includes('PASS migrate: 3g to 3h\n'),r.out);
|
||||
assert.equal(untouched(e),before);assert.equal(storeVersion(e.store,false),'3g');
|
||||
assert.equal(storeVersion(join(r.dir,'data','bus','bus.sqlite'),false),'3h');
|
||||
assert.equal(storeVersion(join(r.dir,'refuse','bus','bus.sqlite'),false),'3g');
|
||||
assert.deepEqual(lines(rh(e,'migrate.log')),[join(r.dir,'data'),join(r.dir,'data')]);
|
||||
});
|
||||
|
||||
test('R3 a failing migration gives REHEARSAL_FAIL migrate and starts nothing',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e);rfault(e,'migrate-fails');
|
||||
const r=await rehearse(e,C,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','FAIL migrate','PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.ok(r.out.includes('FAIL migrate: migrate-schema.mjs exit 1: fixture migrate: failed\n'),r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name',async(t)=>{
|
||||
for(const [f,name,why] of [[`host-fails-${B}`,'host-up','exited'],[`status-${B}`,'bus-status','bus status failed'],['status-other-host','bus-status','the host this run started'],['host-launches','no-session','bus-host/sessions.json'],['host-launches-dir','no-session','found launches under'],['host-keeps-lock','host-released-store','writer.lock is still there']]){
|
||||
const e=await rmk(t),before=untouched(e);rfault(e,f);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.ok(r.names.includes(`FAIL ${name}`),f+r.out);assert.equal(r.names.filter((n)=>n.startsWith('FAIL')).length,1,f+r.out);
|
||||
assert.ok(r.out.split('\n').some((l)=>l.startsWith(`FAIL ${name}: `)&&l.includes(why)),f+r.out);
|
||||
assert.ok(r.names.includes('PASS console-stopped')&&r.names.includes('PASS backup-unchanged'),f+r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(untouched(e),before);
|
||||
}
|
||||
});
|
||||
|
||||
test('R5 a host that ignores SIGTERM gets SIGKILL and FAIL host-stopped',async(t)=>{
|
||||
const e=await rmk(t);rfault(e,'host-ignores-term');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','2');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(/^FAIL host-stopped: pid \d+ was still running 2s after SIGTERM; sent SIGKILL$/m.test(r.out),r.out);
|
||||
});
|
||||
|
||||
test('R6 a Console that does not answer /healthz gives REHEARSAL_FAIL healthz',async(t)=>{
|
||||
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL healthz: no answer within 3s\n'),r.out);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),['FAIL healthz']);
|
||||
});
|
||||
|
||||
test('R7 a host on another copy fails copy-match',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
const r=await rehearse(e,C1,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL copy-match: the host's command line is not ${join(RCOPIES,C1)}'s cli.mjs bus start acme --no-launch\n`),r.out);
|
||||
});
|
||||
|
||||
test('R8 a scratch port in use or reserved refuses, and the rehearsal takes no other port',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e);
|
||||
const held=createServer();held.listen(0,'127.0.0.1');await once(held,'listening');t.after(()=>held.close());
|
||||
const p=String(held.address().port);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',p);
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.equal(r.out,`PASS copy: ${B}, schema 3g\nFAIL port-in-use: 127.0.0.1:${p} is in use; the rehearsal takes no other port\nREHEARSAL_FAIL\n`);
|
||||
for(const q of ['7332','7333',e.port,TESTER]){
|
||||
const s=await rehearse(e,B,'--backup',e.act,'--port',q);
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.equal(s.out,`PASS copy: ${B}, schema 3g\nFAIL port-reserved: --port ${q} is a live Console port (7332, 7333 and the pointer's: ${e.port} ${TESTER})\nREHEARSAL_FAIL\n`);
|
||||
}
|
||||
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
// The live pm gitea reference names a file that is there, as a live token would be.
|
||||
const tok=join(e.root,'live-gitea.token');writeFileSync(tok,'live-token-value',{mode:0o600});
|
||||
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.roles.pm.credentials.gitea.file=tok;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
||||
const before=untouched(e);
|
||||
ownScript(e,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL credential-ref-live: pm gitea is ${tok}, not a file in ${join(r.dir,'placeholders')}\n`),r.out);
|
||||
assert.deepEqual(r.names.slice(-3),['FAIL credential-ref-live','PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
// An env reference names no file at all; the guard says which variable it is.
|
||||
const f=await rmk(t);
|
||||
const fb=JSON.parse(readFileSync(f.biz,'utf8'));const {file,...rest}=fb.roles.pm.credentials.gitea;fb.roles.pm.credentials.gitea={...rest,env:'REHEARSAL_GITEA_TOKEN'};writeFileSync(f.biz,JSON.stringify(fb,null,2)+'\n',{mode:0o600});
|
||||
ownScript(f,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
||||
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.ok(s.out.includes('FAIL credential-ref-live: pm gitea is env REHEARSAL_GITEA_TOKEN\n'),s.out);
|
||||
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R10 mos-0962: a scratch business that still sets a tracker fails tracker-configured',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
const proj=join(e.root,'project');mkdirSync(join(proj,'.mosaic'),{recursive:true});
|
||||
writeFileSync(join(proj,'.mosaic','project.json'),JSON.stringify({projectVersion:1,id:'stack',vars:{'tracker.project':7}}),{mode:0o600});
|
||||
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.projects.stack.root=proj;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
||||
ownScript(e,'if(k.startsWith("tracker."))delete vars[k]','if(k.startsWith("tracker-kept."))delete vars[k]');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL tracker-configured: bootConfig sets trackers for acme\n'),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name',async(t)=>{
|
||||
// mosaic-config already refuses the live path itself, which contains the scratch config directory;
|
||||
// the guard catches a path that reaches the live data root through a link.
|
||||
const e=await rmk(t),link=join(e.root,'live-link');symlinkSync(e.data,link);
|
||||
ownScript(e,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(link)};`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL data-root-live: the scratch config names dataRoot ${link}, which resolves to the live data root ${e.data}\n`),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
// A root that is neither live nor this rehearsal's, and one that is not there.
|
||||
for(const [dir,made,msg] of [['other-root',true,(d,R)=>`the scratch config resolves to ${d}, not ${join(R,'data')}`],['no-root',false,(d)=>`${d} does not exist`]]){
|
||||
const g=await rmk(t),d=join(g.root,dir);if(made)mkdirSync(d);
|
||||
ownScript(g,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(d)};`);
|
||||
const q=await rehearse(g,B,'--backup',g.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(q.code,1,dir+q.out+q.err);
|
||||
assert.ok(q.out.includes(`FAIL data-root-scratch: ${msg(d,q.dir)}\n`),dir+q.out);
|
||||
assert.equal(existsSync(rh(g,'host-args.log')),false);
|
||||
}
|
||||
const f=await rmk(t);
|
||||
ownScript(f,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json"`,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json" && mkdir "$R/data/bus-host" && printf '{"sessionsVersion":1,"sessions":[{"pid":%s}]}\\n' "$$" > "$R/data/bus-host/sessions.json"`);
|
||||
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.ok(/^FAIL sessions-live: \S+\/bus-host\/sessions\.json names running pid\(s\) \d+$/m.test(s.out),s.out);
|
||||
// The file is left as it was: the rehearsal never deletes it and retries.
|
||||
assert.ok(existsSync(join(s.dir,'data','bus-host','sessions.json')));
|
||||
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R12 mos-0962: a notifier config with a binding fails notify-off',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
ownScript(e,`printf '{"notifyVersion":1,"binding":null}\\n'`,`printf '{"notifyVersion":1,"binding":"discord"}\\n'`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL notify-off: '),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R13 the pre-checks: an old copy, a backup that fails its sums, a missing backup or pointer, and usage',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const old=await rehearse(e,A,'--backup',e.act,'--port',port);
|
||||
assert.equal(old.code,1);assert.equal(old.out,`FAIL copy-without-no-launch: ${A}'s bus host has no --no-launch flag\nREHEARSAL_FAIL\n`);
|
||||
const none=await rehearse(e,B,'--backup','20261020T000000Z-000000000000','--port',port);
|
||||
assert.equal(none.code,1);assert.ok(none.out.includes(`FAIL backup-missing: ${join(e.data,'backups','stack','20261020T000000Z-000000000000')} has no bus/bus.sqlite and SHA256SUMS\n`),none.out);
|
||||
const f=await rmk(t);appendFileSync(join(f.backup,'bus','bus.sqlite'),'x');
|
||||
const bad=await rehearse(f,B,'--backup',f.act,'--port',port);
|
||||
assert.equal(bad.code,1);assert.ok(bad.out.includes(`FAIL backup-sums: ${f.backup}/bus does not match its SHA256SUMS\n`),bad.out);
|
||||
const g=await rmk(t);rmSync(g.pointer);
|
||||
const np=await rehearse(g,B,'--backup',g.act,'--port',port);
|
||||
assert.equal(np.code,1);assert.match(np.out,/^FAIL no-pointer: /);
|
||||
for(const args of [['--port',port],['--backup',e.act],['--backup',e.act,'--port','80'],['--backup',e.act,'--port','70000'],['--backup','relative/dir','--port',port],['--backup',e.act,'--port',port,'--pm'],['--backup',e.act,'--port',port,'--plan']]){
|
||||
const u=await rehearse(e,B,...args);assert.equal(u.code,4,args.join(' ')+u.out+u.err);assert.match(u.err,/^usage: /);
|
||||
}
|
||||
assert.deepEqual([...rDirs(e),...rDirs(f),...rDirs(g)],[]);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R14 a host that writes the live config, the live business file or the backup fails the end check by name',async(t)=>{
|
||||
for(const [pick,name] of [[(e)=>e.cfg,'live-config-unchanged'],[(e)=>e.biz,'live-config-unchanged'],[(e)=>join(e.backup,'bus','bus.sqlite'),'backup-unchanged']]){
|
||||
const e=await rmk(t),f=pick(e);rfault(e,'host-touches');writeFileSync(rh(e,'faults','host-touches'),`${f}\n`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${name}`],f+r.out);
|
||||
assert.ok(r.names.includes('PASS gate')&&r.names.includes('PASS host-stopped'),f+r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
}
|
||||
});
|
||||
|
||||
test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks',async(t)=>{
|
||||
for(const sig of ['SIGTERM','SIGINT','SIGHUP']){
|
||||
// The Console never answers /healthz, so the gate is still waiting when the signal comes.
|
||||
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
||||
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill(sig)};
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(r.code,1,sig+r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS migration','FAIL signal','PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'],sig+r.out);
|
||||
assert.ok(r.out.includes('FAIL signal: the rehearsal was interrupted\n'),r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
const pid=JSON.parse(readFileSync(join(r.dir,'data','fixture-host.json'),'utf8')).host.pid;
|
||||
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
|
||||
}
|
||||
});
|
||||
@@ -186,17 +186,22 @@ export function endReason(code, signal) {
|
||||
|
||||
// The host's view of its running sessions, kept in memory and mirrored to
|
||||
// sessions.json for `mosaic stop` and `mosaic launches list`.
|
||||
// readOnly (row 163, a --no-launch host): sessions.json stays as an earlier host left it, so a
|
||||
// later host still finds and ends those runs. add() refuses, and nothing writes the file.
|
||||
export class Registry {
|
||||
#dataRoot;
|
||||
#sessions = [];
|
||||
constructor(dataRoot) {
|
||||
#readOnly;
|
||||
constructor(dataRoot, { readOnly = false } = {}) {
|
||||
this.#dataRoot = dataRoot;
|
||||
this.#readOnly = readOnly;
|
||||
this.#write();
|
||||
}
|
||||
get sessions() {
|
||||
return this.#sessions.map((s) => ({ ...s }));
|
||||
}
|
||||
add(entry) {
|
||||
if (this.#readOnly) throw new SeatError("this host keeps no sessions (--no-launch)", 2);
|
||||
this.#sessions.push({ ...entry });
|
||||
this.#write();
|
||||
}
|
||||
@@ -213,6 +218,7 @@ export class Registry {
|
||||
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread) ?? null;
|
||||
}
|
||||
#write() {
|
||||
if (this.#readOnly) return;
|
||||
const file = sessionsFile(this.#dataRoot);
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
|
||||
@@ -133,6 +133,23 @@ test("Registry mirrors to sessions.json; readSessions marks live entries; bad fi
|
||||
assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /malformed/.test(e.message));
|
||||
});
|
||||
|
||||
// Row 163: a --no-launch host's registry leaves an earlier host's sessions.json as it was.
|
||||
test("a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove()", (t) => {
|
||||
const root = scratch(t);
|
||||
new Registry(root).add({ business: "acme", instance: "chief", run: "r1", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) });
|
||||
const before = readFileSync(sessionsFile(root), "utf8");
|
||||
const r = new Registry(root, { readOnly: true });
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
assert.deepEqual(r.sessions, []);
|
||||
assert.throws(() => r.add({ business: "acme", instance: "coder", run: "r2" }), (e) => e instanceof SeatError && /keeps no sessions/.test(e.message));
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
r.remove("acme", "r1");
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
const empty = scratch(t);
|
||||
new Registry(empty, { readOnly: true });
|
||||
assert.equal(existsSync(sessionsFile(empty)), false);
|
||||
});
|
||||
|
||||
// Row 121 (#1615): a thread instance runs once per thread, so the registry
|
||||
// keys a thread run by its thread too.
|
||||
test("REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread", (t) => {
|
||||
|
||||
+341
-7
@@ -3,7 +3,7 @@
|
||||
# Moves the bus, the Console and the human CLI from the pointer's copy to another copy together,
|
||||
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
|
||||
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
|
||||
# pair, which activate sets or drops (row 187, #1685).
|
||||
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
|
||||
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
|
||||
#
|
||||
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
|
||||
@@ -15,6 +15,8 @@
|
||||
# 4. this script never prunes backups, rollback sets, post-attempt directories or copies.
|
||||
# Decisions 94 and 102 still decide who starts the Console; webui-service.sh keeps having no start
|
||||
# or stop verb. status and --plan only read, and every seat may run them.
|
||||
# rehearse (D2, row 163) stops and starts no unit and writes only its own rehearsal directory, but
|
||||
# it reads the live config and a backup of the live store, so Jason or Mos runs it.
|
||||
#
|
||||
# Most functions below run as `f || handler`, and bash ignores set -e inside such a call, so
|
||||
# every step that can fail carries its own `|| return 1`.
|
||||
@@ -28,16 +30,17 @@ BIN="$HOME/.npm-global/bin"
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
CONSOLE_UNIT=mosaic-webui.service
|
||||
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
usage: stack-release.sh adopt BUSINESS
|
||||
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
|
||||
stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
stack-release.sh status BUSINESS
|
||||
stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
||||
common options: --copies DIR, --bin DIR
|
||||
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||
every table in COUNTED.
|
||||
@@ -82,13 +85,17 @@ parse_opts() {
|
||||
--proof-header) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_HEADER="$2"; shift 2 ;;
|
||||
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
|
||||
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
|
||||
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
|
||||
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
|
||||
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
|
||||
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
|
||||
--port) [ "$OP" = rehearse ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$2))" -ge 1024 ] && [ "$((10#$2))" -le 65535 ] || usage; R_PORT="$((10#$2))"; shift 2 ;;
|
||||
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage
|
||||
[ "$OP" != rehearse ] || { [ -n "$R_BACKUP" ] && [ -n "$R_PORT" ]; } || usage
|
||||
# A bad tester port is a usage error; --plan lists it as tester-port-invalid instead (row 180).
|
||||
[ -z "$TESTER_PORT" ] || [ "$PLAN" = 1 ] || valid_tester_port "$TESTER_PORT" 7332 || usage
|
||||
# --pm-after-harness replaces --pm (row 169 item 1), so the two never go together.
|
||||
@@ -238,14 +245,16 @@ target_schema() {
|
||||
in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; }
|
||||
|
||||
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
|
||||
# sets a proof pair, or drops the pointer's pair with --no-proof, may name the pointer's copy: it
|
||||
# re-pins the live copy with new Console flags (row 187, Part B's B3 and B-R1).
|
||||
# sets a proof pair, drops the pointer's pair with --no-proof, or drops the pointer's --send with
|
||||
# --no-send, may name the pointer's copy: it re-pins the live copy with new Console flags (row 187,
|
||||
# Part B's B3 and B-R1; row 189, mos-0972).
|
||||
check_target() {
|
||||
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
|
||||
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
|
||||
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
|
||||
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
|
||||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
|
||||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
|
||||
refuse already-active "$TO is the pointer's copy"
|
||||
}
|
||||
|
||||
@@ -684,6 +693,33 @@ console_for_target() {
|
||||
DROP_TESTER="--tester-port $inherited"
|
||||
fi
|
||||
proof_for_target
|
||||
send_for_target
|
||||
}
|
||||
|
||||
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
|
||||
# it; without the flag the pointer's --send goes on unchanged. Rollback and pm-on never take it, so
|
||||
# they carry the pointer as it is. Sets SEND_PLAN (the plan's send: line) and SEND_STEP.
|
||||
send_for_target() {
|
||||
local had=0 i
|
||||
local -a without_send=()
|
||||
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
|
||||
if [ "${RUN_CONSOLE[i]}" = --send ]; then had=1; else without_send+=("${RUN_CONSOLE[i]}"); fi
|
||||
done
|
||||
SEND_STEP=""
|
||||
if [ "$NO_SEND" = 1 ]; then
|
||||
RUN_CONSOLE=("${without_send[@]}")
|
||||
if [ "$had" = 1 ]; then
|
||||
SEND_PLAN="send: dropped; --no-send was given"
|
||||
SEND_STEP="drop --send; --no-send was given"
|
||||
else
|
||||
SEND_PLAN="send: off; --no-send was given and the pointer holds no --send"
|
||||
SEND_STEP="--no-send was given and the pointer holds no --send, so there is nothing to drop"
|
||||
fi
|
||||
elif [ "$had" = 1 ]; then
|
||||
SEND_PLAN="send: carried"
|
||||
else
|
||||
SEND_PLAN="send: off"
|
||||
fi
|
||||
}
|
||||
|
||||
# Row 187: the row 178 proof pair in RUN_CONSOLE. A new pair replaces the pointer's and goes last;
|
||||
@@ -937,6 +973,7 @@ plan() {
|
||||
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
|
||||
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
|
||||
echo "$PROOF_PLAN"
|
||||
echo "$SEND_PLAN"
|
||||
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
|
||||
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
|
||||
local r
|
||||
@@ -950,6 +987,7 @@ run_steps() {
|
||||
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
|
||||
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
|
||||
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
|
||||
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
|
||||
trap 'after_fail signal' INT TERM HUP
|
||||
stop_both || rollback_in_run stop-timeout
|
||||
if [ -e "$STORE" ]; then
|
||||
@@ -1155,13 +1193,307 @@ do_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- The rehearsal (D2, row 163) ---
|
||||
|
||||
# rehearse runs the new copy's bus host (--no-launch) and Console on a scratch root built from a
|
||||
# backup, under <dataRoot>/rehearsals/<stamp>/, and gates them as activate does. It never writes the
|
||||
# live store, the live config files, the backup, the pointer, the log or a unit. Nothing prunes
|
||||
# the rehearsal directory. Design, rulings (mos-0961, mos-0962, mos-0963) and the deviations from
|
||||
# the brief: agents/sage/work/queue-163/BUILD.md.
|
||||
|
||||
# One PASS or FAIL line per check, on stdout and in steps.log. Lines from before the directory
|
||||
# exists wait in R_EARLY and go in first.
|
||||
r_line() {
|
||||
echo "$*"
|
||||
if [ -z "$STEPS" ]; then R_EARLY+=("$(now) $*"); else echo "$(now) $*" >> "$STEPS" || true; fi
|
||||
}
|
||||
r_pass() { r_line "PASS $1${2:+: $2}"; }
|
||||
r_fail() { r_line "FAIL $1${2:+: $2}"; R_RC=1; }
|
||||
r_abort() { r_fail "$@"; r_end; }
|
||||
|
||||
r_alive() { local s; s="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1; s="${s##*) }"; [ "${s:0:1}" != Z ]; }
|
||||
r_gone() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
while r_alive "$1"; do [ "$SECONDS" -lt "$deadline" ] || return 1; sleep 1; done
|
||||
}
|
||||
|
||||
# Stops the Console, then the host, with SIGTERM within --timeout; one still there gets SIGKILL
|
||||
# and a FAIL line. The host has to leave the store's writer.lock behind it.
|
||||
r_stop() {
|
||||
local name pid
|
||||
for name in console host; do
|
||||
if [ "$name" = console ]; then pid="$R_CONSOLE_PID"; R_CONSOLE_PID=""; else pid="$R_HOST_PID"; R_HOST_PID=""; fi
|
||||
[ -n "$pid" ] || continue
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
if r_gone "$pid"; then r_pass "$name-stopped" "pid $pid"
|
||||
else kill -KILL "$pid" 2>/dev/null || true; r_fail "$name-stopped" "pid $pid was still running ${TIMEOUT}s after SIGTERM; sent SIGKILL"
|
||||
fi
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
[ -n "$R_STARTED" ] || return 0
|
||||
R_STARTED=""
|
||||
if [ -e "$R/data/bus/writer.lock" ]; then r_fail host-released-store "$R/data/bus/writer.lock is still there"; else r_pass host-released-store; fi
|
||||
}
|
||||
|
||||
r_tree_sums() { (cd "$1" && find . -type f -print0 | sort -z | xargs -0 -r sha256sum); }
|
||||
|
||||
# The last checks, then the last line. The backup and the live config files are compared with the
|
||||
# sums taken before the rehearsal directory was made.
|
||||
r_end() {
|
||||
trap - INT TERM HUP
|
||||
r_stop
|
||||
if [ -n "$R" ]; then
|
||||
if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged; else r_fail backup-unchanged "$R_BK differs from $R/backup.before"; fi
|
||||
if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "$LIVE_CFG or $LIVE_BIZ differs from $R/live.before"; fi
|
||||
fi
|
||||
if [ "$R_RC" = 0 ]; then r_line REHEARSAL_PASS; else r_line REHEARSAL_FAIL; fi
|
||||
[ -z "$R" ] || echo "rehearsal directory: $R"
|
||||
exit "$R_RC"
|
||||
}
|
||||
|
||||
r_port_free() {
|
||||
node -e 'const s=require("node:net").createServer();s.once("error",()=>process.exit(1));s.listen(Number(process.argv[1]),"127.0.0.1",()=>s.close(()=>process.exit(0)));' -- "$1"
|
||||
}
|
||||
|
||||
# The scratch config: the live one with dataRoot set to the scratch data root.
|
||||
R_CONFIG_JS='
|
||||
const fs=require("node:fs");const [file,data]=process.argv.slice(1);
|
||||
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;process.stdout.write(JSON.stringify(c,null,2)+"\n");'
|
||||
|
||||
# The scratch business file (mos-0962, mos-0963): the live file without its tracker.* variables,
|
||||
# business and role, and with every credential reference, the roles and tracker.sync, moved to its
|
||||
# own 0600 placeholder file. The service and its rotateBy or expires date stay. The tracker key
|
||||
# stays because the loader requires it. Mode `normalize` prints the live file as this writes it,
|
||||
# for the diff.
|
||||
R_BUSINESS_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const [mode,live,out,ph]=process.argv.slice(1);
|
||||
const b=JSON.parse(fs.readFileSync(live,"utf8"));
|
||||
if(mode==="normalize"){process.stdout.write(JSON.stringify(b,null,2)+"\n");process.exit(0)}
|
||||
const DATE={gitea:"rotateBy",vikunja:"expires"};
|
||||
const strip=(vars)=>{if(vars&&typeof vars==="object")for(const k of Object.keys(vars))if(k.startsWith("tracker."))delete vars[k]};
|
||||
const swap=(name,refs)=>{
|
||||
if(!refs||typeof refs!=="object")return;
|
||||
for(const s of Object.keys(refs)){
|
||||
const file=path.join(ph,`${name}-${s}`),old=refs[s],ref={file};
|
||||
fs.writeFileSync(file,"rehearsal-placeholder-token",{mode:0o600,flag:"wx"});
|
||||
if(old&&typeof old==="object"&&Object.hasOwn(DATE,s)&&old[DATE[s]]!==undefined)ref[DATE[s]]=old[DATE[s]];
|
||||
refs[s]=ref;
|
||||
}
|
||||
};
|
||||
strip(b.vars);
|
||||
for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}
|
||||
swap("@sync",b.tracker?.sync?.credentials);
|
||||
fs.writeFileSync(out,JSON.stringify(b,null,2)+"\n",{mode:0o600,flag:"wx"});'
|
||||
|
||||
# The guard, before the host starts. It reads the scratch config through the new copy's own
|
||||
# loadSystem and bootConfig, as the host will, and prints one line per check:
|
||||
# data-root-live the data root is not the live one (mos-0961)
|
||||
# sessions-live no sessions.json there names a running process (mos-0961)
|
||||
# data-root-scratch the data root is the scratch one
|
||||
# notify-off the notifier config is {"notifyVersion":1,"binding":null}, 0600 (mos-0962)
|
||||
# tracker-configured bootConfig sets no trackers (mos-0962)
|
||||
# credential-ref-live every credential reference is a file under placeholders/ (mos-0963)
|
||||
# Exit 0 when all pass, 1 on the first FAIL.
|
||||
R_GUARD_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const {pathToFileURL}=require("node:url");
|
||||
const [copy,id,live,scratch,ph]=process.argv.slice(1);
|
||||
const real=(p)=>{try{return fs.realpathSync(p)}catch{return null}};
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,"utf8");return s[s.lastIndexOf(")")+2]!=="Z"}catch{return false}};
|
||||
const pass=(n,w)=>console.log(`PASS ${n}${w?": "+w:""}`);
|
||||
const fail=(n,w)=>{console.log(`FAIL ${n}: ${w}`);process.exit(1)};
|
||||
(async()=>{
|
||||
let c,system,config;
|
||||
try{c=await import(pathToFileURL(`${copy}/packages/cli/src/config.mjs`).href);system=c.loadSystem({env:process.env})}
|
||||
catch(e){fail("scratch-config",String(e?.message??e).split("\n")[0])}
|
||||
const root=real(system.dataRoot);
|
||||
if(root===null)fail("data-root-scratch",`${system.dataRoot} does not exist`);
|
||||
if(root===real(live))fail("data-root-live",`the scratch config names dataRoot ${system.dataRoot}, which resolves to the live data root ${live}`);
|
||||
pass("data-root-live",`${system.dataRoot} is not the live data root`);
|
||||
const sf=path.join(root,"bus-host","sessions.json");
|
||||
if(fs.existsSync(sf)){
|
||||
let doc;try{doc=JSON.parse(fs.readFileSync(sf,"utf8"))}catch{fail("sessions-live",`${sf} is unreadable`)}
|
||||
if(!Array.isArray(doc?.sessions))fail("sessions-live",`${sf} is malformed`);
|
||||
const running=doc.sessions.flatMap((s)=>[s?.pid,s?.runnerPid]).filter((p)=>Number.isSafeInteger(p)&&p>0&&alive(p));
|
||||
if(running.length)fail("sessions-live",`${sf} names running pid(s) ${running.join(", ")}`);
|
||||
}
|
||||
pass("sessions-live","no running session named in the data root");
|
||||
if(root!==real(scratch))fail("data-root-scratch",`the scratch config resolves to ${system.dataRoot}, not ${scratch}`);
|
||||
pass("data-root-scratch");
|
||||
const nf=path.join(root,"notify",id,"notify.json");
|
||||
let st,doc;try{st=fs.lstatSync(nf);doc=JSON.parse(fs.readFileSync(nf,"utf8"))}catch{fail("notify-off",`${nf} is missing or not JSON`)}
|
||||
if(!st.isFile()||(st.mode&0o777)!==0o600||JSON.stringify(doc)!==JSON.stringify({notifyVersion:1,binding:null}))fail("notify-off",`${nf} is not {"notifyVersion":1,"binding":null} at mode 0600`);
|
||||
pass("notify-off","binding null, so no Discord bridge");
|
||||
try{config=c.bootConfig({system,businessId:id,env:process.env})}catch(e){fail("boot-config",String(e?.message??e).split("\n")[0])}
|
||||
if(config.trackers!==undefined)fail("tracker-configured",`bootConfig sets trackers for ${Object.keys(config.trackers).join(", ")}`);
|
||||
pass("tracker-configured","bootConfig sets no trackers");
|
||||
const b=config.businesses?.[id],phr=real(ph),refs=[];
|
||||
for(const [role,r] of Object.entries(b?.roles??{}))for(const [s,ref] of Object.entries(r?.credentials??{}))refs.push([`${role} ${s}`,ref]);
|
||||
for(const [s,ref] of Object.entries(b?.tracker?.sync?.credentials??{}))refs.push([`@sync ${s}`,ref]);
|
||||
for(const [what,ref] of refs){
|
||||
if(ref?.env!==undefined)fail("credential-ref-live",`${what} is env ${ref.env}`);
|
||||
const f=typeof ref?.file==="string"?real(ref.file):null;
|
||||
if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live",`${what} is ${ref?.file}, not a file in ${ph}`);
|
||||
}
|
||||
pass("credential-ref-live",`${refs.length} reference(s), each a placeholder`);
|
||||
})().catch((e)=>fail("guard",String(e?.message??e).split("\n")[0]));'
|
||||
|
||||
# Opens a data root's store with a copy's Store, as the bus does at start, with no migration
|
||||
# (de352dfaf's verify-open.mjs). Prints OPEN_OK <version> or REFUSED <message>.
|
||||
R_OPEN_JS='
|
||||
const {pathToFileURL}=require("node:url");const [copy,root]=process.argv.slice(1);
|
||||
(async()=>{
|
||||
const {Store}=await import(pathToFileURL(`${copy}/packages/bus/src/store.mjs`).href);
|
||||
let s;
|
||||
try{s=new Store(root);process.stdout.write(`OPEN_OK ${s.get("SELECT value FROM meta WHERE key=?","schema_version").value}\n`)}
|
||||
catch(e){process.stdout.write(`REFUSED ${e.message}\n`)}
|
||||
finally{s?.close()}
|
||||
})();'
|
||||
r_open() { node -e "$R_OPEN_JS" -- "$1" "$2" 2>&1 || true; }
|
||||
|
||||
# Every process this rehearsal starts gets only HOME, PATH and the scratch MOSAIC_CONFIG.
|
||||
r_env() { env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$@"; }
|
||||
|
||||
# The de352dfaf checks, when the backup's schema is not the new copy's.
|
||||
r_migration() {
|
||||
local copy="$COPIES/$TO" old="$COPIES/$P_COPY" v out rc
|
||||
v="$(node -e "$STORE_JS" -- version "$R/data/bus/bus.sqlite" 2>&1)" || r_abort store-unreadable "$v"
|
||||
if [ "$v" = "$T_SCHEMA" ]; then r_pass migration "not needed: the backup is already $v"; return 0; fi
|
||||
in_chain "$v" || r_abort store-newer "the backup is $v, which $TO's migration chain does not start from"
|
||||
(umask 077; mkdir "$R/refuse" "$R/refuse/bus" && cp "$R_BK"/bus/* "$R/refuse/bus/") || r_abort scratch-build "cannot copy the backup to $R/refuse"
|
||||
out="$(r_open "$copy" "$R/refuse")"
|
||||
[ "$out" = "REFUSED schema-mismatch" ] || r_abort new-refuses-old-store "$TO opened the $v store: $out"
|
||||
r_pass new-refuses-old-store "$TO refuses the $v store at a normal open"
|
||||
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
|
||||
printf '%s\n' "$out" >> "$R/migrate.log"
|
||||
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate "migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
|
||||
r_pass migrate "$v to $T_SCHEMA"
|
||||
out="$(r_open "$copy" "$R/data")"
|
||||
[ "$out" = "OPEN_OK $T_SCHEMA" ] || r_abort new-opens-migrated "$out"
|
||||
r_pass new-opens-migrated
|
||||
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
|
||||
printf '%s\n' "$out" >> "$R/migrate.log"
|
||||
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate-again "second migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
|
||||
r_pass migrate-again "a second run is a no-op"
|
||||
out="$(r_open "$old" "$R/data")"
|
||||
[ "$out" = "REFUSED schema-mismatch" ] || r_abort old-refuses-migrated "the pointer's copy $P_COPY opened the $T_SCHEMA store: $out"
|
||||
r_pass old-refuses-migrated "$P_COPY refuses the $T_SCHEMA store"
|
||||
}
|
||||
|
||||
# One pass of D1's gate checks but the PM's, against the processes this run started. Sets R_GATE
|
||||
# to the first failing check and R_WHY to what it saw.
|
||||
r_gate_once() {
|
||||
local copy="$COPIES/$TO" out
|
||||
R_GATE=host-up R_WHY=""
|
||||
r_alive "$R_HOST_PID" || { R_WHY="the host (pid $R_HOST_PID) exited; see $R/host.log"; return 1; }
|
||||
grep -q "^bus host up: business $BUSINESS, .*, launches disabled\$" "$R/host.log" || { R_WHY="no \"bus host up ... launches disabled\" line in $R/host.log"; return 1; }
|
||||
R_GATE=bus-status
|
||||
out="$(r_env "$copy/scripts/mosaic" bus status --json 2>/dev/null)" || { R_WHY="bus status failed"; return 1; }
|
||||
R_WHY="$(node -e '
|
||||
const [b,main,since]=process.argv.slice(2),s=JSON.parse(process.argv[1]);
|
||||
const why=!s.host?"no host":s.host.business!==b?`host for ${s.host.business}`:s.host.live!==true?"host not live":String(s.host.pid)!==main?`host pid ${s.host.pid} is not ${main}, the host this run started`:!s.boot||s.boot.ok!==true?"boot status not ok":s.boot.business!==b?`boot status for ${s.boot.business}`:!(Date.parse(s.boot.at)>=Number(since))?"boot status is from before this start":"";
|
||||
process.stdout.write(why);' -- "$out" "$BUSINESS" "$R_HOST_PID" "$START_MS" 2>&1)" || { R_WHY="bus status unreadable"; return 1; }
|
||||
[ -z "$R_WHY" ] || return 1
|
||||
R_GATE=console-up
|
||||
r_alive "$R_CONSOLE_PID" || { R_WHY="the Console (pid $R_CONSOLE_PID) exited; see $R/console.log"; return 1; }
|
||||
grep -qxF "Mosaic Console: http://127.0.0.1:$R_PORT/" "$R/console.log" || { R_WHY="no \"Mosaic Console: http://127.0.0.1:$R_PORT/\" line in $R/console.log"; return 1; }
|
||||
R_GATE=healthz
|
||||
node -e '
|
||||
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$R_PORT" || return 1
|
||||
R_GATE=copy-match
|
||||
tr '\0' ' ' < "/proc/$R_HOST_PID/cmdline" | grep -qF " $copy/packages/cli/src/cli.mjs bus start $BUSINESS --no-launch" || { R_WHY="the host's command line is not $copy's cli.mjs bus start $BUSINESS --no-launch"; return 1; }
|
||||
tr '\0' ' ' < "/proc/$R_CONSOLE_PID/cmdline" | grep -qF " $copy/packages/webui/src/cli.mjs serve " || { R_WHY="the Console's command line is not $copy's webui cli.mjs serve"; return 1; }
|
||||
R_GATE=""
|
||||
}
|
||||
|
||||
r_gate() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
until r_gate_once; do
|
||||
[ "$SECONDS" -lt "$deadline" ] || { r_fail "$R_GATE" "${R_WHY:-no answer within ${TIMEOUT}s}"; return 1; }
|
||||
sleep 1
|
||||
done
|
||||
r_pass gate "host-up, bus-status, console-up, healthz and copy-match on $TO"
|
||||
}
|
||||
|
||||
# No role session: the host wrote no sessions.json and no launches directory or log.
|
||||
r_no_session() {
|
||||
local found=""
|
||||
[ ! -e "$R/data/bus-host/sessions.json" ] || found+=" bus-host/sessions.json"
|
||||
[ ! -e "$R/data/launches" ] || found+=" launches"
|
||||
if [ -z "$found" ]; then r_pass no-session "no sessions.json and no launches under $R/data"; else r_fail no-session "found$found under $R/data"; fi
|
||||
}
|
||||
|
||||
do_rehearse() {
|
||||
TO="$ID"
|
||||
trap 'r_fail signal "the rehearsal was interrupted"; r_end' INT TERM HUP
|
||||
read_pointer || r_abort no-pointer "no $POINTER; the pointer's copy is the old copy the checks use"
|
||||
[ "$P_BUSINESS" = "$BUSINESS" ] || r_abort business-mismatch "the pointer is for $P_BUSINESS"
|
||||
copy_exists "$TO" 2>/dev/null || r_abort copy-missing "no copy $TO in $COPIES"
|
||||
copy_ok "$TO" 2>/dev/null || r_abort copy-unreachable "$TO is not reachable from refactor and origin/refactor"
|
||||
target_schema "$TO" || r_abort copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
|
||||
# mos-0961: an older copy's host has no --no-launch and would start the PM on its own.
|
||||
grep -qF -- '"--no-launch"' "$COPIES/$TO/packages/cli/src/cli.mjs" 2>/dev/null || r_abort copy-without-no-launch "$TO's bus host has no --no-launch flag"
|
||||
r_pass copy "$TO, schema $T_SCHEMA"
|
||||
local i
|
||||
local -a reserved=(7332 7333 "$(console_port)")
|
||||
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do [ "${P_CONSOLE[i]}" != --tester-port ] || reserved+=("${P_CONSOLE[i + 1]:-}"); done
|
||||
for i in "${reserved[@]}"; do [ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved "--port $R_PORT is a live Console port (7332, 7333 and the pointer's: ${reserved[*]:2})"; done
|
||||
r_port_free "$R_PORT" || r_abort port-in-use "127.0.0.1:$R_PORT is in use; the rehearsal takes no other port"
|
||||
r_pass port "127.0.0.1:$R_PORT is free"
|
||||
case "$R_BACKUP" in /*) R_BK="$R_BACKUP" ;; *) R_BK="$DATA/backups/stack/$R_BACKUP" ;; esac
|
||||
[ -f "$R_BK/bus/bus.sqlite" ] && [ -f "$R_BK/SHA256SUMS" ] || r_abort backup-missing "$R_BK has no bus/bus.sqlite and SHA256SUMS"
|
||||
(cd "$R_BK/bus" && sha256sum -c --quiet ../SHA256SUMS) >/dev/null 2>&1 || r_abort backup-sums "$R_BK/bus does not match its SHA256SUMS"
|
||||
r_pass backup "$R_BK matches its SHA256SUMS"
|
||||
LIVE_CFG="$(realpath -m "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
|
||||
LIVE_BIZ="$(dirname "$LIVE_CFG")/businesses/$BUSINESS.json"
|
||||
[ -f "$LIVE_BIZ" ] || r_abort business-missing "no $LIVE_BIZ"
|
||||
|
||||
local stamp before live
|
||||
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
|
||||
(umask 077; mkdir -p "$DATA/rehearsals" && mkdir "$DATA/rehearsals/$stamp") || r_abort rehearsal-dir "cannot make $DATA/rehearsals/$stamp (one rehearsal per second and copy)"
|
||||
R="$DATA/rehearsals/$stamp" STEPS="$DATA/rehearsals/$stamp/steps.log"
|
||||
printf '%s\n' "${R_EARLY[@]}" > "$STEPS" && printf '%s\n' "$before" > "$R/backup.before" && printf '%s\n' "$live" > "$R/live.before" ||
|
||||
r_abort scratch-build "cannot write the sums to $R"
|
||||
(
|
||||
umask 077
|
||||
mkdir "$R/config" "$R/config/businesses" "$R/placeholders" "$R/data" "$R/data/bus" "$R/data/notify" "$R/data/notify/$BUSINESS" &&
|
||||
cp "$R_BK"/bus/* "$R/data/bus/" &&
|
||||
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" > "$R/config/config.json" &&
|
||||
node -e "$R_BUSINESS_JS" -- write "$LIVE_BIZ" "$R/config/businesses/$BUSINESS.json" "$R/placeholders" &&
|
||||
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json"
|
||||
) 2>> "$STEPS" || r_abort scratch-build "cannot build the scratch root under $R; see $STEPS"
|
||||
local rc=0
|
||||
diff -u --label "$LIVE_BIZ (as JSON.stringify writes it)" --label "$R/config/businesses/$BUSINESS.json" \
|
||||
<(node -e "$R_BUSINESS_JS" -- normalize "$LIVE_BIZ") "$R/config/businesses/$BUSINESS.json" > "$R/business.diff" || rc=$?
|
||||
[ "$rc" -le 1 ] || r_abort scratch-build "cannot write $R/business.diff"
|
||||
r_pass scratch-root "$R/data from the backup, config and business file under $R/config, business.diff beside them"
|
||||
|
||||
local out
|
||||
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" 2>&1)" || rc=$?
|
||||
while IFS= read -r i; do r_line "$i"; done <<< "$out"
|
||||
[ "$rc" = 0 ] || { R_RC=1; r_end; }
|
||||
|
||||
r_migration
|
||||
|
||||
START_MS="$(date +%s%3N)"
|
||||
R_STARTED=1
|
||||
# env, not r_env: a function in the background is a subshell, and $! has to be the process
|
||||
# itself (env execs scripts/mosaic, which execs node), for the bus-status pid check and the stop.
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch > "$R/host.log" 2>&1 &
|
||||
R_HOST_PID=$!
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" > "$R/console.log" 2>&1 &
|
||||
R_CONSOLE_PID=$!
|
||||
if r_gate; then r_no_session; fi
|
||||
r_end
|
||||
}
|
||||
|
||||
# --- Dispatch ---
|
||||
|
||||
[ $# -gt 0 ] || usage
|
||||
OP="$1"; shift
|
||||
ID=""
|
||||
case "$OP" in
|
||||
activate) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
activate|rehearse) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
adopt|pm-on|rollback|status) [ $# -ge 1 ] || usage; BUSINESS="$1"; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) usage ;;
|
||||
@@ -1178,11 +1510,13 @@ STORE="$DATA/bus/bus.sqlite"
|
||||
BUS_UNIT="mosaic-bus@$BUSINESS.service"
|
||||
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
|
||||
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
|
||||
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=()
|
||||
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
|
||||
case "$OP" in
|
||||
adopt) do_adopt ;;
|
||||
activate) do_activate ;;
|
||||
pm-on) do_pm_on ;;
|
||||
rollback) do_rollback ;;
|
||||
status) do_status ;;
|
||||
rehearse) do_rehearse ;;
|
||||
esac
|
||||
Reference in new issue
Block a user