@north-light/crouter 0.3.179 → 0.3.181
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +27 -2
- package/dist/api/client.js +41 -1
- package/dist/api/dto/broker.d.ts +32 -0
- package/dist/api/dto/crons.d.ts +17 -0
- package/dist/api/dto/human.d.ts +1 -7
- package/dist/api/dto/inbox.d.ts +71 -1
- package/dist/api/dto/inbox.js +9 -1
- package/dist/api/dto/memory.d.ts +17 -0
- package/dist/api/dto/memory.js +6 -0
- package/dist/api/dto/messages.d.ts +5 -0
- package/dist/api/dto/reviews.d.ts +8 -4
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +5 -0
- package/dist/api/routes.js +8 -1
- package/dist/build-root.d.ts +7 -0
- package/dist/build-root.js +21 -0
- package/dist/builtin-memory/insights/init.md +48 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/__tests__/insights-active-init.test.ts +98 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +7 -50
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +16 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/pi-shell-runner.ts +34 -0
- package/dist/cli.js +1 -2
- package/dist/clients/attach/__tests__/context-message.test.js +5 -2
- package/dist/clients/attach/assets/README.md +7 -0
- package/dist/clients/attach/assets/whip-06.mp3 +0 -0
- package/dist/clients/attach/assets/whip-crack.mp3 +0 -0
- package/dist/clients/attach/assets/whip-snap.mp3 +0 -0
- package/dist/clients/attach/chrome/canvas-panels.d.ts +7 -1
- package/dist/clients/attach/chrome/canvas-panels.js +20 -3
- package/dist/clients/attach/chrome/review-wait.d.ts +6 -0
- package/dist/clients/attach/chrome/review-wait.js +22 -0
- package/dist/clients/attach/chrome/roster.js +23 -2
- package/dist/clients/attach/chrome/widgets.js +1 -1
- package/dist/clients/attach/input/controller.js +4 -3
- package/dist/clients/attach/overlays/mcp.js +3 -1
- package/dist/clients/attach/render/chat-view.js +1 -1
- package/dist/clients/attach/session/whip.d.ts +1 -0
- package/dist/clients/attach/session/whip.js +26 -0
- package/dist/clients/attach/slash/dispatch.js +2 -0
- package/dist/clients/attach/viewer.js +578 -573
- package/dist/clients/inbox/review/document-surface.d.ts +1 -1
- package/dist/clients/inbox/review/document-surface.js +4 -4
- package/dist/clients/inbox/review/launch.js +16 -4
- package/dist/clients/inbox/review/review-client.d.ts +9 -4
- package/dist/clients/inbox/review/review-client.js +3 -0
- package/dist/commands/cron.js +30 -8
- package/dist/commands/human/prompts.d.ts +7 -2
- package/dist/commands/human/prompts.js +15 -10
- package/dist/commands/human.js +1 -2
- package/dist/commands/memory/find.js +11 -8
- package/dist/commands/memory/read.js +111 -11
- package/dist/commands/memory/write.js +1 -1
- package/dist/commands/memory.js +1 -1
- package/dist/commands/pkg/market-manage.d.ts +13 -0
- package/dist/commands/pkg/market-manage.js +39 -33
- package/dist/commands/pkg/plugin-inspect.js +4 -3
- package/dist/commands/pkg/plugin-manage.js +12 -11
- package/dist/commands/surface/node/focus.js +1 -2
- package/dist/commands/sys/doctor.js +4 -4
- package/dist/commands/sys/setup-core.d.ts +14 -7
- package/dist/commands/sys/setup-core.js +66 -11
- package/dist/commands/sys/setup-wizard.js +2 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/core/__tests__/cron-held-settlement.test.d.ts +1 -0
- package/dist/core/__tests__/cron-held-settlement.test.js +222 -0
- package/dist/core/__tests__/helpers/harness.js +1 -2
- package/dist/core/__tests__/phase4-review-store.test.js +1 -0
- package/dist/core/__tests__/serial/command-plugins.test.js +88 -1
- package/dist/core/__tests__/session-model.test.js +5 -3
- package/dist/core/bootstrap.d.ts +0 -4
- package/dist/core/bootstrap.js +1 -55
- package/dist/core/canvas/crons.d.ts +54 -2
- package/dist/core/canvas/crons.js +48 -4
- package/dist/core/canvas/db.js +23 -0
- package/dist/core/command-manifests/manifest.d.ts +11 -0
- package/dist/core/command-manifests/manifest.js +45 -4
- package/dist/core/command-manifests/schema.d.ts +1 -1
- package/dist/core/command-plugins/bundle.d.ts +1 -0
- package/dist/core/command-plugins/bundle.js +3 -3
- package/dist/core/command-plugins/discovery.d.ts +5 -2
- package/dist/core/command-plugins/discovery.js +5 -5
- package/dist/core/command-plugins/help-addenda.d.ts +12 -0
- package/dist/core/command-plugins/help-addenda.js +30 -0
- package/dist/core/command.js +25 -2
- package/dist/core/config.js +0 -1
- package/dist/core/human/convention.d.ts +0 -1
- package/dist/core/human/convention.js +0 -6
- package/dist/core/keybindings/inbox.d.ts +6 -8
- package/dist/core/keybindings/inbox.js +6 -15
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/index.js +1 -1
- package/dist/core/memory/doc-link-grammar.js +4 -1
- package/dist/core/memory-resolver.d.ts +28 -4
- package/dist/core/memory-resolver.js +51 -39
- package/dist/core/review/stage.js +1 -0
- package/dist/core/review/store.d.ts +5 -0
- package/dist/core/review/store.js +10 -0
- package/dist/core/review/types.d.ts +4 -0
- package/dist/core/runtime/broker/event-projection.d.ts +8 -1
- package/dist/core/runtime/broker/event-projection.js +25 -1
- package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
- package/dist/core/runtime/broker/frame-dispatch.js +50 -8
- package/dist/core/runtime/broker/message-ledger.d.ts +53 -0
- package/dist/core/runtime/broker/message-ledger.js +143 -0
- package/dist/core/runtime/broker/rebind.js +14 -0
- package/dist/core/runtime/broker-protocol.d.ts +46 -1
- package/dist/core/runtime/broker.js +11 -2
- package/dist/core/runtime/interactive-deliver.d.ts +5 -2
- package/dist/core/runtime/interactive-deliver.js +6 -3
- package/dist/core/runtime/shell-expansion.d.ts +32 -0
- package/dist/core/runtime/shell-expansion.js +102 -0
- package/dist/core/session-model/session-state.d.ts +9 -4
- package/dist/core/session-model/session-state.js +5 -1
- package/dist/daemon/api/handlers/broker-ops.js +8 -0
- package/dist/daemon/api/handlers/crons.js +14 -1
- package/dist/daemon/api/handlers/inbox.js +346 -2
- package/dist/daemon/api/handlers/memory.d.ts +2 -0
- package/dist/daemon/api/handlers/memory.js +48 -0
- package/dist/daemon/api/handlers/messages.js +7 -1
- package/dist/daemon/api/handlers/reviews.js +7 -5
- package/dist/daemon/api/map.js +3 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +71 -3
- package/dist/daemon/crtrd.js +3 -0
- package/dist/daemon/reconcilers/pending-review-submit.d.ts +7 -0
- package/dist/daemon/reconcilers/pending-review-submit.js +35 -0
- package/dist/daemon/review/companion.d.ts +8 -0
- package/dist/daemon/review/companion.js +35 -0
- package/dist/daemon/review/deliver.js +2 -1
- package/dist/daemon/review/finish.d.ts +29 -2
- package/dist/daemon/review/finish.js +75 -2
- package/dist/shared/generated-context.d.ts +3 -4
- package/dist/shared/generated-context.js +24 -6
- package/dist/types.d.ts +0 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/cron-held-settlement.test.ts
|
|
2
|
+
//
|
|
3
|
+
// The owed-gate (exit 75) settlement branch: a scheduled run exiting
|
|
4
|
+
// EX_TEMPFAIL declares "this occurrence is owed but not currently eligible."
|
|
5
|
+
// The row is PARKED (held=1, occurrence not spent) instead of disposed — no
|
|
6
|
+
// failure, no escalation, no delivery, no on-change hash, no one-shot
|
|
7
|
+
// consumption — and a poke (`pokeHeldCrons`) re-dues every held active row
|
|
8
|
+
// now. These pin the branch's truth table: the exact class of scheduling
|
|
9
|
+
// defect that otherwise fails silently weeks later.
|
|
10
|
+
import { after, afterEach, before, test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
16
|
+
import { closeDb, openDb } from '../canvas/db.js';
|
|
17
|
+
import { armCron, cancelCron, getCron, listCronRuns, pokeHeldCrons, setCronState, } from '../canvas/crons.js';
|
|
18
|
+
import { executeCron, runDueCrons } from '../../daemon/cron-run.js';
|
|
19
|
+
const HELD_DELIVERED = 'deferred (exit 75) — held for poke';
|
|
20
|
+
const HELD_FAR_FUTURE = '9999-12-31T23:59:59.999Z';
|
|
21
|
+
let home;
|
|
22
|
+
let realHome;
|
|
23
|
+
function arm(overrides) {
|
|
24
|
+
armCron({
|
|
25
|
+
name: overrides.cron_id,
|
|
26
|
+
created_by: null,
|
|
27
|
+
fire_at: new Date(Date.now() - 1_000).toISOString(),
|
|
28
|
+
recur: null,
|
|
29
|
+
tz: null,
|
|
30
|
+
expires_at: null,
|
|
31
|
+
anchor_node: null,
|
|
32
|
+
cancel_on_wake: false,
|
|
33
|
+
cwd: home,
|
|
34
|
+
env_json: null,
|
|
35
|
+
profile: null,
|
|
36
|
+
scope: 'profile',
|
|
37
|
+
run_timeout_s: 30,
|
|
38
|
+
overlap: 'skip',
|
|
39
|
+
on_output: 'silent',
|
|
40
|
+
sink: '{}',
|
|
41
|
+
tier: 'normal',
|
|
42
|
+
...overrides,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
before(() => {
|
|
46
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-cron-held-'));
|
|
47
|
+
realHome = process.env['CRTR_HOME'];
|
|
48
|
+
process.env['CRTR_HOME'] = home;
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
closeDb();
|
|
52
|
+
});
|
|
53
|
+
after(() => {
|
|
54
|
+
closeDb();
|
|
55
|
+
rmSync(home, { recursive: true, force: true });
|
|
56
|
+
if (realHome === undefined)
|
|
57
|
+
delete process.env['CRTR_HOME'];
|
|
58
|
+
else
|
|
59
|
+
process.env['CRTR_HOME'] = realHome;
|
|
60
|
+
});
|
|
61
|
+
test('a recurring exit-75 run parks: held, backstop untouched, no disposition', async () => {
|
|
62
|
+
// on-change makes the skipped disposition observable: were the branch to
|
|
63
|
+
// fall through, a failed run would escalate (pause + node) and the hash
|
|
64
|
+
// bookkeeping would write last_output_hash.
|
|
65
|
+
arm({
|
|
66
|
+
cron_id: 'gate-recurring',
|
|
67
|
+
command: 'exit 75',
|
|
68
|
+
recur: JSON.stringify({ every: '1h' }),
|
|
69
|
+
on_output: 'on-change',
|
|
70
|
+
});
|
|
71
|
+
const settlements = runDueCrons(Date.now());
|
|
72
|
+
assert.equal(settlements.length, 1, 'the due gated run was admitted');
|
|
73
|
+
// The pre-run advance already wrote the natural next slot — the backstop.
|
|
74
|
+
const backstop = getCron('gate-recurring').fire_at;
|
|
75
|
+
await Promise.all(settlements);
|
|
76
|
+
const row = getCron('gate-recurring');
|
|
77
|
+
assert.equal(row.held, true);
|
|
78
|
+
assert.equal(row.fire_at, backstop, 'settlement must not touch the pre-advanced slot');
|
|
79
|
+
assert.equal(row.state, 'active', 'held is not a failure — no pause, no escalation');
|
|
80
|
+
assert.equal(row.run_state, 'idle');
|
|
81
|
+
assert.equal(row.last_output_hash, null, 'on-change bookkeeping is skipped for a held run');
|
|
82
|
+
const runs = listCronRuns('gate-recurring');
|
|
83
|
+
assert.equal(runs.length, 1);
|
|
84
|
+
assert.equal(runs[0].exit_code, 75);
|
|
85
|
+
assert.equal(runs[0].delivered, HELD_DELIVERED);
|
|
86
|
+
cancelCron('gate-recurring');
|
|
87
|
+
});
|
|
88
|
+
test('a held one-shot without expires is retained at the far-future backstop', async () => {
|
|
89
|
+
arm({ cron_id: 'gate-oneshot', command: 'exit 75' });
|
|
90
|
+
await Promise.all(runDueCrons(Date.now()));
|
|
91
|
+
const row = getCron('gate-oneshot');
|
|
92
|
+
assert.notEqual(row, null, 'the occurrence is not spent — the one-shot is not consumed');
|
|
93
|
+
assert.equal(row.held, true);
|
|
94
|
+
assert.equal(row.fire_at, HELD_FAR_FUTURE, 'poke-only: never due on the clock');
|
|
95
|
+
cancelCron('gate-oneshot');
|
|
96
|
+
});
|
|
97
|
+
test('a held one-shot with --expires waits at expiry, then is deleted unfired', async () => {
|
|
98
|
+
const expiresAt = new Date(Date.now() + 1_000).toISOString();
|
|
99
|
+
arm({ cron_id: 'gate-oneshot-expires', command: 'exit 75', expires_at: expiresAt });
|
|
100
|
+
await Promise.all(runDueCrons(Date.now()));
|
|
101
|
+
const row = getCron('gate-oneshot-expires');
|
|
102
|
+
assert.equal(row.held, true);
|
|
103
|
+
assert.equal(row.fire_at, expiresAt, 'expiry bounds the wait');
|
|
104
|
+
// Past the bound: expiry runs before due processing, so the row is deleted
|
|
105
|
+
// without ever firing blind at its expiry instant.
|
|
106
|
+
const afterExpiry = runDueCrons(new Date(expiresAt).getTime() + 1_000);
|
|
107
|
+
assert.equal(afterExpiry.length, 0, 'the expired row never fired');
|
|
108
|
+
await Promise.all(afterExpiry);
|
|
109
|
+
assert.equal(getCron('gate-oneshot-expires'), null, 'deleted unfired at expiry');
|
|
110
|
+
});
|
|
111
|
+
test('a poke re-dues held active rows, skips paused ones, stamps every row', async () => {
|
|
112
|
+
arm({ cron_id: 'poke-active', command: 'exit 75', recur: JSON.stringify({ every: '1h' }) });
|
|
113
|
+
await Promise.all(runDueCrons(Date.now()));
|
|
114
|
+
arm({ cron_id: 'poke-paused', command: 'exit 75', recur: JSON.stringify({ every: '1h' }) });
|
|
115
|
+
await Promise.all(runDueCrons(Date.now()));
|
|
116
|
+
setCronState('poke-paused', 'paused');
|
|
117
|
+
const pausedBackstop = getCron('poke-paused').fire_at;
|
|
118
|
+
arm({ cron_id: 'poke-ordinary', command: 'exit 0', fire_at: new Date(Date.now() + 3_600_000).toISOString() });
|
|
119
|
+
const nowIso = new Date().toISOString();
|
|
120
|
+
assert.equal(pokeHeldCrons(nowIso), 1, 'only the held ACTIVE row unparks');
|
|
121
|
+
const active = getCron('poke-active');
|
|
122
|
+
assert.equal(active.held, false);
|
|
123
|
+
assert.equal(active.fire_at, nowIso, 'unparked: due now');
|
|
124
|
+
const paused = getCron('poke-paused');
|
|
125
|
+
assert.equal(paused.held, true, 'pause is user-owned — a poke never unparks a paused row');
|
|
126
|
+
assert.equal(paused.fire_at, pausedBackstop);
|
|
127
|
+
for (const id of ['poke-active', 'poke-paused', 'poke-ordinary']) {
|
|
128
|
+
assert.equal(getCron(id).last_poke_at, nowIso, `last_poke_at stamps every row (${id})`);
|
|
129
|
+
cancelCron(id);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
test('a poke never unparks a row whose gate check is in flight', async () => {
|
|
133
|
+
// A held recurring row at its backstop slot: due selection pre-advances
|
|
134
|
+
// fire_at, then leases the run. A poke landing mid-run must not touch the
|
|
135
|
+
// row — unparking would overwrite the advanced slot with `now`, and a
|
|
136
|
+
// PASSING gate (ordinary settlement only clears `held`) would then fire a
|
|
137
|
+
// second time on the next tick.
|
|
138
|
+
arm({ cron_id: 'poke-inflight', command: 'sleep 0.3; exit 0', recur: JSON.stringify({ every: '1h' }) });
|
|
139
|
+
openDb().prepare('UPDATE crons SET held = 1 WHERE cron_id = ?').run('poke-inflight');
|
|
140
|
+
const settlements = runDueCrons(Date.now());
|
|
141
|
+
assert.equal(settlements.length, 1, 'the held row past its backstop is due');
|
|
142
|
+
const advanced = getCron('poke-inflight').fire_at;
|
|
143
|
+
await delay(50);
|
|
144
|
+
const nowIso = new Date().toISOString();
|
|
145
|
+
assert.equal(pokeHeldCrons(nowIso), 0, 'a leased row is stamped, never unparked');
|
|
146
|
+
await Promise.all(settlements);
|
|
147
|
+
const row = getCron('poke-inflight');
|
|
148
|
+
assert.equal(row.held, false, 'the passing gate resolved — ordinary settlement clears held');
|
|
149
|
+
assert.equal(row.fire_at, advanced, 'the pre-advanced slot survives the poke — no double fire');
|
|
150
|
+
assert.equal(row.last_poke_at, nowIso);
|
|
151
|
+
cancelCron('poke-inflight');
|
|
152
|
+
});
|
|
153
|
+
test('a row paused mid-run parks on exit 75 even when a poke stamped it', async () => {
|
|
154
|
+
// Pause lands while the gate check is in flight, then a poke stamps every
|
|
155
|
+
// row (skipping the paused row's unpark). The settling 75 must PARK, not
|
|
156
|
+
// re-due: a re-due would leave a past-due unheld row that `cron resume`
|
|
157
|
+
// fires blind even though its gate never passed.
|
|
158
|
+
arm({ cron_id: 'paused-midrun', command: 'sleep 0.3; exit 75', recur: JSON.stringify({ every: '1h' }) });
|
|
159
|
+
const settlements = runDueCrons(Date.now());
|
|
160
|
+
assert.equal(settlements.length, 1);
|
|
161
|
+
const backstop = getCron('paused-midrun').fire_at;
|
|
162
|
+
await delay(50);
|
|
163
|
+
setCronState('paused-midrun', 'paused');
|
|
164
|
+
pokeHeldCrons(new Date().toISOString());
|
|
165
|
+
await Promise.all(settlements);
|
|
166
|
+
const row = getCron('paused-midrun');
|
|
167
|
+
assert.equal(row.state, 'paused');
|
|
168
|
+
assert.equal(row.held, true, 'paused mid-run parks — resume re-checks the gate, never fires it blind');
|
|
169
|
+
assert.equal(row.fire_at, backstop, 'the pre-advanced backstop survives');
|
|
170
|
+
cancelCron('paused-midrun');
|
|
171
|
+
});
|
|
172
|
+
test('a poke landing mid-run re-dues the settling row instead of parking it (F2)', async () => {
|
|
173
|
+
arm({ cron_id: 'gate-midrun-poke', command: 'sleep 0.3; exit 75' });
|
|
174
|
+
const settlement = executeCron(getCron('gate-midrun-poke'), { outOfBand: false });
|
|
175
|
+
await delay(50);
|
|
176
|
+
// The gate check is in flight: the poke's WHERE held=1 misses it.
|
|
177
|
+
assert.equal(pokeHeldCrons(new Date().toISOString()), 0);
|
|
178
|
+
const record = await settlement;
|
|
179
|
+
assert.equal(record?.exit_code, 75);
|
|
180
|
+
const row = getCron('gate-midrun-poke');
|
|
181
|
+
assert.equal(row.held, false, 're-dued, not parked — the poke is not lost');
|
|
182
|
+
assert.notEqual(row.fire_at, HELD_FAR_FUTURE);
|
|
183
|
+
assert.ok(Math.abs(new Date(row.fire_at).getTime() - Date.now()) < 5_000, `re-dued to settle time, got ${row.fire_at}`);
|
|
184
|
+
cancelCron('gate-midrun-poke');
|
|
185
|
+
});
|
|
186
|
+
test('an out-of-band exit 75 reports the result and never touches scheduling state (F3)', async () => {
|
|
187
|
+
const futureFire = new Date(Date.now() + 3_600_000).toISOString();
|
|
188
|
+
arm({ cron_id: 'oob-75', command: 'exit 75', recur: JSON.stringify({ every: '1h' }), fire_at: futureFire });
|
|
189
|
+
const record = await executeCron(getCron('oob-75'), { outOfBand: true });
|
|
190
|
+
assert.equal(record?.exit_code, 75);
|
|
191
|
+
assert.notEqual(record?.delivered, HELD_DELIVERED, 'a manual verification is not an owed gate');
|
|
192
|
+
const row = getCron('oob-75');
|
|
193
|
+
assert.equal(row.held, false);
|
|
194
|
+
assert.equal(row.fire_at, futureFire);
|
|
195
|
+
cancelCron('oob-75');
|
|
196
|
+
});
|
|
197
|
+
test('a timeout kill is a real failure even when the command would exit 75 (F3)', async () => {
|
|
198
|
+
arm({
|
|
199
|
+
cron_id: 'timeout-75',
|
|
200
|
+
command: 'sleep 3; exit 75',
|
|
201
|
+
recur: JSON.stringify({ every: '1h' }),
|
|
202
|
+
run_timeout_s: 1,
|
|
203
|
+
});
|
|
204
|
+
await Promise.all(runDueCrons(Date.now()));
|
|
205
|
+
const row = getCron('timeout-75');
|
|
206
|
+
assert.equal(row.held, false, 'a timed-out run takes the failure path, never the held branch');
|
|
207
|
+
const runs = listCronRuns('timeout-75');
|
|
208
|
+
assert.equal(runs[0].exit_code, -1);
|
|
209
|
+
assert.notEqual(runs[0].delivered, HELD_DELIVERED);
|
|
210
|
+
cancelCron('timeout-75');
|
|
211
|
+
});
|
|
212
|
+
test('an ordinary scheduled settlement clears held (the backstop run resolves the gate)', async () => {
|
|
213
|
+
arm({ cron_id: 'clear-held', command: 'exit 0', recur: JSON.stringify({ every: '1h' }) });
|
|
214
|
+
// Simulate a parked row whose backstop slot has arrived: held rows are
|
|
215
|
+
// deliberately NOT filtered out of the due query.
|
|
216
|
+
openDb().prepare('UPDATE crons SET held = 1 WHERE cron_id = ?').run('clear-held');
|
|
217
|
+
const settlements = runDueCrons(Date.now());
|
|
218
|
+
assert.equal(settlements.length, 1, 'a held row past its backstop is due');
|
|
219
|
+
await Promise.all(settlements);
|
|
220
|
+
assert.equal(getCron('clear-held').held, false, 'the resolved gate unparks the row');
|
|
221
|
+
cancelCron('clear-held');
|
|
222
|
+
});
|
|
@@ -103,9 +103,8 @@ function cleanBaseEnv() {
|
|
|
103
103
|
e[k] = v;
|
|
104
104
|
for (const k of CANVAS_ENV_KEYS)
|
|
105
105
|
delete e[k];
|
|
106
|
-
// Contain per-invocation
|
|
106
|
+
// Contain per-invocation scope-init + auto-update side effects (they write to
|
|
107
107
|
// ~/.crouter / ~/.claude / ~/.pi, NOT under CRTR_HOME — HOME is contained too).
|
|
108
|
-
e['CRTR_NO_BOOTSTRAP'] = '1';
|
|
109
108
|
e['CRTR_NO_AUTO_UPDATE'] = '1';
|
|
110
109
|
e['CRTR_NO_EXPORTS'] = '1';
|
|
111
110
|
e['CRTR_NO_AUTO_INIT'] = '1';
|
|
@@ -30,6 +30,7 @@ import { join, dirname } from 'node:path';
|
|
|
30
30
|
import { fileURLToPath } from 'node:url';
|
|
31
31
|
import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, } from '../../command-plugins/discovery.js';
|
|
32
32
|
import { composeExternalSubtrees, adaptPluginContributions } from '../../command-plugins/compose.js';
|
|
33
|
+
import { collectHelpAddenda } from '../../command-plugins/help-addenda.js';
|
|
33
34
|
import { resolveCommandRegistry } from '../../command-manifests/registry.js';
|
|
34
35
|
import { defineRoot } from '../../command.js';
|
|
35
36
|
import { renderRoot, renderBranch, renderLeafArgv } from '../../help.js';
|
|
@@ -379,7 +380,7 @@ describe('end-to-end CLI invocation', () => {
|
|
|
379
380
|
resetScopeCache();
|
|
380
381
|
return execFileSync(process.execPath, [cli, ...args], {
|
|
381
382
|
cwd: emptyStart,
|
|
382
|
-
env: { ...process.env, HOME: home,
|
|
383
|
+
env: { ...process.env, HOME: home, CRTR_FRONT_DOOR: '' },
|
|
383
384
|
encoding: 'utf8',
|
|
384
385
|
});
|
|
385
386
|
};
|
|
@@ -511,6 +512,92 @@ describe('typed validation issues', () => {
|
|
|
511
512
|
});
|
|
512
513
|
});
|
|
513
514
|
// ---------------------------------------------------------------------------
|
|
515
|
+
// Plugin help addenda — the append-only product-guidance seam. An addendum
|
|
516
|
+
// that quietly stops rendering is exactly the silent-absence class: pin that a
|
|
517
|
+
// valid entry survives validation and is collected attributed, and that the
|
|
518
|
+
// STRICT gates (install/bundle, which pass coreCommandPaths) reject a key
|
|
519
|
+
// naming no core command path while the tolerant compose path still mounts the
|
|
520
|
+
// plugin's tree. The help-render lookup receives the gate's exact inputs, so a
|
|
521
|
+
// map rejected at ingress renders nothing — render never disagrees with the
|
|
522
|
+
// install report.
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
describe('plugin help addenda', () => {
|
|
525
|
+
const gate = (paths) => ({
|
|
526
|
+
reservedCoreNames: RESERVED,
|
|
527
|
+
coreCommandPaths: new Set(paths),
|
|
528
|
+
});
|
|
529
|
+
test('a helpAddenda entry survives validation and is collected attributed to its plugin', () => {
|
|
530
|
+
const m = commandsJson();
|
|
531
|
+
m['helpAddenda'] = { cron: 'Gate on the device: exit 75 parks the occurrence.' };
|
|
532
|
+
installPlugin(userRoot, 'northlight-fixture', { manifest: m });
|
|
533
|
+
resetScopeCache();
|
|
534
|
+
const p = listInstalledPluginsInRoot('user', userRoot).find((x) => x.name === 'northlight-fixture');
|
|
535
|
+
const v = validatePluginCommands(p, RESERVED);
|
|
536
|
+
assert.equal(v.issues.length, 0, JSON.stringify(v.issues));
|
|
537
|
+
assert.deepEqual(v.manifest?.helpAddenda, { cron: 'Gate on the device: exit 75 parks the occurrence.' });
|
|
538
|
+
// The help-render lookup: attributed, and empty for an unaddended path.
|
|
539
|
+
assert.deepEqual(collectHelpAddenda('cron', gate(['cron', 'node']), emptyStart), [
|
|
540
|
+
{ plugin: 'northlight-fixture', text: 'Gate on the device: exit 75 parks the occurrence.' },
|
|
541
|
+
]);
|
|
542
|
+
assert.deepEqual(collectHelpAddenda('node', gate(['cron', 'node']), emptyStart), []);
|
|
543
|
+
});
|
|
544
|
+
test('a key naming no core command path fails the strict gate and renders nothing, even beside a valid key', () => {
|
|
545
|
+
const m = commandsJson();
|
|
546
|
+
m['helpAddenda'] = {
|
|
547
|
+
cron: 'a valid target beside the typo',
|
|
548
|
+
'cronn add': 'a typo that must fail at install, never silently at render',
|
|
549
|
+
};
|
|
550
|
+
installPlugin(userRoot, 'p', { manifest: m });
|
|
551
|
+
resetScopeCache();
|
|
552
|
+
const p = listInstalledPluginsInRoot('user', userRoot).find((x) => x.name === 'p');
|
|
553
|
+
// Strict (install/bundle/doctor): the typo is a typed issue, zero contributions.
|
|
554
|
+
const strict = validatePluginCommands(p, RESERVED, new Set(['cron', 'cron add']));
|
|
555
|
+
assert.ok(strict.issues.some((i) => i.code === 'command_help_addendum_invalid'), JSON.stringify(strict.issues));
|
|
556
|
+
assert.equal(strict.contributions.length, 0);
|
|
557
|
+
assert.equal(strict.manifest, undefined);
|
|
558
|
+
// Tolerant (runtime compose): an already-installed plugin's typo'd addendum
|
|
559
|
+
// must not drop its whole command tree.
|
|
560
|
+
const tolerant = validatePluginCommands(p, RESERVED);
|
|
561
|
+
assert.equal(tolerant.issues.length, 0, JSON.stringify(tolerant.issues));
|
|
562
|
+
assert.equal(tolerant.contributions.length, 1);
|
|
563
|
+
// The render lookup reproduces the GATE verdict, not the compose one: the
|
|
564
|
+
// rejected map contributes nothing — including its valid key — so render
|
|
565
|
+
// can never show what the install report said was rejected.
|
|
566
|
+
assert.deepEqual(collectHelpAddenda('cron', gate(['cron', 'cron add']), emptyStart), []);
|
|
567
|
+
});
|
|
568
|
+
test('an installed addendum renders through the real help path as an attributed block', async () => {
|
|
569
|
+
const m = commandsJson();
|
|
570
|
+
m['helpAddenda'] = { cron: 'ADDENDUM RENDERED THROUGH THE REAL HELP PATH' };
|
|
571
|
+
installPlugin(userRoot, 'northlight-fixture', { manifest: m });
|
|
572
|
+
resetScopeCache();
|
|
573
|
+
// Pin the durable wiring, not the lookup: runCli → renderNodeWithAddenda →
|
|
574
|
+
// collectHelpAddenda → <plugin-help> serialization. This is the exact
|
|
575
|
+
// silent-absence class — the dynamic import or the append could regress
|
|
576
|
+
// with every lookup-level assertion still green.
|
|
577
|
+
const { resolveRoot } = await import('../../../build-root.js');
|
|
578
|
+
const { runCli } = await import('../../command.js');
|
|
579
|
+
const root = await resolveRoot('cron');
|
|
580
|
+
const chunks = [];
|
|
581
|
+
const realWrite = process.stdout.write.bind(process.stdout);
|
|
582
|
+
const realCwd = process.cwd();
|
|
583
|
+
process.chdir(emptyStart); // startDir with no project scope — user scope is the fixture HOME
|
|
584
|
+
process.stdout.write = ((chunk) => {
|
|
585
|
+
chunks.push(String(chunk));
|
|
586
|
+
return true;
|
|
587
|
+
});
|
|
588
|
+
try {
|
|
589
|
+
await runCli(root, ['node', 'crtr', 'cron', '-h']);
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
process.stdout.write = realWrite;
|
|
593
|
+
process.chdir(realCwd);
|
|
594
|
+
}
|
|
595
|
+
const out = chunks.join('');
|
|
596
|
+
assert.ok(out.includes('<plugin-help plugin="northlight-fixture">'), out.slice(-400));
|
|
597
|
+
assert.ok(out.includes('ADDENDUM RENDERED THROUGH THE REAL HELP PATH'), out.slice(-400));
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
514
601
|
// Lifecycle surfaces — the validator's verdict must reach the caller WITHOUT
|
|
515
602
|
// executing the plugin binary, and a cross-plugin top-level collision must not
|
|
516
603
|
// read as a pass on any claimant.
|
|
@@ -98,11 +98,13 @@ test('a welcome is a COMPLETE catch-up: a re-welcome drops a dead broker s level
|
|
|
98
98
|
const s0 = fold(initialSessionState(), [
|
|
99
99
|
welcome(),
|
|
100
100
|
{ type: 'extension_ui_request', id: 'u1', method: 'setStatus', statusKey: 'gone', statusText: 'stale' },
|
|
101
|
-
{ type: 'queue_update', steering: ['one'], followUp: [] },
|
|
101
|
+
{ type: 'queue_update', steering: ['one', 'two'], followUp: [], steeringIds: ['id-1'] },
|
|
102
102
|
]);
|
|
103
103
|
assert.equal(s0.display.statuses['gone'], 'stale');
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// Broker-enriched ids zip positionally; a position without one (older broker,
|
|
105
|
+
// short array) still renders as a bare-text entry.
|
|
106
|
+
assert.deepEqual(s0.queued, [{ id: 'id-1', text: 'one' }, { text: 'two' }]);
|
|
107
|
+
assert.equal(s0.engine?.pendingMessageCount, 2);
|
|
106
108
|
// A fresh broker (post-revive) welcomes with its OWN levels and an empty queue,
|
|
107
109
|
// and emits no queue_update to overwrite the panel — so the welcome must.
|
|
108
110
|
const s1 = reduce(s0, welcome());
|
package/dist/core/bootstrap.d.ts
CHANGED
|
@@ -1,5 +1 @@
|
|
|
1
|
-
export declare const OFFICIAL_MARKETPLACE_NAME = "crouter-official-marketplace";
|
|
2
|
-
export declare const OFFICIAL_MARKETPLACE_URL = "https://github.com/crouton-labs/crouter-official-marketplace.git";
|
|
3
|
-
export declare const OFFICIAL_MARKETPLACE_REF = "main";
|
|
4
|
-
export declare function ensureOfficialMarketplace(argv: string[]): void;
|
|
5
1
|
export declare function ensureProjectScope(argv: string[]): void;
|
package/dist/core/bootstrap.js
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { findProjectScopeRoot, resetScopeCache, userScopeRoot } from './scope.js';
|
|
4
|
-
import {
|
|
5
|
-
import { readConfig, readState, updateConfig, updateState, ensureScopeInitialized } from './config.js';
|
|
6
|
-
import { clone } from './git.js';
|
|
7
|
-
import { readMarketplaceManifest } from './manifest.js';
|
|
4
|
+
import { ensureScopeInitialized } from './config.js';
|
|
8
5
|
import { CRTR_DIR_NAME } from '../types.js';
|
|
9
|
-
export const OFFICIAL_MARKETPLACE_NAME = 'crouter-official-marketplace';
|
|
10
|
-
export const OFFICIAL_MARKETPLACE_URL = 'https://github.com/crouton-labs/crouter-official-marketplace.git';
|
|
11
|
-
export const OFFICIAL_MARKETPLACE_REF = 'main';
|
|
12
6
|
const SKIP_SUBCOMMANDS = new Set([
|
|
13
7
|
'help',
|
|
14
8
|
'--help',
|
|
@@ -22,54 +16,6 @@ function shouldSkipForArgv(argv) {
|
|
|
22
16
|
return true;
|
|
23
17
|
return SKIP_SUBCOMMANDS.has(sub);
|
|
24
18
|
}
|
|
25
|
-
export function ensureOfficialMarketplace(argv) {
|
|
26
|
-
try {
|
|
27
|
-
if (process.env.CRTR_NO_BOOTSTRAP === '1')
|
|
28
|
-
return;
|
|
29
|
-
if (shouldSkipForArgv(argv))
|
|
30
|
-
return;
|
|
31
|
-
const state = readState('user');
|
|
32
|
-
if (state.bootstrap_done === true)
|
|
33
|
-
return;
|
|
34
|
-
const cfg = readConfig('user');
|
|
35
|
-
if (cfg.marketplaces[OFFICIAL_MARKETPLACE_NAME] !== undefined) {
|
|
36
|
-
updateState('user', (s) => {
|
|
37
|
-
s.bootstrap_done = true;
|
|
38
|
-
});
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
const root = userScopeRoot();
|
|
42
|
-
ensureScopeInitialized('user', root);
|
|
43
|
-
const mktsDir = join(root, 'marketplaces');
|
|
44
|
-
ensureDir(mktsDir);
|
|
45
|
-
const dest = join(mktsDir, OFFICIAL_MARKETPLACE_NAME);
|
|
46
|
-
if (pathExists(dest)) {
|
|
47
|
-
removePath(dest);
|
|
48
|
-
}
|
|
49
|
-
clone(OFFICIAL_MARKETPLACE_URL, dest, { depth: 1, ref: OFFICIAL_MARKETPLACE_REF });
|
|
50
|
-
const manifest = readMarketplaceManifest(dest);
|
|
51
|
-
if (manifest === null) {
|
|
52
|
-
removePath(dest);
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
updateConfig('user', (c) => {
|
|
56
|
-
c.marketplaces[OFFICIAL_MARKETPLACE_NAME] = {
|
|
57
|
-
url: OFFICIAL_MARKETPLACE_URL,
|
|
58
|
-
ref: OFFICIAL_MARKETPLACE_REF,
|
|
59
|
-
installed_at: nowIso(),
|
|
60
|
-
};
|
|
61
|
-
});
|
|
62
|
-
updateState('user', (s) => {
|
|
63
|
-
s.bootstrap_done = true;
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
catch (e) {
|
|
67
|
-
if (process.env.CRTR_DEBUG === '1') {
|
|
68
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
69
|
-
process.stderr.write(`crtr: bootstrap error: ${msg}\n`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
19
|
export function ensureProjectScope(argv) {
|
|
74
20
|
try {
|
|
75
21
|
if (process.env.CRTR_NO_AUTO_INIT === '1')
|
|
@@ -26,6 +26,19 @@ export interface Cron {
|
|
|
26
26
|
sink: string;
|
|
27
27
|
tier: string;
|
|
28
28
|
last_output_hash: string | null;
|
|
29
|
+
/** True while the row is PARKED by the exit-75 owed-gate disposition: the
|
|
30
|
+
* last scheduled run declared "owed but not currently eligible", so the
|
|
31
|
+
* occurrence was not spent. A poke re-dues the row now; otherwise a
|
|
32
|
+
* recurring row re-checks at its already-advanced natural slot and a held
|
|
33
|
+
* one-shot waits at its backstop `fire_at` (expires_at, else far future).
|
|
34
|
+
* Deliberately NOT part of the due query — a held row is non-due by
|
|
35
|
+
* `fire_at`, not by a new predicate. */
|
|
36
|
+
held: boolean;
|
|
37
|
+
/** UTC ISO of the last `POST /v1/crons/poke` receipt, written to EVERY row
|
|
38
|
+
* on poke. Settlement compares a 75-exiting run's start against it: a poke
|
|
39
|
+
* that arrived mid-run re-dues instead of parking (the in-flight gate check
|
|
40
|
+
* could not have seen the eligibility change). */
|
|
41
|
+
last_poke_at: string | null;
|
|
29
42
|
state: CronState;
|
|
30
43
|
run_state: CronRunState;
|
|
31
44
|
run_pid: number | null;
|
|
@@ -194,9 +207,48 @@ export declare function dueClockCrons(nowIso: string): Cron[];
|
|
|
194
207
|
* `outOfBand` stamps the lease as a `cron run` (manual verification) so the
|
|
195
208
|
* overlap pass leaves it alone — see `dueOverlapCrons`. */
|
|
196
209
|
export declare function acquireCronRunLease(cron_id: string, leaseOwner: string, pid: number, startedAtIso: string, outOfBand: boolean): boolean;
|
|
210
|
+
/** What a settling run does with the row's `held` state, applied in the SAME
|
|
211
|
+
* UPDATE that releases the lease — one write boundary, so no tick can observe
|
|
212
|
+
* `run_state='idle'` with the held transition not yet applied.
|
|
213
|
+
* clear — an ordinary scheduled disposition settlement (success, real
|
|
214
|
+
* failure): the gate resolved, so a parked row unparks.
|
|
215
|
+
* park — the exit-75 owed-gate disposition: held=1. A one-shot also moves
|
|
216
|
+
* `fire_at` to its backstop (`fireAt`); a recurring row keeps its
|
|
217
|
+
* pre-run-advanced natural slot.
|
|
218
|
+
* redue — a poke arrived while this gate check was in flight (start <
|
|
219
|
+
* last_poke_at): fire_at=now, held=0 — re-check next tick.
|
|
220
|
+
* Omitted entirely = leave `held` untouched (a replaced run, an out-of-band
|
|
221
|
+
* run, an unobserved crashed run — the gate is still unresolved). */
|
|
222
|
+
export type HeldSettle = {
|
|
223
|
+
kind: 'clear';
|
|
224
|
+
} | {
|
|
225
|
+
kind: 'park';
|
|
226
|
+
fireAt?: string;
|
|
227
|
+
} | {
|
|
228
|
+
kind: 'redue';
|
|
229
|
+
nowIso: string;
|
|
230
|
+
};
|
|
197
231
|
/** Release the run lease back to idle (run settled, or stale-lease recovery
|
|
198
|
-
* for a dead pid)
|
|
199
|
-
|
|
232
|
+
* for a dead pid), applying the settlement's `held` disposition in the same
|
|
233
|
+
* statement. */
|
|
234
|
+
export declare function releaseCronRunLease(cron_id: string, held?: HeldSettle): void;
|
|
235
|
+
/** The daemon-level eligibility poke (`POST /v1/crons/poke`): "something
|
|
236
|
+
* changed; re-check now". One write boundary, two statements:
|
|
237
|
+
* 1. stamp `last_poke_at` on EVERY row — this is what reaches a gate check
|
|
238
|
+
* in flight right now, which statement 2 deliberately skips: its exit-75
|
|
239
|
+
* settlement sees the stamp and re-dues itself (the F2 branch in
|
|
240
|
+
* cron-run.ts);
|
|
241
|
+
* 2. re-due every held active IDLE row now (fire_at=now, held=0). A leased
|
|
242
|
+
* row is excluded: its running gate check settles against the stamp —
|
|
243
|
+
* 75 re-dues, and success means the gate resolved and the occurrence
|
|
244
|
+
* fired, poke satisfied. Unparking mid-run would overwrite the row's
|
|
245
|
+
* pre-advanced backstop slot with `now`, so a PASSING gate would fire a
|
|
246
|
+
* second time on the next tick.
|
|
247
|
+
* Paused rows are not unparked — pause is user-owned and freezes everything;
|
|
248
|
+
* a paused held row keeps its state and unparks via a later poke/backstop
|
|
249
|
+
* after resume (it still receives the stamp, harmlessly). Returns the
|
|
250
|
+
* unparked count. Idempotent and free when nothing is held. */
|
|
251
|
+
export declare function pokeHeldCrons(nowIso: string): number;
|
|
200
252
|
/** Recurring crons whose NEXT fire has arrived while their previous run is
|
|
201
253
|
* still leased — the overlap-policy pass. One-shots never appear here (a
|
|
202
254
|
* one-shot has exactly one fire, already consumed by its running lease).
|
|
@@ -46,6 +46,8 @@ function cronFrom(r) {
|
|
|
46
46
|
sink: r['sink'],
|
|
47
47
|
tier: r['tier'],
|
|
48
48
|
last_output_hash: r['last_output_hash'] ?? null,
|
|
49
|
+
held: Number(r['held']) !== 0,
|
|
50
|
+
last_poke_at: r['last_poke_at'] ?? null,
|
|
49
51
|
state: r['state'],
|
|
50
52
|
run_state: r['run_state'],
|
|
51
53
|
run_pid: r['run_pid'] ?? null,
|
|
@@ -286,14 +288,56 @@ export function acquireCronRunLease(cron_id, leaseOwner, pid, startedAtIso, outO
|
|
|
286
288
|
return Number(res.changes) > 0;
|
|
287
289
|
}
|
|
288
290
|
/** Release the run lease back to idle (run settled, or stale-lease recovery
|
|
289
|
-
* for a dead pid)
|
|
290
|
-
|
|
291
|
+
* for a dead pid), applying the settlement's `held` disposition in the same
|
|
292
|
+
* statement. */
|
|
293
|
+
export function releaseCronRunLease(cron_id, held) {
|
|
294
|
+
let heldSql = '';
|
|
295
|
+
const heldParams = [];
|
|
296
|
+
if (held !== undefined) {
|
|
297
|
+
if (held.kind === 'clear') {
|
|
298
|
+
heldSql = ', held = 0';
|
|
299
|
+
}
|
|
300
|
+
else if (held.kind === 'park') {
|
|
301
|
+
heldSql = held.fireAt !== undefined ? ', held = 1, fire_at = ?' : ', held = 1';
|
|
302
|
+
if (held.fireAt !== undefined)
|
|
303
|
+
heldParams.push(held.fireAt);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
heldSql = ', held = 0, fire_at = ?';
|
|
307
|
+
heldParams.push(held.nowIso);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
291
310
|
openDb()
|
|
292
311
|
.prepare(`UPDATE crons
|
|
293
312
|
SET run_state = 'idle', run_started_at = NULL, run_pid = NULL, run_pid_identity = NULL,
|
|
294
|
-
run_lease_owner = NULL, run_out_of_band = 0, updated =
|
|
313
|
+
run_lease_owner = NULL, run_out_of_band = 0, updated = ?${heldSql}
|
|
295
314
|
WHERE cron_id = ?`)
|
|
296
|
-
.run(new Date().toISOString(), cron_id);
|
|
315
|
+
.run(new Date().toISOString(), ...heldParams, cron_id);
|
|
316
|
+
}
|
|
317
|
+
/** The daemon-level eligibility poke (`POST /v1/crons/poke`): "something
|
|
318
|
+
* changed; re-check now". One write boundary, two statements:
|
|
319
|
+
* 1. stamp `last_poke_at` on EVERY row — this is what reaches a gate check
|
|
320
|
+
* in flight right now, which statement 2 deliberately skips: its exit-75
|
|
321
|
+
* settlement sees the stamp and re-dues itself (the F2 branch in
|
|
322
|
+
* cron-run.ts);
|
|
323
|
+
* 2. re-due every held active IDLE row now (fire_at=now, held=0). A leased
|
|
324
|
+
* row is excluded: its running gate check settles against the stamp —
|
|
325
|
+
* 75 re-dues, and success means the gate resolved and the occurrence
|
|
326
|
+
* fired, poke satisfied. Unparking mid-run would overwrite the row's
|
|
327
|
+
* pre-advanced backstop slot with `now`, so a PASSING gate would fire a
|
|
328
|
+
* second time on the next tick.
|
|
329
|
+
* Paused rows are not unparked — pause is user-owned and freezes everything;
|
|
330
|
+
* a paused held row keeps its state and unparks via a later poke/backstop
|
|
331
|
+
* after resume (it still receives the stamp, harmlessly). Returns the
|
|
332
|
+
* unparked count. Idempotent and free when nothing is held. */
|
|
333
|
+
export function pokeHeldCrons(nowIso) {
|
|
334
|
+
return withCanvasWrite((db) => {
|
|
335
|
+
db.prepare('UPDATE crons SET last_poke_at = ?').run(nowIso);
|
|
336
|
+
const res = db
|
|
337
|
+
.prepare("UPDATE crons SET fire_at = ?, held = 0, updated = ? WHERE held = 1 AND state = 'active' AND run_state = 'idle'")
|
|
338
|
+
.run(nowIso, nowIso);
|
|
339
|
+
return Number(res.changes);
|
|
340
|
+
});
|
|
297
341
|
}
|
|
298
342
|
/** Recurring crons whose NEXT fire has arrived while their previous run is
|
|
299
343
|
* still leased — the overlap-policy pass. One-shots never appear here (a
|
package/dist/core/canvas/db.js
CHANGED
|
@@ -1131,6 +1131,27 @@ ALTER TABLE review_comments
|
|
|
1131
1131
|
function dropConsultOutbox(db) {
|
|
1132
1132
|
db.exec('DROP TABLE IF EXISTS consult_outbox');
|
|
1133
1133
|
}
|
|
1134
|
+
/** v31 — a human's submit intent recorded on a still-open review, so the
|
|
1135
|
+
* approval can wait for the companion to stop working. Deliberately a column
|
|
1136
|
+
* and not a new `state` value: through the wait the review IS open — comments
|
|
1137
|
+
* still land, ranges still remap, and cancellation still applies. */
|
|
1138
|
+
function addReviewSubmitRequested(db) {
|
|
1139
|
+
db.exec('ALTER TABLE reviews ADD COLUMN submit_requested_at TEXT');
|
|
1140
|
+
}
|
|
1141
|
+
/** v32 — held crons (the exit-75 owed-gate disposition). `held` marks a row
|
|
1142
|
+
* whose last scheduled run exited 75 ("owed but not currently eligible"):
|
|
1143
|
+
* the occurrence was not spent and the row waits for an eligibility poke or
|
|
1144
|
+
* its natural backstop slot. `last_poke_at` records the most recent
|
|
1145
|
+
* `POST /v1/crons/poke` receipt on EVERY row, closing the race where a poke
|
|
1146
|
+
* lands while a gate check is in flight (the settling run compares its start
|
|
1147
|
+
* against it and re-dues instead of parking). Deliberately not part of the
|
|
1148
|
+
* due query: a held row is non-due by `fire_at`, never by a new predicate. */
|
|
1149
|
+
function addCronHeldColumns(db) {
|
|
1150
|
+
db.exec(`
|
|
1151
|
+
ALTER TABLE crons ADD COLUMN held INTEGER NOT NULL DEFAULT 0;
|
|
1152
|
+
ALTER TABLE crons ADD COLUMN last_poke_at TEXT;
|
|
1153
|
+
`);
|
|
1154
|
+
}
|
|
1134
1155
|
/** The ordered migration list. Index `i` is migration version `i + 1`; the db's
|
|
1135
1156
|
* `user_version` tracks how many have been applied. Append only. */
|
|
1136
1157
|
export const MIGRATIONS = [
|
|
@@ -1166,6 +1187,8 @@ export const MIGRATIONS = [
|
|
|
1166
1187
|
/* v28 */ addReviewCommentTables,
|
|
1167
1188
|
/* v29 */ addReviewCommentAnchorState,
|
|
1168
1189
|
/* v30 */ dropConsultOutbox,
|
|
1190
|
+
/* v31 */ addReviewSubmitRequested,
|
|
1191
|
+
/* v32 */ addCronHeldColumns,
|
|
1169
1192
|
];
|
|
1170
1193
|
/** Migration indexes that manage their OWN transaction and therefore must not
|
|
1171
1194
|
* be wrapped by `migrate()` — `node:sqlite` rejects a nested BEGIN. Index 3
|
|
@@ -5,6 +5,10 @@ export interface ValidatedCommandManifest {
|
|
|
5
5
|
timeouts?: ManifestTimeouts;
|
|
6
6
|
/** Top-level branches, each ready to mount at parent []. */
|
|
7
7
|
roots: DeclBranch<DeclLeaf>[];
|
|
8
|
+
/** Core command path (space-joined, e.g. "cron add") → attributed addendum
|
|
9
|
+
* text appended beneath that core command's help. Append-only product
|
|
10
|
+
* guidance — a plugin can never alter core contract text. */
|
|
11
|
+
helpAddenda?: Readonly<Record<string, string>>;
|
|
8
12
|
}
|
|
9
13
|
export interface CommandManifestValidation {
|
|
10
14
|
/** The validated, materialized manifest (present only if all issues are fixed). */
|
|
@@ -21,4 +25,11 @@ export interface CommandManifestValidation {
|
|
|
21
25
|
export declare function validateCommandManifest(raw: unknown, options: {
|
|
22
26
|
transport: TransportKind;
|
|
23
27
|
reservedCoreNames: ReadonlySet<string>;
|
|
28
|
+
/** Every core command path (space-joined). Supplied at the strict gates
|
|
29
|
+
* (install, bundle parse, doctor/inspect reports) so a helpAddenda key
|
|
30
|
+
* naming no core command fails there — and by the help-render lookup
|
|
31
|
+
* (`collectHelpAddenda`), which reproduces the gate's verdict so render
|
|
32
|
+
* and ingress report can never disagree. Omitted only on the tolerant
|
|
33
|
+
* compose path, where mounting ignores addenda entirely. */
|
|
34
|
+
coreCommandPaths?: ReadonlySet<string>;
|
|
24
35
|
}): CommandManifestValidation;
|