Files
jason.woltje 158205c5cc lead: commit darkwing's untracked evidence (348 files: queue-137 queue-141 queue-150 queue-162 queue-165 queue-166 queue-167 queue-169 queue-179 queue-180 queue-181 queue-182 queue-185 queue-186 queue-187 queue-188 queue-190 queue-93)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

270 lines
18 KiB
Diff

diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index ad1a24add..49fa5d1e7 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -387,6 +387,12 @@ one final line with `result` `ok`, `refused`, `gate-failed`,
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`).
+An activation id is the UTC second and the target copy's first 12 characters,
+`20261020T013708Z-89af38a83afa`. A second run in the same second adds `-1`,
+then `-2` up to `-9`, the first one with no rollback set, backup or
+post-attempt store yet, so ids still sort by time. With all ten taken the run
+refuses `capture-failed` before it stops anything. Adopt uses the same rule.
+
1. Once per host, `adopt BUSINESS` records the running stack as the pointer:
the copy `stack-copy.sh check` reports, the bus unit's `--pm` and
`MOSAIC_CLAUDE_BIN`, the Console's flags without `--checkout`, and the
@@ -421,11 +427,13 @@ failure that rolled back exits 1, a failed rollback exits 2, usage exits 4. Only
this start. A pass writes the pointer.
6. A gate failure puts back the saved units and wrapper, restores the backup
when a migration ran, keeps the store it replaced as
- `bus.post-attempt-<stamp>`, starts the old copy and gates it. If that gate
+ `bus.post-attempt-<stamp>`, where the stamp is the activation's second and
+ its suffix if it has one, starts the old copy and gates it. If that gate
fails too, both units stay stopped, the log says `rollback-failed`, and
Jason or Mos settles it from the rollback set the message names.
7. `rollback BUSINESS` returns to the copy before the pointer's, taken from
- the log. When the schemas differ it needs `--restore ACTIVATION`; events
+ the log. When the schemas differ it needs `--restore ACTIVATION`, with or
+ without a `-1` to `-9` suffix; events
written after that backup need `--events N` with the count it prints, and
Jason decides first. It refuses `business-unreadable` and
`unknown-kind-for-target` like `activate`. For the second, set each kind
@@ -834,7 +842,10 @@ unit write for the authorized operator, with no start/restart verb.
`stack-release.sh activate ID BUSINESS --tester-port N` replaces or adds this
Console flag in both the installed unit and the pointer. It checks the target
copy's own Console before stopping anything; an explicit unsupported request
-refuses `tester-port-target`. Without an explicit flag, activate and rollback
+refuses `tester-port-target`. A port outside 1024 to 65535, 7332 or the
+Console's `--port` is a usage error (exit 4). Under `--plan` both are listed
+with the plan's other refusals instead, the bad port as `tester-port-invalid`,
+and the plan exits 3. Without an explicit flag, activate and rollback
keep it between supporting copies, or drop it when the target predates support.
The plan prints the final Console flags and any dropped `--tester-port N`;
the steps log records a drop too. `pm-on` keeps the pointer's Console flags.
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
index 56d8eeb55..c7d9478c9 100644
--- a/packages/cli/tests/stack-release.test.mjs
+++ b/packages/cli/tests/stack-release.test.mjs
@@ -1095,3 +1095,105 @@ test('T42 tester-port pm-on preserves pointer Console flags',async(t)=>{
r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,testerFlags(e));assert.match(consoleText(e),/--tester-port 7333(?:\n|$)/);
});
+
+// Row 180 item 1 (P19 391): a date stand-in prints one second for every call that names a time,
+// so each run below starts in that second, as two runs within one real second do. Calls for
+// +%s (the gate's start mark) pass through, so each gate still reads only its own start.
+function pinClock(e){
+ const dir=join(e.root,'clock'),second=Math.floor(Date.now()/1000);mkdirSync(dir);
+ writeFileSync(join(dir,'date'),`#!/bin/sh\ncase "$*" in *%s*) exec /usr/bin/date "$@" ;; esac\nexec /usr/bin/date -d @${second} "$@"\n`,{mode:0o755});
+ e.env={...e.env,PATH:`${dir}:${e.env.PATH}`};
+ return execFileSync(join(dir,'date'),['-u','+%Y%m%dT%H%M%SZ'],{env:e.env,encoding:'utf8'}).trim();
+}
+const rollbackSets=(e)=>readdirSync(join(e.state,'stack-rollback')).sort();
+
+test('T43 row 180 item 1: runs in one second get distinct activation ids, rollback sets and post-attempt stores',async(t)=>{
+ await t.test('activate then pm-on on the same copy',async(t)=>{
+ const e=await mk(t);await seed(e,{pm:true});const sec=pinClock(e),id=`${sec}-${C1.slice(0,12)}`;
+ let r=await run(e,'activate',C1,'acme','--pm-after-harness');assert.equal(r.code,0,r.out+r.err);
+ assert.equal(pointer(e).activation,id);
+ biz(e,HARNESS);
+ r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
+ assert.equal(pointer(e).activation,`${id}-1`);
+ assert.deepEqual(logLines(e).slice(-4).map((l)=>[l.op,l.result,l.activation]),[['activate','started',id],['activate','ok',id],['pm-on','started',`${id}-1`],['pm-on','ok',`${id}-1`]]);
+ assert.deepEqual(rollbackSets(e),[id,`${id}-1`]);
+ for(const a of [id,`${id}-1`])assert.ok(existsSync(join(e.state,'stack-rollback',a,'SHA256SUMS')),a);
+ });
+ await t.test('two restores: an old-form id and a suffixed one, each with its own post-attempt store',async(t)=>{
+ const e=await mk(t);await seed(e);const sec=pinClock(e),c=`${sec}-${C.slice(0,12)}`,b=`${sec}-${B.slice(0,12)}`;
+ for(const [act,restore] of [[c,b],[`${c}-1`,`${b}-1`]]){
+ let r=await run(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).activation,act);
+ assert.ok(existsSync(join(e.data,'backups','stack',act,'SHA256SUMS')),act);
+ r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,0,r.out+r.err);
+ const post=join(e.data,`bus.post-attempt-${sec}${restore.slice(b.length)}`);
+ assert.ok(r.out.startsWith(`restore: ${join(e.data,'backups','stack',act)}; the store now in place moves to ${post} and is kept; 0 event(s) were written after the backup\n`),r.out);
+ assert.deepEqual([pointer(e).copy,pointer(e).activation,pointer(e).schema],[B,restore,'3g']);
+ assert.equal(logLines(e).at(-1).backup,act);
+ }
+ assert.deepEqual(posts(e).sort(),[`bus.post-attempt-${sec}`,`bus.post-attempt-${sec}-1`]);
+ assert.deepEqual(readdirSync(join(e.data,'backups','stack')).sort(),[c,`${c}-1`]);
+ assert.deepEqual(rollbackSets(e),[c,`${c}-1`,b,`${b}-1`].sort());
+ });
+ await t.test('adopt takes the next free id too',async(t)=>{
+ const e=await mk(t);await seed(e,{pointer:'none'});const id=`${pinClock(e)}-${B.slice(0,12)}`;
+ mkdirSync(join(e.state,'stack-rollback',id),{recursive:true});
+ const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.out+r.err);
+ assert.equal(pointer(e).activation,`${id}-1`);
+ assert.deepEqual(logLines(e).map((l)=>[l.op,l.result,l.activation]),[['adopt','ok',`${id}-1`]]);
+ });
+ await t.test('all ten names taken: capture-failed before anything stops, and the log line names no activation',async(t)=>{
+ const e=await mk(t);await seed(e);const sec=pinClock(e),id=`${sec}-${C1.slice(0,12)}`;
+ // A rollback set, a backup or a post-attempt store each takes a name.
+ for(const s of ['','-1','-2','-3','-4','-5','-6','-7'])mkdirSync(join(e.state,'stack-rollback',`${id}${s}`),{recursive:true});
+ mkdirSync(join(e.data,'backups','stack',`${id}-8`),{recursive:true});writeFileSync(join(e.data,`bus.post-attempt-${sec}-9`),'');
+ await refused(e,['activate',C1,'acme'],'capture-failed',`every activation id for ${id} is taken; nothing was stopped`);
+ assert.equal(logLines(e).at(-1).activation,null);
+ });
+ await t.test('--restore takes an id with or without -1 to -9 and nothing else',async(t)=>{
+ const e=await mk(t);await seed(e);const id=`20261020T013708Z-${B.slice(0,12)}`;
+ for(const bad of [`${id}-0`,`${id}-10`,`${id}-a`,`${id}-`,`${id}1`]){
+ const r=await run(e,'rollback','acme','--restore',bad,'--plan');assert.equal(r.code,4,bad);
+ }
+ for(const good of [id,`${id}-1`,`${id}-9`]){
+ const r=await run(e,'rollback','acme','--restore',good,'--plan');assert.notEqual(r.code,4,`${good}: ${r.err}`);
+ }
+ });
+});
+
+test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-target with its other refusals',async(t)=>{
+ const usageText=async(e)=>(await run(e,'--help')).err;
+ await t.test('a bad tester port',async(t)=>{
+ const e=await mk(t);await seed(e,{pm:true});biz(e,HARNESS);
+ const before=fp(e),n=calls(e).length,logs=logLines(e).length,p0=readFileSync(e.pointer,'utf8');
+ const rule=(port,main)=>`--tester-port ${port} is not a whole port from 1024 to 65535 other than 7332${main?` and the Console's --port ${main}`:''}`;
+ // The fixture's Console has --port e.port; a pointer without --port has the default 7332, which
+ // the reason names once. --plan reads the pointer's flags and not the units, so only it changes.
+ const noPort=()=>{const q=JSON.parse(p0);q.console=q.console.filter((f,i,a)=>f!=='--port'&&a[i-1]!=='--port');writeFileSync(e.pointer,JSON.stringify(q)+'\n')};
+ for(const [port,reason,edit] of [['80',rule('80',e.port)],[e.port,rule(e.port,e.port)],['80',rule('80'),noPort],['7332',rule('7332'),noPort]]){
+ if(edit)edit();
+ const r=await run(e,'activate',C1,'acme','--pm-after-harness','--plan','--tester-port',port);
+ assert.equal(r.code,3,r.out+r.err);assert.equal(r.err,'');
+ assert.deepEqual(r.out.split('\n').filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: ${reason}`,'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm']);
+ const u=await run(e,'activate',C1,'acme','--pm-after-harness','--tester-port',port);
+ assert.equal(u.code,4,u.out+u.err);assert.equal(u.out,'');assert.equal(u.err,await usageText(e));
+ writeFileSync(e.pointer,p0);
+ }
+ assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
+ });
+ await t.test('without --plan a bad port is usage before any pointer read',async(t)=>{
+ // parse_opts checks it, so a host with no pointer gets usage and not no-pointer (exit 3, a log line).
+ const e=await mk(t);await seed(e,{pointer:'none'});
+ const n=calls(e).length,u=await run(e,'activate',C1,'acme','--tester-port','80');
+ assert.equal(u.code,4,u.out+u.err);assert.equal(u.out,'');assert.equal(u.err,await usageText(e));
+ assert.deepEqual(logLines(e),[]);assert.deepEqual(startsStops(e,n),[]);
+ });
+ await t.test('a target Console without --tester-port',async(t)=>{
+ const e=await mk(t);await seed(e);biz(e,HARNESS);
+ const before=fp(e),n=calls(e).length,logs=logLines(e).length;
+ const r=await run(e,'activate',A,'acme','--tester-port','7333','--plan');
+ assert.equal(r.code,3,r.out+r.err);assert.equal(r.err,'');
+ assert.deepEqual(r.out.split('\n').filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-target: ${A}'s Console does not support --tester-port`,`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`]);
+ assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
+ await refused(e,['activate',A,'acme','--tester-port','7333'],'tester-port-target',`${A}'s Console does not support --tester-port`);
+ });
+});
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
index cc59bcb12..8811a5b9e 100644
--- a/packages/webui/tests/service.test.mjs
+++ b/packages/webui/tests/service.test.mjs
@@ -170,6 +170,19 @@ test('tester service invalid ports refuse render and install with exit 4 and lea
assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
});
+// Row 180 (Dewey's row 176 N2): the length counts as written, before the zeros go, as serve
+// counts it. 007333 is six digits and refuses; 07333 is five and renders.
+test('tester service counts the digits as written before it strips leading zeros', async (t) => {
+ const dir = tmp(t);
+ const six = await run(dir, ['render', '--tester-port', '007333']);
+ assert.equal(six.status, 4, six.stderr);
+ assert.equal(six.stdout, '');
+ assert.equal(six.stderr, 'webui-service: --tester-port must be 1024..65535, different from --port and never 7332: 007333\n');
+ const five = await run(dir, ['render', '--tester-port', '07333']);
+ assert.equal(five.status, 0, five.stderr);
+ assert.ok(execStart(five.stdout).endsWith(' --tester-port 07333'), execStart(five.stdout));
+});
+
test('tester service install and status show the tester port without changing send reporting', async (t) => {
const dir = tmp(t);
const unitDir = join(dir, 'units');
diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh
index adcb68477..f70295e99 100755
--- a/scripts/stack-release.sh
+++ b/scripts/stack-release.sh
@@ -73,14 +73,16 @@ parse_opts() {
--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 ;;
- --tester-port) [ "$OP" = activate ] && [ $# -ge 2 ] || usage; TESTER_PORT="$2"; valid_tester_port "$TESTER_PORT" 7332 || usage; shift 2 ;;
+ --tester-port) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; TESTER_PORT="$2"; shift 2 ;;
--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 ;;
+ --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 ;;
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
*) usage ;;
esac
done
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || 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.
[ "$WANT_PM" = 0 ] || [ "$AFTER_HARNESS" = 0 ] || usage
local d
@@ -633,8 +635,14 @@ console_for_target() {
RUN_CONSOLE=("${P_CONSOLE[@]}")
DROP_TESTER=""
if [ -n "$TESTER_PORT" ]; then
- valid_tester_port "$TESTER_PORT" "$main" || usage
- [ "$supported" = 1 ] || refuse tester-port-target "$TO's Console does not support --tester-port"
+ # --plan lists both with its other refusals (row 180); outside it they end the run.
+ if ! valid_tester_port "$TESTER_PORT" "$main"; then
+ [ "$PLAN" = 1 ] || usage
+ local other=""
+ [ "$main" = 7332 ] || other=" and the Console's --port $main"
+ check_refuse tester-port-invalid "--tester-port $TESTER_PORT is not a whole port from 1024 to 65535 other than 7332$other"
+ fi
+ [ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port"
RUN_CONSOLE=("${without_tester[@]}" --tester-port "$TESTER_PORT")
elif [ -n "$inherited" ] && [ "$supported" = 0 ]; then
RUN_CONSOLE=("${without_tester[@]}")
@@ -659,10 +667,25 @@ pointer_for_business() {
[ "$P_BUSINESS" = "$BUSINESS" ] || refuse business-mismatch "the pointer is for $P_BUSINESS"
}
+# The activation id is the second and the target's 12-character prefix. A second run in the same
+# second adds -1 to -9, so ids still sort by time and an id without one is still whole (row 180).
+# Callers hold the lock. Sets ACT and POST from one free suffix: no rollback set, backup or
+# post-attempt store with that name.
+next_activation() {
+ local second s
+ second="$(date -u +%Y%m%dT%H%M%SZ)"
+ for s in "" -1 -2 -3 -4 -5 -6 -7 -8 -9; do
+ ACT="$second-${TO:0:12}$s" POST="$DATA/bus.post-attempt-$second$s"
+ [ -e "$STATE/stack-rollback/$ACT" ] || [ -e "$DATA/backups/stack/$ACT" ] || [ -e "$POST" ] || return 0
+ done
+ # Every name is another run's, so the refused line names none and closes no started run.
+ ACT="" POST=""
+ refuse capture-failed "every activation id for $second-${TO:0:12} is taken; nothing was stopped"
+}
+
new_activation() {
- ACT="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
+ next_activation
RS="$STATE/stack-rollback/$ACT"
- POST="$DATA/bus.post-attempt-$(date -u +%Y%m%dT%H%M%SZ)"
}
# --- Verbs ---
@@ -677,7 +700,7 @@ do_adopt() {
read_units || refuse check-failed "cannot read $BUS_UNIT or $CONSOLE_UNIT"
[ -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}"
+ next_activation
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)"
diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh
index b0782dd34..71db7e5c1 100755
--- a/scripts/webui-service.sh
+++ b/scripts/webui-service.sh
@@ -122,6 +122,8 @@ tester_port_usage() {
tester_port_ok() {
case "$TESTER_PORT" in ''|*[!0-9]*) tester_port_usage ;; esac
+ # At most five digits as written, before the zeros go, as serve and stack-release.sh count them.
+ [ "${#TESTER_PORT}" -le 5 ] || tester_port_usage
local tester_port="$TESTER_PORT"
# Compare decimal values, including values written with leading zeros.
while [ "${tester_port#0}" != "$tester_port" ]; do tester_port="${tester_port#0}"; done