diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 2e862737d..6bce48e33 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -366,7 +366,8 @@ 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] [--migrate] [--timeout S] [--plan] +scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--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 common options: --copies DIR, --bin DIR @@ -380,9 +381,9 @@ running sessions, so run it only when no PM turn is in flight. `--plan` and The script keeps `/state/stack-active.json` (the live copy, business, PM flag, claude pin, Console flags, schema and activation) and appends every run to `/state/stack-activation-log.jsonl`: a `started` line, then -one final line with `result` `ok`, `refused`, `gate-failed` or -`rollback-failed`. A refusal exits 3, a gate failure that rolled back exits 1, -a failed rollback exits 2, usage exits 4. Only one run at a time holds +one final line with `result` `ok`, `refused`, `gate-failed`, +`rollback-failed`, `pm-on-failed` or `pm-on-down`. A refusal exits 3, a gate +failure that rolled back exits 1, a failed rollback exits 2, usage exits 4. Only one run at a time holds `state/stack-release.lock` (`run-locked`). 1. Once per host, `adopt BUSINESS` records the running stack as the pointer: @@ -401,8 +402,9 @@ a failed rollback exits 2, usage exits 4. Only one run at a time holds `activate` refuses `drop-in-override` and names the file. 5. With Mos's go, `activate ID BUSINESS`, plus `--migrate` when the plan says so. Before it stops anything, it refuses a missing, unpushed or - unreadable copy, a busy `mosaic-bus@` instance, and unit flags that differ - from the pointer. Then it saves the unit files, drop-ins and wrapper under + unreadable copy, a busy `mosaic-bus@` instance, unit flags that differ + from the pointer, and a business file that names a plug-in kind the + target's `KINDS` lacks (`unknown-kind-for-target`, naming each kind). Then it saves the unit files, drop-ins and wrapper under `state/stack-rollback//`, stops the Console then the bus, and reads the stopped store. A `-wal` or `-shm` beside it refuses `store-unclean` and leaves the store alone. A migration takes an @@ -420,10 +422,42 @@ a failed rollback exits 2, usage exits 4. Only one run at a time holds 7. `rollback BUSINESS` returns to the copy before the pointer's, taken from the log. When the schemas differ it needs `--restore ACTIVATION`; events written after that backup need `--events N` with the count it prints, and - Jason decides first. + Jason decides first. It refuses `unknown-kind-for-target` like `activate`; + set each kind it names to `null` with `plugins set` through the live bus + first. 8. Jason's plain `mosaic talk pm "..."` must print sent and a reply. Keep the `status` output with the receipts. +#### 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 +business file, and no copy before it can set one. So `activate --pm` to such a +copy fails its gate and rolls back. Use two phases instead (row 169, Gitea +#1666, mos-0848): + +1. `activate ID BUSINESS --pm-after-harness`, in place of `--pm`. It needs a + pointer with `pm` true. Before anything stops, it refuses + `pm-after-harness-target` when the target's `KINDS` has no `harness`, and + `pm-after-harness-set` when the business file already sets `harness`; + use `--pm` in both cases. It installs the bus without `--pm`, runs every + gate check but the PM's, and writes the pointer with `pm` false and + `pmPending` true. The log's final line is `result ok` with `pm pending`. + `status` and `--plan` print the pending state and the next step. +2. Jason sets `harness` with `plugins set` through the live bus. +3. `pm-on BUSINESS` refuses `not-pending` without a pending pointer and + `harness-unset` unless `harness` is a non-empty list the pointer's copy + loads. It restarts the bus alone, with `--pm` on the same copy, and runs + the whole gate. A pass writes `pm` true and drops `pmPending`. A failure + puts the phase-1 bus unit back and reruns the phase-1 gate. If that + passes, the log says `pm-on-failed`, the pointer stays pending and the run + exits 1; fix `harness` and run `pm-on` again. If it fails too, both units + stop, the log says `pm-on-down`, and the run exits 2. Neither touches the + copy, the Console or the store. + +While the pointer is pending, `activate` refuses `pm-pending`. Run `pm-on` +or `rollback`; a rollback from pending installs the old copy with `--pm` and +gates its launch. + A `started` line with no final line is an interrupted run. `status` shows it, and `adopt`, `activate` and `rollback` refuse until Jason or Mos settles it. Nothing prunes copies, rollback sets, backups or post-attempt stores. diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index d8546dc30..87e9a6dd1 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -240,11 +240,18 @@ const M=git(repo,'rev-parse','HEAD');git(repo,'update-ref','refs/remotes/origin/ // `to` false leaves out the `to:` lines (C1); a string puts that value in each one (A). The --plan // steps line can't be read from either (T27). const storeText=(v,froms,to=true)=>`export const SCHEMA_VERSION = '${v}';\nexport const MIGRATIONS = [\n${froms.map((f,i)=>` {\n from: '${f}',\n${to===false?'':` to: '${to===true?froms[i+1]??v:to}',\n`} run() {},\n },\n`).join('')}];\n`; +// Plug-in kinds (row 169): C1 and C know the harness kind, as a copy at or past c216f47db does. The +// rest have it under another name, as ca15d73e9 has no harness kind. Every copy has the real business +// loader and roles, which the tool imports to read the business file. +const KINDS_NEW=readFileSync(join(repo,'packages','plugins','src','kinds.mjs'),'utf8'); +const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-before-c216f47db": Object.freeze({'); +assert.notEqual(KINDS_OLD,KINDS_NEW); for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeText('3g',['3f'],'3x')],[B,storeText('3g',['3f'])],[C1,storeText('3g',['3f'],false)],[C,storeText('3h',['3f','3g'])],[S,storeText('3g',['3f'])],[U,storeText('3g',['3f'])]]){ const d=join(COPIES,id); - for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs','// fixture Console, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644]]){ + for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs','// fixture Console, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644]]){ mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode}); } + for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true}); execFileSync('chmod',['-R','a-w',d]); } for(const [name,fn] of [['systemctl',systemctl],['journalctl',journalctl],['systemd-analyze',systemdAnalyze]])writeFileSync(join(SBIN,name),script(fn),{mode:0o755}); @@ -283,6 +290,16 @@ function tree(p){ // T17: what a run may never remove. const kept=(e)=>[...tree(join(e.data,'backups','stack')),...tree(join(e.state,'stack-rollback')),...posts(e).flatMap((n)=>tree(join(e.data,n))),...tree(COPIES)].map((l)=>l.replace(/ .*/,'')); +// The business file (row 169): the committed example made loadable, as acme, with `plugins` when given. +const EXAMPLE=readFileSync(join(repo,'packages','business','examples','mosaic-stack.example.json'),'utf8'); +function biz(e,plugins){ + const b=JSON.parse(EXAMPLE);let n=0; + const walk=(o)=>{for(const [k,v] of Object.entries(o)){if(k==='botId')o[k]=++n;else if(v==='YYYY-MM-DD')o[k]='2027-01-01';else if(v&&typeof v==='object')walk(v)}}; + walk(b);b.id='acme';if(plugins!==undefined)b.plugins=plugins; + const f=join(dirname(e.cfg),'businesses','acme.json');mkdirSync(dirname(f),{recursive:true}); + writeFileSync(f,JSON.stringify(b,null,2)+'\n',{mode:0o600});chmodSync(f,0o600); +} + async function mk(t){ const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-'))); const e={root,data:join(root,'data'),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')}; @@ -290,6 +307,7 @@ async function mk(t){ for(const d of [join(root,'home'),dirname(e.cfg),join(e.data,'bus'),e.bin,dirname(e.claude),join(e.sd,'faults'),e.units,join(root,'xdata')])mkdirSync(d,{recursive:true}); chmodSync(join(e.data,'bus'),0o700); writeFileSync(e.cfg,JSON.stringify({configVersion:1,environment:'development',dataRoot:e.data,execution:{backend:'docker',provider:'zai',model:'glm-5.3-flash',adapter:'mock'}})); + biz(e); writeFileSync(join(e.bin,'mosaic'),`#!/usr/bin/env bash\n${MARKER}\n[ "\${1:-}" = __lease-capability ] && exec "${e.bin}/mosaic-old" "$@"\nexec "/old/scripts/mosaic" "$@"\n`,{mode:0o755}); writeFileSync(join(e.bin,'mosaic-old'),'#!/bin/sh\nexit 0\n',{mode:0o755}); writeFileSync(e.claude,'#!/bin/sh\necho "9.9.9 (Claude Code fixture)"\n',{mode:0o755}); @@ -827,3 +845,181 @@ test('T31 a quoted or escaped Environment= or ExecStart= is unreadable: refusals for(const x of [r,d])assert.ok(!x.err.includes('/a'),x.err); }); }); + +// --- Row 169 (#1666): docs/plans/2026-10-19_pm-after-harness.md items 1 to 4 --- + +const HARNESS={harness:['claude-code']}; +const busUnit=(e)=>readFileSync(join(e.units,'mosaic-bus@.service'),'utf8'); +const execLine=(to,pm)=>`ExecStart=${COPIES}/${to}/scripts/mosaic bus start %i${pm?' --pm':''}\n`; +const sinceLines=(e)=>lines(join(e.sd,'journal-since.log')).length; +const PENDING_MSG='the pointer is pending (activate --pm-after-harness); run pm-on or rollback first'; +// The stack after phase 1: seeded on B with --pm, then activate C1 --pm-after-harness. +async function pending(e){ + await seed(e,{pm:true}); + const r=await run(e,'activate',C1,'acme','--pm-after-harness');assert.equal(r.code,0,r.out+r.err); +} +// A refusal before anything stops: exit 3, the message, no file changed, no start or stop, one log line. +async function refused(e,args,code,msg){ + const before=fp(e),n=calls(e).length,logs=logLines(e).length; + const r=await run(e,...args);assert.equal(r.code,3,r.out+r.err); + assert.equal(r.err,`stack-release: refusing ${code}: ${msg}\n`); + assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]); + assert.equal(logLines(e).length,logs+1);assert.deepEqual([logLines(e).at(-1).result,logLines(e).at(-1).code],['refused',code]); +} + +test('T32 item 1: --pm-after-harness refuses a target without harness or a file that sets it, and passes without the PM',async(t)=>{ + await t.test('usage: not with --pm, and only for activate',async(t)=>{ + const e=await mk(t);await seed(e,{pm:true});const logs=logLines(e).length; + for(const args of [['activate',C1,'acme','--pm','--pm-after-harness'],['activate',C1,'acme','--pm-after-harness','--pm'],['rollback','acme','--pm-after-harness'],['pm-on','acme','--pm-after-harness']]){ + const r=await run(e,...args);assert.equal(r.code,4,args.join(' '));assert.match(r.err,/^usage: stack-release.sh adopt BUSINESS\n/); + } + assert.equal(logLines(e).length,logs); + }); + await t.test('pm-after-harness-target',async(t)=>{ + const e=await mk(t);await seed(e,{pm:true}); + await refused(e,['activate',A,'acme','--pm-after-harness'],'pm-after-harness-target',`${A} has no harness plug-in kind, so there is nothing to wait for; use --pm`); + }); + await t.test('pm-after-harness-set',async(t)=>{ + const e=await mk(t);await seed(e,{pm:true}); + for(const [h,shown] of [[['claude-code'],'["claude-code"]'],[[],'[]']]){ + biz(e,{harness:h}); + await refused(e,['activate',C1,'acme','--pm-after-harness'],'pm-after-harness-set',`the business file already sets harness ${shown}; use --pm`); + } + }); + await t.test('a pointer without pm',async(t)=>{ + const e=await mk(t);await seed(e); + await refused(e,['activate',C1,'acme','--pm-after-harness'],'unit-flags-drift','the units differ from the pointer; settle it first:\n pm: pointer 0, --pm-after-harness needs a pointer with pm 1'); + }); + await t.test('phase 1 installs without --pm, skips pm_launched and writes the pending pointer',async(t)=>{ + const e=await mk(t);await seed(e,{pm:true});const since=sinceLines(e),n=calls(e).length; + const r=await run(e,'activate',C1,'acme','--pm-after-harness');assert.equal(r.code,0,r.out+r.err); + assert.ok(r.out.endsWith(`stack-release: activate ok: ${C1} is live for acme without its PM; the PM is pending: Jason's plugins set of harness, then pm-on\n`),r.out); + assert.ok(busUnit(e).includes(execLine(C1,false)),busUnit(e)); + assert.equal(sinceLines(e),since); + assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]); + const {at,activation,...p}=pointer(e); + assert.deepEqual(p,{copy:C1,business:'acme',pm:false,pmPending:true,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g'}); + const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result,l.pm],['activate',B,C1,'ok','pending']); + }); +}); + +test('T33 item 2: read_pointer takes pmPending only as true beside pm false; a pending pointer refuses activate and shows in status',async(t)=>{ + await t.test('read_pointer',async(t)=>{ + const e=await mk(t);await seed(e);const good=pointer(e); + for(const bad of [{pmPending:false},{pmPending:'true'},{pmPending:1},{pmPending:null},{pm:true,pmPending:true}]){ + writeFileSync(e.pointer,JSON.stringify({...good,...bad})+'\n'); + const before=fp(e),n=calls(e).length,logs=logLines(e).length; + for(const args of [['activate',C1,'acme'],['status','acme']]){ + const r=await run(e,...args);assert.equal(r.code,1,`${JSON.stringify(bad)} ${args[0]}`); + assert.ok(r.err.endsWith(`stack-release: ${e.pointer} is unreadable; Jason or Mos settles it\n`),r.err); + } + assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs); + } + }); + await t.test('pm-pending, already-adopted and the status line',async(t)=>{ + const e=await mk(t);await pending(e); + for(const args of [['activate',C,'acme','--migrate'],['activate',C,'acme','--pm','--migrate'],['activate',C1,'acme','--pm-after-harness']])await refused(e,args,'pm-pending',PENDING_MSG); + await refused(e,['adopt','acme'],'already-adopted',`${e.pointer} exists (copy ${C1})`); + const before=tree(e.data),r=await run(e,'status','acme');assert.equal(r.code,0,r.err); + const out=r.out.split('\n'); + assert.deepEqual(out.slice(0,3),[`pointer: ${e.pointer}`,` ${readFileSync(e.pointer,'utf8').trimEnd()}`,'pm: pending (activate --pm-after-harness); next: Jason\'s plugins set of harness, then stack-release.sh pm-on acme']); + assert.ok(out.includes('unit flags: match the pointer'),r.out); + assert.deepEqual(tree(e.data),before); + }); +}); + +test('T34 item 3: pm-on refuses before any stop, passes with the PM, or puts the phase-1 bus back',async(t)=>{ + await t.test('not-pending',async(t)=>{ + const e=await mk(t);await seed(e,{pm:true});biz(e,HARNESS); + await refused(e,['pm-on','acme'],'not-pending','the pointer is not pending; pm-on follows activate --pm-after-harness'); + }); + await t.test('harness-unset',async(t)=>{ + const e=await mk(t);await pending(e); + await refused(e,['pm-on','acme'],'harness-unset','the business file sets no harness; Jason sets it with plugins set first'); + biz(e,{harness:[]}); + await refused(e,['pm-on','acme'],'harness-unset','the business file\'s harness is [], not a non-empty list'); + }); + await t.test('a launched line passes: the bus alone restarts with --pm',async(t)=>{ + const e=await mk(t);await pending(e);biz(e,HARNESS); + const since=sinceLines(e),n=calls(e).length,con=sha(join(e.units,CON)),act=pointer(e).activation; + const r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err); + assert.ok(r.out.endsWith(`stack-release: pm-on ok: the PM is live on ${C1} for acme\n`),r.out); + assert.deepEqual(startsStops(e,n),[`stop ${BUS}`,`start ${BUS}`]); + assert.ok(busUnit(e).includes(execLine(C1,true)),busUnit(e));assert.equal(sha(join(e.units,CON)),con); + assert.equal(sinceLines(e),since+1);assert.match(lines(join(e.sd,'journal-since.log')).at(-1),/^@\d+$/); + const {at,activation,...p}=pointer(e);assert.notEqual(activation,act); + assert.deepEqual(p,{copy:C1,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g'}); + const l=logLines(e).at(-1);assert.deepEqual([l.op,l.to,l.result,l.pm,l.activation],['pm-on',C1,'ok','on',activation]); + const s=await run(e,'status','acme');assert.equal(s.code,0,s.err);assert.ok(!s.out.includes('pm: pending'),s.out); + }); + await t.test('a failed pm_launched puts the phase-1 unit back: exit 1, still pending',async(t)=>{ + const e=await mk(t);await pending(e);biz(e,HARNESS);fault(e,`pm-${C1}`,'none'); + const before=fp(e),p0=readFileSync(e.pointer,'utf8'),n=calls(e).length; + const r=await run(e,'pm-on','acme','--timeout','3');assert.equal(r.code,1,r.out+r.err); + const l=logLines(e).at(-1),rs=join(e.state,'stack-rollback',l.activation); + assert.equal(r.err,`stack-release: pm-on failed with pm-launch-unconfirmed (no launched line since the start); the bus runs ${C1} without its PM again and passed the phase-1 gate, and the pointer stays pending. Fix harness with plugins set, then run pm-on again. Rollback set: ${rs}\n`); + assert.deepEqual([l.op,l.result,l.code,l.why],['pm-on','pm-on-failed','pm-launch-unconfirmed','no launched line since the start']); + assert.deepEqual(fp(e),before);assert.ok(busUnit(e).includes(execLine(C1,false))); + assert.equal(readFileSync(e.pointer,'utf8'),p0); + assert.deepEqual([units(e)[BUS].state,units(e)[BUS].wd,units(e)[CON].state],['active',join(COPIES,C1),'active']); + assert.deepEqual(startsStops(e,n),[`stop ${BUS}`,`start ${BUS}`,`stop ${BUS}`,`start ${BUS}`]); + }); + await t.test('a failed phase-1 gate too: pm-on-down, exit 2, both units stopped',async(t)=>{ + const e=await mk(t);await pending(e);biz(e,HARNESS);fault(e,`healthz-${C1}`); + const p0=readFileSync(e.pointer,'utf8'),store=sha(e.store); + const r=await run(e,'pm-on','acme','--timeout','3');assert.equal(r.code,2,r.out+r.err); + const l=logLines(e).at(-1),rs=join(e.state,'stack-rollback',l.activation); + assert.equal(r.err,`stack-release: pm-on-down: pm-on failed with healthz and the phase-1 bus with healthz; both units are stopped. Rollback set: ${rs}\n`); + assert.deepEqual([l.result,l.code,l.phase1],['pm-on-down','healthz','healthz']); + assert.deepEqual([units(e)[BUS].state,units(e)[CON].state],['inactive','inactive']); + assert.equal(readFileSync(e.pointer,'utf8'),p0);assert.equal(sha(e.store),store); + assert.ok(busUnit(e).includes(execLine(C1,false))); + }); +}); + +test('T35 item 4: rollback from pending installs --pm; a plug-in kind the target lacks refuses by name',async(t)=>{ + await t.test('rollback from pending gates pm_launched on the old copy',async(t)=>{ + const e=await mk(t);await pending(e);const since=sinceLines(e); + const r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err); + assert.ok(busUnit(e).includes(execLine(B,true)),busUnit(e)); + assert.equal(sinceLines(e),since+1); + const {at,activation,...p}=pointer(e); + assert.deepEqual(p,{copy:B,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g'}); + }); + await t.test('after the harness set, rollback refuses until harness is gone',async(t)=>{ + const e=await mk(t);await pending(e);biz(e,HARNESS); + assert.equal((await run(e,'pm-on','acme')).code,0); + await refused(e,['rollback','acme'],'unknown-kind-for-target',`the business file names plug-in kind(s) harness, which ${B} does not know; set each to null with plugins set through the live bus first`); + biz(e); + const r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err); + assert.ok(busUnit(e).includes(execLine(B,true)));assert.deepEqual([pointer(e).copy,pointer(e).pm],[B,true]); + }); + await t.test('activate names every kind the target lacks',async(t)=>{ + const e=await mk(t);await seed(e); + biz(e,{...HARNESS,widget:{}}); + await refused(e,['activate',A,'acme'],'unknown-kind-for-target',`the business file names plug-in kind(s) harness widget, which ${A} does not know`); + await refused(e,['activate',C1,'acme'],'unknown-kind-for-target',`the business file names plug-in kind(s) widget, which ${C1} does not know`); + }); +}); + +test('T36 --plan shows the pending state and lists the row 169 refusals it can see',async(t)=>{ + const e=await mk(t);await seed(e,{pm:true}); + const quiet=async(code,...args)=>{ + const before=fp(e),n=calls(e).length,logs=logLines(e).length,r=await run(e,...args); + assert.equal(r.code,code,r.out+r.err);assert.equal(r.err,''); + assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs); + return r.out.split('\n'); + }; + let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan'); + assert.deepEqual(out.slice(-5),['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(-6),['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(-6),['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(-5),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']); +}); diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index ed5b41d46..0b501ffbc 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -25,12 +25,13 @@ COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies" BIN="$HOME/.npm-global/bin" UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" CONSOLE_UNIT=mosaic-webui.service -TIMEOUT=60 WANT_PM=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" +TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" usage() { cat >&2 <<'USAGE' usage: stack-release.sh adopt BUSINESS - stack-release.sh activate ID BUSINESS [--pm] [--migrate] [--timeout S] [--plan] + stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--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 common options: --copies DIR, --bin DIR @@ -64,6 +65,7 @@ parse_opts() { --timeout) [ "$OP" != status ] && [ "$OP" != adopt ] && [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || usage; TIMEOUT="$2"; shift 2 ;; --plan) [ "$OP" = activate ] || [ "$OP" = rollback ] || usage; PLAN=1; shift ;; --pm) [ "$OP" = activate ] || usage; WANT_PM=1; shift ;; + --pm-after-harness) [ "$OP" = activate ] || usage; AFTER_HARNESS=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}$ ]] || usage; RESTORE="$2"; shift 2 ;; --events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;; @@ -71,6 +73,8 @@ parse_opts() { esac done [ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage + # --pm-after-harness replaces --pm (row 169 item 1), so the two never go together. + [ "$WANT_PM" = 0 ] || [ "$AFTER_HARNESS" = 0 ] || usage local d for d in "$COPIES" "$BIN"; do case "$d" in /*) ;; *) usage ;; esac @@ -80,30 +84,32 @@ parse_opts() { # --- The pointer and the log (item 1) --- -# Sets P_COPY P_BUSINESS P_PM P_CLAUDE P_SCHEMA P_ACT and the array P_CONSOLE; returns 1 with no -# pointer. A pointer that is there but malformed stops the script: Jason or Mos settles it. +# Sets P_COPY P_BUSINESS P_PM P_PENDING P_CLAUDE P_SCHEMA P_ACT and the array P_CONSOLE; returns 1 +# with no pointer. A pointer that is there but malformed stops the script: Jason or Mos settles it. +# pmPending (row 169) is missing or true, and true only beside pm false. read_pointer() { [ -f "$POINTER" ] || return 1 local fields fields="$(node -e ' const p=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); -const ok=/^[0-9a-f]{40}$/.test(p.copy)&&typeof p.business==="string"&&typeof p.pm==="boolean"&&typeof p.claude==="string"&&typeof p.schema==="string"&&typeof p.activation==="string"&&Array.isArray(p.console)&&p.console.every((t)=>typeof t==="string"&&t.length>0&&!/[\n\r]/.test(t)); +const ok=/^[0-9a-f]{40}$/.test(p.copy)&&typeof p.business==="string"&&typeof p.pm==="boolean"&&(p.pmPending===undefined||(p.pmPending===true&&p.pm===false))&&typeof p.claude==="string"&&typeof p.schema==="string"&&typeof p.activation==="string"&&Array.isArray(p.console)&&p.console.every((t)=>typeof t==="string"&&t.length>0&&!/[\n\r]/.test(t)); if(!ok)throw new Error("pointer fields are missing or malformed"); -process.stdout.write([p.copy,p.business,p.pm?1:0,p.claude,p.schema,p.activation,...p.console].join("\n")+"\n");' -- "$POINTER")" || { echo "stack-release: $POINTER is unreadable; Jason or Mos settles it" >&2; exit 1; } +process.stdout.write([p.copy,p.business,p.pm?1:0,p.pmPending?1:0,p.claude,p.schema,p.activation,...p.console].join("\n")+"\n");' -- "$POINTER")" || { echo "stack-release: $POINTER is unreadable; Jason or Mos settles it" >&2; exit 1; } local -a f mapfile -t f <<< "$fields" - P_COPY="${f[0]}" P_BUSINESS="${f[1]}" P_PM="${f[2]}" P_CLAUDE="${f[3]}" P_SCHEMA="${f[4]}" P_ACT="${f[5]}" - P_CONSOLE=("${f[@]:6}") + P_COPY="${f[0]}" P_BUSINESS="${f[1]}" P_PM="${f[2]}" P_PENDING="${f[3]}" P_CLAUDE="${f[4]}" P_SCHEMA="${f[5]}" P_ACT="${f[6]}" + P_CONSOLE=("${f[@]:7}") } -# Writes the pointer through a temp file in state/ and a rename. Args: copy pm claude schema console... +# Writes the pointer through a temp file in state/ and a rename. Args: copy pm pending claude schema +# console... A pending of 1 writes pmPending true (row 169); 0 leaves the key out. write_pointer() { local tmp tmp="$(mktemp "$STATE/.stack-active.XXXXXX")" node -e ' -const [copy,business,pm,claude,schema,activation,at,...console]=process.argv.slice(1); -process.stdout.write(JSON.stringify({copy,business,pm:pm==="1",claude,console,schema,activation,at})+"\n");' -- \ - "$1" "$BUSINESS" "$2" "$3" "$4" "$ACT" "$(now)" "${@:5}" > "$tmp" +const [copy,business,pm,pending,claude,schema,activation,at,...console]=process.argv.slice(1); +process.stdout.write(JSON.stringify({copy,business,pm:pm==="1",...(pending==="1"?{pmPending:true}:{}),claude,console,schema,activation,at})+"\n");' -- \ + "$1" "$BUSINESS" "$2" "$3" "$4" "$5" "$ACT" "$(now)" "${@:6}" > "$tmp" chmod 0644 "$tmp" mv -f "$tmp" "$POINTER" } @@ -218,6 +224,58 @@ check_target() { [ "$TO" != "$P_COPY" ] || refuse already-active "$TO is the pointer's copy" } +# --- Plug-in kinds (row 169) --- + +# Unlike copy_schema, these import the copy's own code, as a business-check does: its kinds.mjs +# (no imports) and, for `harness`, its business.mjs loader. Modes, for copy $2: +# has KIND exits 0 when the copy's KINDS has KIND +# unknown prints the business file's plugins keys the copy's KINDS lacks, from a plain JSON read +# harness prints plugins.harness as JSON, or `none`, from the copy's loader +# A failure exits 1 and prints its first line. +PLUGINS_JS=' +const {readFileSync}=require("node:fs");const {pathToFileURL}=require("node:url"); +const [mode,copy,id,kind]=process.argv.slice(1); +const imp=(p)=>import(pathToFileURL(`${copy}/packages/${p}`).href); +(async()=>{ + const {KINDS}=await imp("plugins/src/kinds.mjs"); + if(mode==="has")process.exit(Object.hasOwn(KINDS,kind)?0:1); + const b=await imp("business/src/business.mjs"); + if(mode==="unknown"){ + const p=JSON.parse(readFileSync(b.businessFilePath(id),"utf8")).plugins; + if(p===undefined)return; + if(p===null||typeof p!=="object"||Array.isArray(p))throw new Error("its plugins is not an object"); + process.stdout.write(Object.keys(p).filter((k)=>!Object.hasOwn(KINDS,k)).join(" ")); + }else{ + const h=b.loadBusiness(id,{rolesDir:`${copy}/roles`}).plugins.harness; + process.stdout.write(h===undefined?"none":JSON.stringify(h)); + } +})().catch((e)=>{process.stdout.write(String(e?.message??e).split("\n")[0]);process.exit(1)});' +plugins() { node -e "$PLUGINS_JS" -- "$1" "$COPIES/$2" "$BUSINESS" "${3:-}"; } + +# A refusal --plan lists and goes on from; outside --plan it is refuse, which exits. A caller +# returns after it, so the checks that depend on this one are skipped. +check_refuse() { + [ "$PLAN" = 1 ] || refuse "$@" + local code="$1"; shift + PLAN_REFUSE+=("$code: $*") +} + +# Item 1 and item 4, before anything stops, for target $TO: no plug-in kind the target lacks, then +# for --pm-after-harness the target's harness kind and no harness entry yet. +plugin_checks() { + local out fix="" + out="$(plugins unknown "$TO" 2>&1)" || { check_refuse business-unreadable "cannot read the business file's plugins with $TO's code: $out"; return 0; } + if [ -n "$out" ]; then + [ "$OP" != rollback ] || fix="; set each to null with plugins set through the live bus first" + check_refuse unknown-kind-for-target "the business file names plug-in kind(s) $out, which $TO does not know$fix" + return 0 + fi + [ "$AFTER_HARNESS" = 1 ] || return 0 + plugins has "$TO" harness 2>/dev/null || { check_refuse pm-after-harness-target "$TO has no harness plug-in kind, so there is nothing to wait for; use --pm"; return 0; } + out="$(plugins harness "$TO" 2>&1)" || { check_refuse business-unreadable "$TO's loader refuses the business file: $out"; return 0; } + [ "$out" = none ] || check_refuse pm-after-harness-set "the business file already sets harness $out; use --pm" +} + # --- The store --- STORE_JS=' @@ -533,12 +591,19 @@ drop_in_override() { } # unit-flags-drift: the effective units against the pointer, and --pm against the pointer's pm. +# --pm-after-harness stands in for --pm (row 169 item 1). A pending pointer (pm false, unit +# without --pm) matches its unit; only pm-on and rollback get this far with one, since activate +# refuses pm-pending first and adopt refuses already-adopted. flags_drift() { local d="" [ "$U_PM" = "$P_PM" ] || d+=$'\n'" pm: pointer $P_PM, unit $U_PM" [ "$U_CLAUDE" = "$P_CLAUDE" ] || d+=$'\n'" claude: pointer $P_CLAUDE, unit $U_CLAUDE" [ "${P_CONSOLE[*]}" = "${U_CONSOLE[*]}" ] || d+=$'\n'" console: pointer ${P_CONSOLE[*]}, unit ${U_CONSOLE[*]}" - [ "$OP" != activate ] || [ "$WANT_PM" = "$P_PM" ] || d+=$'\n'" pm: pointer $P_PM, --pm given $WANT_PM" + if [ "$AFTER_HARNESS" = 1 ]; then + [ "$P_PM" = 1 ] || d+=$'\n'" pm: pointer $P_PM, --pm-after-harness needs a pointer with pm 1" + else + [ "$OP" != activate ] || [ "$WANT_PM" = "$P_PM" ] || d+=$'\n'" pm: pointer $P_PM, --pm given $WANT_PM" + fi [ -z "$d" ] || refuse unit-flags-drift "the units differ from the pointer; settle it first:$d" } @@ -577,7 +642,7 @@ do_adopt() { [ -n "$U_CLAUDE" ] || refuse no-claude-pin "$BUS_UNIT has no MOSAIC_CLAUDE_BIN" target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION from $COPIES/$TO/packages/bus/src/store.mjs" ACT="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}" - write_pointer "$TO" "$U_PM" "$U_CLAUDE" "$T_SCHEMA" "${U_CONSOLE[@]}" + write_pointer "$TO" "$U_PM" 0 "$U_CLAUDE" "$T_SCHEMA" "${U_CONSOLE[@]}" log_line result ok echo "adopted $TO for $BUSINESS (pm $U_PM, schema $T_SCHEMA)" } @@ -666,9 +731,14 @@ plan() { echo "restore: the store must go back to $T_SCHEMA; needs --restore $(from_log backup || echo ACTIVATION)" fi plan_commits - echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$P_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE" + [ "$P_PENDING" = 0 ] || echo "pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM" + [ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on" + echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE" echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${P_CONSOLE[*]:+ ${P_CONSOLE[*]}}" echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN" + local r + for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done + [ "${#PLAN_REFUSE[@]}" = 0 ] || exit 3 } # Steps 4.1 to 4.5 and the gate for target $TO. $1 is the store step: migrate or restore. @@ -701,21 +771,39 @@ run_steps() { elif [ -n "$RESTORE" ]; then rollback_in_run store-differs fi - install_stack "$TO" "$P_PM" "$P_CLAUDE" "${P_CONSOLE[@]}" || after_fail "$FAIL" + install_stack "$TO" "$RUN_PM" "$P_CLAUDE" "${P_CONSOLE[@]}" || after_fail "$FAIL" start_both || after_fail start - gate "$TO" "$P_PM" || after_fail "$GATE" + gate "$TO" "$RUN_PM" || after_fail "$GATE" trap - INT TERM HUP - write_pointer "$TO" "$P_PM" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}" - log_line result ok - echo "stack-release: $OP ok: $TO is live for $BUSINESS" + write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}" + if [ "$RUN_PENDING" = 1 ]; then + log_line result ok pm pending + echo "stack-release: $OP ok: $TO is live for $BUSINESS without its PM; the PM is pending: Jason's plugins set of harness, then pm-on" + else + log_line result ok + echo "stack-release: $OP ok: $TO is live for $BUSINESS" + fi +} + +# The pm the run installs and gates, and whether the pointer it writes is pending (row 169). +# --pm-after-harness installs without --pm and leaves the PM pending; a rollback from a pending +# pointer installs the --pm the activate replaced. +run_pm() { + RUN_PM="$P_PM" RUN_PENDING=0 + if [ "$AFTER_HARNESS" = 1 ]; then RUN_PM=0 RUN_PENDING=1 + elif [ "$P_PENDING" = 1 ]; then RUN_PM=1 + fi } do_activate() { TO="$ID" [ "$PLAN" = 1 ] || lock pointer_for_business - if [ "$PLAN" = 1 ]; then check_target; plan; exit 0; fi + run_pm + [ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first" + if [ "$PLAN" = 1 ]; then check_target; plugin_checks; plan; exit 0; fi precheck + plugin_checks new_activation run_steps migrate } @@ -724,8 +812,10 @@ do_rollback() { [ "$PLAN" = 1 ] || lock pointer_for_business TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY" - if [ "$PLAN" = 1 ]; then check_target; plan; exit 0; fi + run_pm + if [ "$PLAN" = 1 ]; then check_target; plugin_checks; plan; exit 0; fi precheck + plugin_checks [ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION" new_activation if [ -n "$RESTORE" ]; then @@ -750,9 +840,86 @@ do_rollback() { run_steps restore } +# --- pm-on (row 169 item 3) --- + +# Stops the bus alone and waits for it and the stopped check, within --timeout. The Console runs on. +stop_bus() { + systemctl --user stop "$BUS_UNIT" || return 1 + local deadline=$((SECONDS + TIMEOUT)) s + while :; do + s="$(systemctl --user is-active "$BUS_UNIT" 2>/dev/null || true)" + case "$s" in inactive|failed) if stopped; then say "stopped $BUS_UNIT"; return 0; fi ;; esac + [ "$SECONDS" -lt "$deadline" ] || return 1 + sleep 1 + done +} + +start_bus() { + START_EPOCH="$(date +%s)" && START_MS="$(date +%s%3N)" || return 1 + systemctl --user start "$BUS_UNIT" +} + +# mos-0848 point 3: put the phase-1 bus unit back (no --pm), restart it and rerun the phase-1 gate. +# Passing leaves the bus live without its PM and the pointer pending (exit 1); failing stops both +# units (exit 2). Neither touches the copy or the store. $1 is the code pm-on failed with. +pm_on_fail() { + trap - INT TERM HUP + local code="$1" why="$GATE_WHY" pcode="" + say "pm-on failed: $code${why:+ ($why)}; putting the phase-1 bus unit back" + GATE_WHY="" + if ! stop_bus; then pcode=stop-timeout + elif ! put_back; then pcode=put-back + elif ! start_bus; then pcode=start + elif ! gate "$P_COPY" 0; then pcode="$GATE" + fi + if [ -z "$pcode" ]; then + log_line result pm-on-failed code "$code" why "${why:-@null}" + echo "stack-release: pm-on failed with $code${why:+ ($why)}; the bus runs $P_COPY without its PM again and passed the phase-1 gate, and the pointer stays pending. Fix harness with plugins set, then run pm-on again. Rollback set: $RS" >&2 + exit 1 + fi + local pwhy="$GATE_WHY" + systemctl --user stop "$CONSOLE_UNIT" || true + systemctl --user stop "$BUS_UNIT" || true + say "pm-on-down: pm-on $code, phase 1 $pcode" + log_line result pm-on-down code "$code" why "${why:-@null}" phase1 "$pcode" phase1Why "${pwhy:-@null}" + echo "stack-release: pm-on-down: pm-on failed with $code${why:+ ($why)} and the phase-1 bus with $pcode${pwhy:+ ($pwhy)}; both units are stopped. Rollback set: $RS" >&2 + exit 2 +} + +# Phase 2: the bus unit alone, on the pointer's copy with --pm, gated with pm_launched. +do_pm_on() { + lock + pointer_for_business + [ "$P_PENDING" = 1 ] || refuse not-pending "the pointer is not pending; pm-on follows activate --pm-after-harness" + local out + out="$(plugins harness "$P_COPY" 2>&1)" || refuse harness-unset "$P_COPY's loader refuses the business file: $out" + [ "$out" != none ] || refuse harness-unset "the business file sets no harness; Jason sets it with plugins set first" + node -e 'const h=JSON.parse(process.argv[1]);process.exit(Array.isArray(h)&&h.length>0?0:1)' -- "$out" 2>/dev/null || refuse harness-unset "the business file's harness is $out, not a non-empty list" + instance_busy + drop_in_override + read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT" + [ -n "$U_CLAUDE" ] || refuse no-claude-pin "$BUS_UNIT has no MOSAIC_CLAUDE_BIN" + flags_drift + TO="$P_COPY" + new_activation + log_line result started + capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped" + trap 'pm_on_fail signal' INT TERM HUP + stop_bus || pm_on_fail stop-timeout + scripts/bus-service.sh install --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus + say "installed $BUS_UNIT for $P_COPY with --pm" + start_bus || pm_on_fail start + gate "$P_COPY" 1 || pm_on_fail "$GATE" + trap - INT TERM HUP + write_pointer "$P_COPY" 1 0 "$P_CLAUDE" "$P_SCHEMA" "${P_CONSOLE[@]}" + log_line result ok pm on + echo "stack-release: pm-on ok: the PM is live on $P_COPY for $BUSINESS" +} + do_status() { echo "pointer: $POINTER" if read_pointer; then sed 's/^/ /' "$POINTER"; else echo " none"; fi + [ "$P_PENDING" = 0 ] || echo "pm: pending (activate --pm-after-harness); next: Jason's plugins set of harness, then stack-release.sh pm-on $BUSINESS" echo "log (last five lines):" if [ -f "$LOG" ]; then tail -n 5 "$LOG" | sed 's/^/ /'; else echo " none"; fi local open @@ -786,7 +953,7 @@ OP="$1"; shift ID="" case "$OP" in activate) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;; - adopt|rollback|status) [ $# -ge 1 ] || usage; BUSINESS="$1"; shift ;; + adopt|pm-on|rollback|status) [ $# -ge 1 ] || usage; BUSINESS="$1"; shift ;; -h|--help) usage 0 ;; *) usage ;; esac @@ -802,10 +969,11 @@ 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_SCHEMA="" P_CLAUDE="" P_CONSOLE=() +P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 PLAN_REFUSE=() case "$OP" in adopt) do_adopt ;; activate) do_activate ;; + pm-on) do_pm_on ;; rollback) do_rollback ;; status) do_status ;; esac