@north-light/crouter 0.3.197 → 0.3.198
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 +2 -2
- package/dist/api/dto/inbox.d.ts +46 -0
- package/dist/builtin-memory/00-runtime-base.md +3 -3
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +1 -1
- package/dist/clients/attach/render/chat-view.d.ts +3 -8
- package/dist/clients/attach/render/chat-view.js +3 -24
- package/dist/clients/attach/render/markdown-source.js +0 -6
- package/dist/clients/attach/render/tool-calls.js +16 -2
- package/dist/clients/attach/viewer.js +613 -602
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +2 -2
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +11 -4
- package/dist/clients/inbox/controller.js +4 -4
- package/dist/clients/inbox/page-adapter.d.ts +2 -1
- package/dist/clients/inbox/tui/render.js +46 -25
- package/dist/clients/inbox/tui/slots.d.ts +1 -3
- package/dist/clients/inbox/tui/slots.js +8 -37
- package/dist/clients/inbox/tui/types.d.ts +4 -3
- package/dist/commands/__tests__/human.test.js +160 -23
- package/dist/commands/canvas-browse.js +1 -1
- package/dist/commands/human/doc.js +21 -74
- package/dist/commands/human/html.d.ts +2 -0
- package/dist/commands/human/html.js +81 -0
- package/dist/commands/human/prompts.d.ts +5 -4
- package/dist/commands/human/prompts.js +81 -153
- package/dist/commands/human/shared.d.ts +5 -6
- package/dist/commands/human/shared.js +13 -61
- package/dist/commands/human.js +7 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -4
- package/dist/core/__tests__/dead-node-policy-table.test.js +20 -36
- package/dist/core/__tests__/human-deliver.test.js +28 -37
- package/dist/core/__tests__/seam/broker-crash-teardown.test.js +13 -11
- package/dist/core/__tests__/seam/dormancy-release.test.js +63 -4
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +3 -13
- package/dist/core/canvas/__tests__/attention.test.js +2 -2
- package/dist/core/canvas/__tests__/render-remote.test.js +2 -2
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +21 -3
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +5 -1
- package/dist/core/canvas/canvas.d.ts +26 -0
- package/dist/core/canvas/canvas.js +55 -0
- package/dist/core/command.js +6 -11
- package/dist/core/config.js +3 -2
- package/dist/core/help.d.ts +3 -0
- package/dist/core/human/__tests__/html-markdown.test.d.ts +1 -0
- package/dist/core/human/__tests__/html-markdown.test.js +52 -0
- package/dist/core/human/__tests__/page-catalog.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-catalog.test.js +16 -0
- package/dist/core/human/__tests__/page-render.test.js +40 -23
- package/dist/core/human/__tests__/page-tickets.test.js +48 -27
- package/dist/core/human/__tests__/page.test.js +48 -65
- package/dist/core/human/__tests__/serial/inbox-core.test.js +5 -15
- package/dist/core/human/answer-text.d.ts +3 -0
- package/dist/core/human/answer-text.js +98 -0
- package/dist/core/human/answer.d.ts +51 -0
- package/dist/core/human/answer.js +125 -0
- package/dist/core/human/component-docs.d.ts +14 -0
- package/dist/core/human/component-docs.js +161 -0
- package/dist/core/human/html-markdown.d.ts +3 -0
- package/dist/core/human/html-markdown.js +288 -0
- package/dist/core/human/page-catalog.d.ts +19 -3
- package/dist/core/human/page-catalog.js +73 -16
- package/dist/core/human/page-render.d.ts +3 -2
- package/dist/core/human/page-render.js +19 -45
- package/dist/core/human/page-schema.d.ts +108 -8
- package/dist/core/human/page-schema.js +88 -31
- package/dist/core/human/page-synth.d.ts +2 -4
- package/dist/core/human/page-synth.js +23 -7
- package/dist/core/human/page.d.ts +14 -7
- package/dist/core/human/page.js +178 -90
- package/dist/core/human/review-schema.d.ts +1 -0
- package/dist/core/human/review-schema.js +1 -1
- package/dist/core/human/scan.d.ts +3 -2
- package/dist/core/human/scan.js +7 -8
- package/dist/core/human/summary.d.ts +4 -7
- package/dist/core/human/summary.js +4 -60
- package/dist/core/human/tickets.d.ts +10 -9
- package/dist/core/human/tickets.js +5 -3
- package/dist/core/human/types.d.ts +4 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/review/realize.js +9 -1
- package/dist/core/runtime/boot-root.js +6 -1
- package/dist/core/runtime/broker.js +8 -6
- package/dist/core/runtime/busy.d.ts +4 -3
- package/dist/core/runtime/busy.js +4 -3
- package/dist/core/runtime/spawn.js +4 -4
- package/dist/core/user-settings.d.ts +17 -2
- package/dist/core/user-settings.js +32 -11
- package/dist/daemon/api/handlers/human.js +1 -1
- package/dist/daemon/api/handlers/inbox.js +22 -7
- package/dist/daemon/cron-run.js +16 -2
- package/dist/daemon/crtrd.js +1 -1
- package/dist/daemon/fleet.d.ts +22 -10
- package/dist/daemon/fleet.js +43 -24
- package/dist/daemon/human/finish.d.ts +2 -1
- package/dist/daemon/human/finish.js +45 -28
- package/dist/daemon/human/sweep.js +4 -2
- package/dist/daemon/reconcilers/broker-supervision.js +11 -8
- package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
- package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
- package/dist/daemon/reconcilers/live-obligation.js +30 -2
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
- package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
- package/dist/daemon/review/sweep.js +12 -1
- package/dist/pages/bundle.css +1 -1
- package/dist/pages/bundle.js +1008 -577
- package/dist/pages/comments.d.ts +29 -0
- package/dist/pages/comments.js +51 -0
- package/dist/pages/controls.d.ts +98 -0
- package/dist/pages/controls.js +261 -0
- package/dist/pages/elements/cards.d.ts +15 -4
- package/dist/pages/elements/cards.js +299 -186
- package/dist/pages/elements/chart.d.ts +22 -0
- package/dist/pages/elements/chart.js +107 -91
- package/dist/pages/elements/options.d.ts +9 -0
- package/dist/pages/elements/options.js +268 -155
- package/dist/pages/elements/pages.d.ts +6 -0
- package/dist/pages/elements/pages.js +60 -38
- package/dist/pages/elements/slot.d.ts +5 -0
- package/dist/pages/elements/slot.js +17 -10
- package/dist/pages/elements/table.d.ts +17 -0
- package/dist/pages/elements/table.js +336 -228
- package/dist/pages/elements/text.d.ts +9 -3
- package/dist/pages/elements/text.js +350 -143
- package/dist/pages/host.d.ts +0 -24
- package/dist/pages/host.js +0 -17
- package/dist/pages/readonly.d.ts +24 -0
- package/dist/pages/readonly.js +31 -0
- package/dist/pages/responses.d.ts +37 -0
- package/dist/pages/responses.js +56 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
- package/dist/pi-extensions/canvas-bash-valve.js +12 -8
- package/dist/types.d.ts +19 -5
- package/dist/types.js +2 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/install-runtime.mjs +19 -24
- package/dist/clients/attach/render/html-markdown.d.ts +0 -13
- package/dist/clients/attach/render/html-markdown.js +0 -206
- package/dist/core/human/markdown-html.d.ts +0 -8
- package/dist/core/human/markdown-html.js +0 -581
|
@@ -20,7 +20,7 @@ const BROWSE_NAV_ACTIONS = [
|
|
|
20
20
|
'crtr.browse.tab-next', 'crtr.browse.tab-previous', 'crtr.browse.tab.1', 'crtr.browse.tab.2',
|
|
21
21
|
'crtr.browse.tab.3', 'crtr.browse.tab.4', 'crtr.browse.tab.5', 'crtr.browse.close-subtree', 'crtr.browse.sort',
|
|
22
22
|
'crtr.browse.scope', 'crtr.browse.profile', 'crtr.browse.residents', 'crtr.browse.preview', 'crtr.browse.search',
|
|
23
|
-
'crtr.browse.revive-now', 'crtr.browse.focus', 'crtr.browse.focus-background',
|
|
23
|
+
'crtr.browse.revive-now', 'crtr.browse.focus', 'crtr.browse.focus-background', 'crtr.browse.yank-id',
|
|
24
24
|
];
|
|
25
25
|
const BROWSE_SEARCH_ACTIONS = [
|
|
26
26
|
'crtr.browse.search.cancel', 'crtr.browse.search.commit', 'crtr.browse.search.delete',
|
|
@@ -39,6 +39,7 @@ export function resolveBrowseSearchAction(bindings, raw) {
|
|
|
39
39
|
// press restores the structural `tree` view, then relevance/recency, then back.
|
|
40
40
|
const SORT_CYCLE = ['attention', 'tree', 'relevance', 'recency'];
|
|
41
41
|
import { renderFrame, detectColorCaps, headerHeight, PREVIEW_HEIGHT } from './render.js';
|
|
42
|
+
import { writeClipboardTextEverywhere } from '../../clipboard-text.js';
|
|
42
43
|
/** Viewport (body) height = total rows minus the header renderFrame draws (see
|
|
43
44
|
* render.ts headerHeight), the footer, and the preview panel when shown. Kept
|
|
44
45
|
* in lockstep with render.ts via the shared headerHeight/PREVIEW_HEIGHT. */
|
|
@@ -102,7 +103,9 @@ export async function runBrowse(opts) {
|
|
|
102
103
|
// canvas-wide because there is no truthful profile to select.
|
|
103
104
|
const launchProfileId = opts.profileId;
|
|
104
105
|
const state = {
|
|
105
|
-
|
|
106
|
+
// Open on Live (active|idle): the navigator's job is getting you back to a
|
|
107
|
+
// node you are working with, and All buries those under every dormant row.
|
|
108
|
+
tab: 'Live',
|
|
106
109
|
cursor: 0,
|
|
107
110
|
// Initial collapse = every node with children → only roots/top-level show.
|
|
108
111
|
collapsed: new Set([...tree.nodes.entries()].filter(([, n]) => n.childIds.length > 0).map(([id]) => id)),
|
|
@@ -115,6 +118,7 @@ export async function runBrowse(opts) {
|
|
|
115
118
|
preview: true, // default ON (decision)
|
|
116
119
|
residentsOnly: true, // default ON: hide one-shot workers (decision)
|
|
117
120
|
pendingClose: null,
|
|
121
|
+
flash: null,
|
|
118
122
|
pinned: loadPins(),
|
|
119
123
|
help: false,
|
|
120
124
|
rename: false,
|
|
@@ -242,7 +246,7 @@ export async function runBrowse(opts) {
|
|
|
242
246
|
tree, visible, tab: state.tab, cursor: state.cursor, scrollOffset: state.scrollOffset,
|
|
243
247
|
query: state.query, search: state.search, totalNodes,
|
|
244
248
|
cwdScope: state.cwdScope, profileScope: state.profileScope, sort: state.sort, preview: state.preview,
|
|
245
|
-
residentsOnly: state.residentsOnly, pendingClose: state.pendingClose,
|
|
249
|
+
residentsOnly: state.residentsOnly, pendingClose: state.pendingClose, flash: state.flash,
|
|
246
250
|
pinned: state.pinned, help: state.help,
|
|
247
251
|
rename: state.rename, renameBuf: state.renameBuf,
|
|
248
252
|
message: state.message, messageBuf: state.messageBuf,
|
|
@@ -542,6 +546,8 @@ export async function runBrowse(opts) {
|
|
|
542
546
|
const onKeyNav = (input, key) => {
|
|
543
547
|
const raw = { input, ...key };
|
|
544
548
|
const action = resolveBrowseNavAction(bindings, raw);
|
|
549
|
+
// Any keystroke retires the previous one-shot footer confirmation.
|
|
550
|
+
state.flash = null;
|
|
545
551
|
// Unbound ctrl-combos are swallowed so they never masquerade as printable commands.
|
|
546
552
|
if (key.ctrl && action === null)
|
|
547
553
|
return;
|
|
@@ -756,6 +762,18 @@ export async function runBrowse(opts) {
|
|
|
756
762
|
scheduleFlush();
|
|
757
763
|
return;
|
|
758
764
|
}
|
|
765
|
+
// Yank the hovered node's id to the system clipboard, so it can be pasted into
|
|
766
|
+
// a prompt or another command without retyping it off the screen.
|
|
767
|
+
if (action === 'crtr.browse.yank-id') {
|
|
768
|
+
if (row !== undefined) {
|
|
769
|
+
const { system, tmux } = writeClipboardTextEverywhere(row.id);
|
|
770
|
+
state.flash = system || tmux
|
|
771
|
+
? `Copied ${row.id}${system ? '' : ' to the tmux buffer'}`
|
|
772
|
+
: 'No clipboard tool available — nothing copied';
|
|
773
|
+
}
|
|
774
|
+
scheduleFlush();
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
759
777
|
// Resume the selected node.
|
|
760
778
|
if (action === 'crtr.browse.focus') {
|
|
761
779
|
if (row !== undefined)
|
|
@@ -43,6 +43,10 @@ export interface RenderState {
|
|
|
43
43
|
* a descendant) is actively streaming. Holds the node id being confirmed; the
|
|
44
44
|
* footer becomes the y/n prompt. null/undefined = no pending confirm. */
|
|
45
45
|
pendingClose?: string | null;
|
|
46
|
+
/** One-shot footer confirmation for a keystroke whose whole effect is off-screen
|
|
47
|
+
* (`y` yank). Replaces the nav hints for exactly one frame; the app clears it on
|
|
48
|
+
* the next key. null/undefined = show the ordinary footer. */
|
|
49
|
+
flash?: string | null;
|
|
46
50
|
/** Set when the source is a remote canvas: mutation/focus keys (`x`/`K`/Enter)
|
|
47
51
|
* are disabled, so the footer drops their hints instead of advertising a
|
|
48
52
|
* dead action. undefined/false = local, byte-identical footer. */
|
|
@@ -428,7 +428,8 @@ const HELP_ROWS = [
|
|
|
428
428
|
['Pin', 'f', 'star / unstar — floats to top, Pinned tab'],
|
|
429
429
|
['Open', '⏎ o', 'resume the node (closes navigator)'],
|
|
430
430
|
['', 'O', 'resume, keep browsing (open in background)'],
|
|
431
|
-
['Act', '
|
|
431
|
+
['Act', 'y', 'copy the node id to the clipboard'],
|
|
432
|
+
['', 'm', 'message the node (no resume)'],
|
|
432
433
|
['', 'R', 'rename the node'],
|
|
433
434
|
['', 'x', 'close-out node + subtree'],
|
|
434
435
|
['', 'K', 'revive now (a hanging node)'],
|
|
@@ -533,6 +534,9 @@ export function renderFrame(state, size, caps = { color: false, color256: false
|
|
|
533
534
|
const warn = `⚠ ${who} is ⟳ — close it (and its subtree) anyway? y / n`;
|
|
534
535
|
lines.push(caps.color ? `${BOLD}${ESC}${FG_BRIGHT_YELLOW}m${clip(warn, width)}${RESET}` : `${REVERSE}${clip(warn, width)}${RESET}`);
|
|
535
536
|
}
|
|
537
|
+
else if (state.flash !== null && state.flash !== undefined && state.flash !== '') {
|
|
538
|
+
lines.push(caps.color ? `${BOLD}${clip(state.flash, width)}${RESET}` : `${REVERSE}${clip(state.flash, width)}${RESET}`);
|
|
539
|
+
}
|
|
536
540
|
else {
|
|
537
541
|
// When the cursor row has an active fault, lead the footer with the on-demand kick
|
|
538
542
|
// hint (K shells `node lifecycle revive <id> --now` — immediate revive instead of
|
|
@@ -206,6 +206,11 @@ export declare function view(root: string): string[];
|
|
|
206
206
|
* that's still live (active|idle) — i.e. something that can actually wake it?
|
|
207
207
|
* If so, stopping is a legitimate await; if not, it must finish or escalate. */
|
|
208
208
|
export declare function hasActiveLiveSubscription(nodeId: string): boolean;
|
|
209
|
+
/** Whether a LIVE node still subscribes to this one. The mirror of
|
|
210
|
+
* `hasActiveLiveSubscription`: that asks what can still wake this node, this
|
|
211
|
+
* asks what this node still owes — a subscriber that is itself active or idle
|
|
212
|
+
* is waiting on a report this node has not delivered. */
|
|
213
|
+
export declare function hasLiveSubscriber(nodeId: string): boolean;
|
|
209
214
|
/** Whether a node explicitly awaits a controller that can still deliver. A
|
|
210
215
|
* parent relationship never implies this wait; the node must declare it. */
|
|
211
216
|
export declare function hasLiveMessageWait(nodeId: string): boolean;
|
|
@@ -324,6 +329,27 @@ export declare function pruneNodes(opts: {
|
|
|
324
329
|
dryRun?: boolean;
|
|
325
330
|
includeStale?: boolean;
|
|
326
331
|
}): PruneResult;
|
|
332
|
+
/** Drop rows whose on-disk `nodes/<id>/` dir is gone — a node that no longer
|
|
333
|
+
* exists in the only place it is durably stored.
|
|
334
|
+
*
|
|
335
|
+
* `createNode` scaffolds the dir and writes meta.json BEFORE seeding the row, and
|
|
336
|
+
* both delete paths ({@link deleteNode}, `deletePrunedNodes`) commit the row
|
|
337
|
+
* removal BEFORE removing the dir, so a row with no dir is never a transient of
|
|
338
|
+
* either — it is a node whose durable state was lost. Nothing about it is
|
|
339
|
+
* recoverable: `getNode` reads meta.json and returns null, so every surface that
|
|
340
|
+
* resolves a node (revive, focus, close, rename, message) rejects it as unknown
|
|
341
|
+
* while the roster keeps listing it. The row IS the residue, so deleting it is
|
|
342
|
+
* the whole repair; there is no dir left to remove.
|
|
343
|
+
*
|
|
344
|
+
* Because `active`/`idle` ghosts are the ones that jam surfaces, status is not a
|
|
345
|
+
* filter here — count-based pruning protects those rows precisely so daemon
|
|
346
|
+
* crash-recovery can resume them, and that reason does not survive the dir being
|
|
347
|
+
* gone.
|
|
348
|
+
*
|
|
349
|
+
* Refuses to act on "can't tell": an unreadable nodes root, or a readable one
|
|
350
|
+
* holding no node dirs at all while rows exist, reads as a detached or wiped
|
|
351
|
+
* store rather than per-node loss, and reaps nothing. Returns the reaped ids. */
|
|
352
|
+
export declare function reapGhostRows(): string[];
|
|
327
353
|
/** Count-based retention sweep: retain the `maxNodes` MOST-RECENTLY-ACTIVE
|
|
328
354
|
* nodes plus every protected node, so the real retained floor is
|
|
329
355
|
* `max(maxNodes, protectedCount)`. Bounds the otherwise-unbounded growth the
|
|
@@ -591,6 +591,18 @@ export function hasActiveLiveSubscription(nodeId) {
|
|
|
591
591
|
.get(nodeId);
|
|
592
592
|
return r !== undefined;
|
|
593
593
|
}
|
|
594
|
+
/** Whether a LIVE node still subscribes to this one. The mirror of
|
|
595
|
+
* `hasActiveLiveSubscription`: that asks what can still wake this node, this
|
|
596
|
+
* asks what this node still owes — a subscriber that is itself active or idle
|
|
597
|
+
* is waiting on a report this node has not delivered. */
|
|
598
|
+
export function hasLiveSubscriber(nodeId) {
|
|
599
|
+
const r = openDb()
|
|
600
|
+
.prepare(`SELECT 1 FROM edges e JOIN nodes n ON n.node_id = e.from_id
|
|
601
|
+
WHERE e.type = 'subscribes_to' AND e.to_id = ? AND e.active = 1
|
|
602
|
+
AND n.status IN ('active', 'idle') LIMIT 1`)
|
|
603
|
+
.get(nodeId);
|
|
604
|
+
return r !== undefined;
|
|
605
|
+
}
|
|
594
606
|
/** Whether a node explicitly awaits a controller that can still deliver. A
|
|
595
607
|
* parent relationship never implies this wait; the node must declare it. */
|
|
596
608
|
export function hasLiveMessageWait(nodeId) {
|
|
@@ -789,6 +801,49 @@ export function pruneNodes(opts) {
|
|
|
789
801
|
deletePrunedNodes(candidates);
|
|
790
802
|
return { pruned: candidates, dryRun };
|
|
791
803
|
}
|
|
804
|
+
/** Drop rows whose on-disk `nodes/<id>/` dir is gone — a node that no longer
|
|
805
|
+
* exists in the only place it is durably stored.
|
|
806
|
+
*
|
|
807
|
+
* `createNode` scaffolds the dir and writes meta.json BEFORE seeding the row, and
|
|
808
|
+
* both delete paths ({@link deleteNode}, `deletePrunedNodes`) commit the row
|
|
809
|
+
* removal BEFORE removing the dir, so a row with no dir is never a transient of
|
|
810
|
+
* either — it is a node whose durable state was lost. Nothing about it is
|
|
811
|
+
* recoverable: `getNode` reads meta.json and returns null, so every surface that
|
|
812
|
+
* resolves a node (revive, focus, close, rename, message) rejects it as unknown
|
|
813
|
+
* while the roster keeps listing it. The row IS the residue, so deleting it is
|
|
814
|
+
* the whole repair; there is no dir left to remove.
|
|
815
|
+
*
|
|
816
|
+
* Because `active`/`idle` ghosts are the ones that jam surfaces, status is not a
|
|
817
|
+
* filter here — count-based pruning protects those rows precisely so daemon
|
|
818
|
+
* crash-recovery can resume them, and that reason does not survive the dir being
|
|
819
|
+
* gone.
|
|
820
|
+
*
|
|
821
|
+
* Refuses to act on "can't tell": an unreadable nodes root, or a readable one
|
|
822
|
+
* holding no node dirs at all while rows exist, reads as a detached or wiped
|
|
823
|
+
* store rather than per-node loss, and reaps nothing. Returns the reaped ids. */
|
|
824
|
+
export function reapGhostRows() {
|
|
825
|
+
const rows = listNodes();
|
|
826
|
+
if (rows.length === 0)
|
|
827
|
+
return [];
|
|
828
|
+
let onDisk;
|
|
829
|
+
try {
|
|
830
|
+
onDisk = new Set(readdirSync(nodesRoot()));
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
return []; // nodes root absent or unreadable — can't tell, so touch nothing
|
|
834
|
+
}
|
|
835
|
+
if (onDisk.size === 0)
|
|
836
|
+
return [];
|
|
837
|
+
const ghosts = rows.map((r) => r.node_id).filter((id) => !onDisk.has(id));
|
|
838
|
+
if (ghosts.length === 0)
|
|
839
|
+
return [];
|
|
840
|
+
withCanvasWrite((db) => {
|
|
841
|
+
const del = db.prepare('DELETE FROM nodes WHERE node_id = ?');
|
|
842
|
+
for (const id of ghosts)
|
|
843
|
+
del.run(id);
|
|
844
|
+
});
|
|
845
|
+
return ghosts;
|
|
846
|
+
}
|
|
792
847
|
/** Hard-delete a set of pruned nodes in one transaction (the edges→nodes and
|
|
793
848
|
* anchored-crons FKs cascade their coupled state), followed by best-effort
|
|
794
849
|
* on-disk `nodes/<id>/` removal. Shared by TTL and count pruning so their
|
package/dist/core/command.js
CHANGED
|
@@ -439,9 +439,8 @@ export async function parseArgv(params, tokens, options) {
|
|
|
439
439
|
throw parseArgvError('bad_invocation', `unexpected extra positional argument: ${token}`, tokens.join(' '), undefined, 'Use --flag for parameters; only one positional allowed.');
|
|
440
440
|
}
|
|
441
441
|
// A bare positional is accepted when the leaf declares a positional param,
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
if (positionalParam === undefined && stdinParam === undefined) {
|
|
442
|
+
// or when its stdin parameter explicitly permits a positional body.
|
|
443
|
+
if (positionalParam === undefined && (stdinParam === undefined || stdinParam.allowPositional === false)) {
|
|
445
444
|
throw parseArgvError('bad_invocation', `this leaf takes no positional arguments: ${token}`, token, undefined, 'Use --flag for parameters. Run -h for the schema.');
|
|
446
445
|
}
|
|
447
446
|
positionalValues.push(token);
|
|
@@ -454,16 +453,12 @@ export async function parseArgv(params, tokens, options) {
|
|
|
454
453
|
? positionalValues
|
|
455
454
|
: positionalValues[0];
|
|
456
455
|
}
|
|
457
|
-
// Resolve stdin if declared.
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
// standard Unix convention for "read stdin instead" — it always reads,
|
|
461
|
-
// never becomes the literal value. And a positional alongside genuinely
|
|
462
|
-
// piped, non-empty stdin is ambiguous: never silently pick one, error loud
|
|
463
|
-
// and name both sources.
|
|
456
|
+
// Resolve stdin if declared. Some leaves permit one positional token to
|
|
457
|
+
// supply the stdin body; `-` then means read stdin. A positional alongside
|
|
458
|
+
// genuinely piped, non-empty stdin is ambiguous and errors loudly.
|
|
464
459
|
let stdinWasSupplied = false;
|
|
465
460
|
if (stdinParam !== undefined) {
|
|
466
|
-
if (positionalValues.length > 0 && positionalParam === undefined) {
|
|
461
|
+
if (positionalValues.length > 0 && positionalParam === undefined && stdinParam.allowPositional !== false) {
|
|
467
462
|
const positionalValue = positionalValues[0];
|
|
468
463
|
if (positionalValue === '-') {
|
|
469
464
|
const raw = await readStdinRaw();
|
package/dist/core/config.js
CHANGED
|
@@ -479,8 +479,8 @@ function mergeConfig(partial) {
|
|
|
479
479
|
? [...defaults.working_gerunds]
|
|
480
480
|
: normalizeWorkingGerunds(partial.working_gerunds);
|
|
481
481
|
const whip_mode = typeof partial.whip_mode === 'boolean' ? partial.whip_mode : defaults.whip_mode;
|
|
482
|
-
const html_pages = typeof partial.html_pages === 'boolean' ? partial.html_pages : defaults.html_pages;
|
|
483
482
|
const page_components = normalizePageComponents(partial.page_components);
|
|
483
|
+
const html_surface = typeof partial.html_surface === 'boolean' ? partial.html_surface : defaults.html_surface;
|
|
484
484
|
const whip_messages = normalizeWhipMessages(partial.whip_messages);
|
|
485
485
|
const mouse_mode_default = MOUSE_MODE_DEFAULTS.includes(partial.mouse_mode_default)
|
|
486
486
|
? partial.mouse_mode_default
|
|
@@ -489,6 +489,7 @@ function mergeConfig(partial) {
|
|
|
489
489
|
const condensed_history = CONDENSED_HISTORY_MODES.includes(partial.condensed_history)
|
|
490
490
|
? partial.condensed_history
|
|
491
491
|
: defaults.condensed_history;
|
|
492
|
+
const bash_tool_purpose = typeof partial.bash_tool_purpose === 'boolean' ? partial.bash_tool_purpose : defaults.bash_tool_purpose;
|
|
492
493
|
const fold_finished_tools = typeof partial.fold_finished_tools === 'boolean' ? partial.fold_finished_tools : defaults.fold_finished_tools;
|
|
493
494
|
const summarize_tool_calls = typeof partial.summarize_tool_calls === 'boolean' ? partial.summarize_tool_calls : defaults.summarize_tool_calls;
|
|
494
495
|
const detailed_tool_recaps = typeof partial.detailed_tool_recaps === 'boolean' ? partial.detailed_tool_recaps : defaults.detailed_tool_recaps;
|
|
@@ -500,7 +501,7 @@ function mergeConfig(partial) {
|
|
|
500
501
|
const kinds = mergeKinds(partial.kinds);
|
|
501
502
|
const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
|
|
502
503
|
const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
|
|
503
|
-
return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode,
|
|
504
|
+
return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, page_components, html_surface, whip_messages, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
|
|
504
505
|
}
|
|
505
506
|
/** Raw (un-defaulted) partial config for one scope, or null if the scope has
|
|
506
507
|
* no root or no config.json. Used by `readMergedLaunchConfig` to layer
|
package/dist/core/help.d.ts
CHANGED
|
@@ -64,6 +64,9 @@ export interface StdinParam {
|
|
|
64
64
|
name: string;
|
|
65
65
|
required: boolean;
|
|
66
66
|
constraint: string;
|
|
67
|
+
/** Whether one positional token may supply stdin instead of a pipe. Defaults
|
|
68
|
+
* to true for existing stdin-body leaves; false requires actual stdin. */
|
|
69
|
+
allowPositional?: boolean;
|
|
67
70
|
}
|
|
68
71
|
/** --context-file PATH: reads and JSON-parses the file at PATH. */
|
|
69
72
|
export interface ContextFileParam {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { RICH_PAGE_PLACEHOLDER, renderHtmlProseAsMarkdown } from '../html-markdown.js';
|
|
4
|
+
test('downconverts bounded semantic HTML page prose without raw tag leakage', () => {
|
|
5
|
+
const markdown = renderHtmlProseAsMarkdown(`<!doctype html><html><head><title>ignored</title></head><body>
|
|
6
|
+
<h2>Release <em>notes</em></h2>
|
|
7
|
+
<p>Read <strong>this</strong>, <code>npm run build</code>, and <a href="https://example.test/a">details</a>.</p>
|
|
8
|
+
<ul><li>First</li><li>Second<ul><li>Nested</li></ul></li></ul>
|
|
9
|
+
<ol><li>One</li></ol>
|
|
10
|
+
<blockquote><p>Quoted</p></blockquote>
|
|
11
|
+
<pre><code class="language-ts">const x = 1;</code></pre>
|
|
12
|
+
<table><thead><tr><th>Name</th><th>State</th></tr></thead><tbody><tr><td>A</td><td>Ready</td></tr></tbody></table>
|
|
13
|
+
<p>Safe <span style="color:red">text</span>.</p>
|
|
14
|
+
<div class="custom-chrome">Widget controls</div>
|
|
15
|
+
<crtr-pages><crtr-page><p>Before <span>slot</span>.</p><crtr-options>ignored config</crtr-options><p>After.</p></crtr-page></crtr-pages>
|
|
16
|
+
<p>Use <span>x</span> literally.</p>
|
|
17
|
+
<p># Not heading<br>> Not quote<br>- Not list<br>1. Not ordered</p>
|
|
18
|
+
</body></html>`);
|
|
19
|
+
const expected = `## Release *notes*
|
|
20
|
+
|
|
21
|
+
Read **this**, \`npm run build\`, and [details](<https://example.test/a>).
|
|
22
|
+
|
|
23
|
+
- First
|
|
24
|
+
- Second
|
|
25
|
+
- Nested
|
|
26
|
+
|
|
27
|
+
1. One
|
|
28
|
+
|
|
29
|
+
> Quoted
|
|
30
|
+
|
|
31
|
+
\`\`\`ts
|
|
32
|
+
const x = 1;
|
|
33
|
+
\`\`\`
|
|
34
|
+
|
|
35
|
+
| Name | State |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| A | Ready |
|
|
38
|
+
|
|
39
|
+
Safe text.
|
|
40
|
+
|
|
41
|
+
${RICH_PAGE_PLACEHOLDER}
|
|
42
|
+
|
|
43
|
+
Before slot.
|
|
44
|
+
|
|
45
|
+
After.
|
|
46
|
+
|
|
47
|
+
Use <span>x</span> literally.
|
|
48
|
+
|
|
49
|
+
` + ['\\# Not heading ', '> Not quote ', '\\- Not list ', '1\\. Not ordered'].join('\n');
|
|
50
|
+
assert.equal(markdown, expected);
|
|
51
|
+
assert.doesNotMatch(markdown, /<(?:span|div)\b/i);
|
|
52
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { normalizePageComponents, pageComponentKinds } from '../page-catalog.js';
|
|
4
|
+
test('page component registrations accept legacy names and documented objects', () => {
|
|
5
|
+
const components = normalizePageComponents([
|
|
6
|
+
'approval',
|
|
7
|
+
{ kind: 'approval-card', description: 'Approval card', useWhen: 'Use for an approval.', doc: 'Product contract.', display: true },
|
|
8
|
+
]);
|
|
9
|
+
assert.deepEqual(components, [
|
|
10
|
+
{ kind: 'approval' },
|
|
11
|
+
{ kind: 'approval-card', description: 'Approval card', useWhen: 'Use for an approval.', doc: 'Product contract.', display: true },
|
|
12
|
+
]);
|
|
13
|
+
assert.deepEqual(pageComponentKinds(components), ['approval', 'approval-card']);
|
|
14
|
+
assert.throws(() => normalizePageComponents([{ kind: 'approval', display: 'yes' }]), /page_components\[0\]\.display must be a boolean/);
|
|
15
|
+
assert.throws(() => normalizePageComponents([{ kind: 'approval', unexpected: true }]), /unknown field\(s\): unexpected/);
|
|
16
|
+
});
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Nothing else about rendering is pinned here; prose shape is the renderer's
|
|
4
|
-
// own business and a change there is visible the moment anyone looks at a page.
|
|
1
|
+
// An authored HTML slot id must reach the runtime's `slot-id` attribute. Without
|
|
2
|
+
// this pin every element renders but no response reaches the host.
|
|
5
3
|
import assert from 'node:assert/strict';
|
|
6
4
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
5
|
import { tmpdir } from 'node:os';
|
|
@@ -9,41 +7,60 @@ import { join } from 'node:path';
|
|
|
9
7
|
import test from 'node:test';
|
|
10
8
|
import { derivePageManifest } from '../page.js';
|
|
11
9
|
import { renderPageHtml } from '../page-render.js';
|
|
12
|
-
|
|
10
|
+
import { preparePage } from '../tickets.js';
|
|
11
|
+
function render(source) {
|
|
13
12
|
const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-test-'));
|
|
14
13
|
try {
|
|
15
|
-
const path = join(dir,
|
|
14
|
+
const path = join(dir, 'page.html');
|
|
16
15
|
writeFileSync(path, source);
|
|
17
|
-
return renderPageHtml(derivePageManifest(path,
|
|
16
|
+
return renderPageHtml(derivePageManifest(path, 'html'), readFileSync(path, 'utf8'));
|
|
18
17
|
}
|
|
19
18
|
finally {
|
|
20
19
|
rmSync(dir, { recursive: true, force: true });
|
|
21
20
|
}
|
|
22
21
|
}
|
|
23
|
-
test('
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'
|
|
28
|
-
|
|
29
|
-
'
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
22
|
+
test('source-backed slots render embedded config, never a filesystem path', () => {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-source-'));
|
|
24
|
+
try {
|
|
25
|
+
const data = join(dir, 'rows.json');
|
|
26
|
+
const page = join(dir, 'page.html');
|
|
27
|
+
writeFileSync(data, JSON.stringify([{ id: 'one', cells: { name: '</script><script>window.pwned=1</script>' } }]));
|
|
28
|
+
writeFileSync(page, `<!doctype html><html><head><meta name="crtr-page-title" content="Rows"></head><body><crtr-table><script type="application/json">${JSON.stringify({ columns: [{ id: 'name', label: '</scripture>' }], source: data })}</script></crtr-table></body></html>`);
|
|
29
|
+
const prepared = preparePage({ sourceFile: page, dialect: 'html' });
|
|
30
|
+
const rendered = renderPageHtml(prepared.manifest, prepared.document);
|
|
31
|
+
assert.match(rendered, /"rows":\[{"id":"one","cells":\{"name":"\\u003c\/script>\\u003cscript>window\.pwned=1\\u003c\/script>"}}]/);
|
|
32
|
+
assert.match(rendered, /"label":"\\u003c\/scripture>"/);
|
|
33
|
+
assert.doesNotMatch(rendered, /"source"|rows\.json|<script>window\.pwned/);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
test('render replacement ignores comments and non-config scripts', () => {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-scripts-'));
|
|
41
|
+
try {
|
|
42
|
+
const data = join(dir, 'rows.json');
|
|
43
|
+
const page = join(dir, 'page.html');
|
|
44
|
+
writeFileSync(data, JSON.stringify([{ id: 'one', cells: { name: 'One' } }]));
|
|
45
|
+
writeFileSync(page, `<!doctype html><html><head><meta name="crtr-page-title" content="Rows"></head><body><!-- > <crtr-table><script type="application/json">{}</script></crtr-table> --><crtr-table><script data-type="application/json" type="text/plain">KEEP</script><script type="application/json">${JSON.stringify({ columns: [{ id: 'name', label: 'Name' }], source: data })}</script></crtr-table></body></html>`);
|
|
46
|
+
const prepared = preparePage({ sourceFile: page, dialect: 'html' });
|
|
47
|
+
const rendered = renderPageHtml(prepared.manifest, prepared.document);
|
|
48
|
+
assert.match(rendered, /<script data-type="application\/json" type="text\/plain">KEEP<\/script>/);
|
|
49
|
+
assert.match(rendered, /"rows":\[{"id":"one","cells":\{"name":"One"}}]/);
|
|
50
|
+
assert.doesNotMatch(rendered, /"source"|rows\.json/);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
36
55
|
});
|
|
37
56
|
test('an authored html slot id is mirrored to the slot-id the runtime reads', () => {
|
|
38
|
-
const html = render(
|
|
57
|
+
const html = render([
|
|
39
58
|
'<!doctype html><html><head><meta name="crtr-page-title" content="Sign-off"></head><body>',
|
|
40
59
|
'<crtr-pages><crtr-page label="Window">',
|
|
41
60
|
'<crtr-options id="window"><script type="application/json">{"mode":"single","options":[{"id":"sat","label":"Saturday"}]}</script></crtr-options>',
|
|
42
61
|
'</crtr-page></crtr-pages>',
|
|
43
62
|
'</body></html>',
|
|
44
63
|
].join('\n'));
|
|
45
|
-
// Without this, every element mounts and renders and no response ever
|
|
46
|
-
// reaches the host, because `src/pages` keys responses by `slot-id`.
|
|
47
64
|
assert.match(html, /<crtr-options id="window" slot-id="window">/);
|
|
48
65
|
assert.match(html, /<crtr-page label="Window">/);
|
|
49
66
|
});
|
|
@@ -9,47 +9,64 @@ const { atomicWriteJson, pageManifestPath, pagePath, progressPath, responsePath
|
|
|
9
9
|
const { claimTicket } = await import('../claim.js');
|
|
10
10
|
const { scanInbox, scanPageHistory } = await import('../scan.js');
|
|
11
11
|
const { ticketDir } = await import('../root.js');
|
|
12
|
-
const { cancelTicket, completePage, readPageProgress, readPageTicket, readTicketResult, submitPage, takeoverAndCompletePage, writePageProgress, } = await import('../tickets.js');
|
|
12
|
+
const { cancelTicket, completePage, readPageProgress, readPageTicket, preparePage, readTicketResult, submitPage, takeoverAndCompletePage, writePageProgress, } = await import('../tickets.js');
|
|
13
13
|
const sources = mkdtempSync(join(tmpdir(), 'crtr-page-sources-'));
|
|
14
14
|
function sourceFile(name, body) {
|
|
15
15
|
const path = join(sources, name);
|
|
16
16
|
writeFileSync(path, body);
|
|
17
17
|
return path;
|
|
18
18
|
}
|
|
19
|
-
const askPage = sourceFile('ask.
|
|
20
|
-
title: Rollout scope
|
|
21
|
-
subtitle: Pick tonight's rollout
|
|
22
|
-
placement: inline
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
Which rollout do you want?
|
|
26
|
-
|
|
27
|
-
\`\`\`slot:options
|
|
28
|
-
{"id": "rollout", "mode": "single", "options": [{"id": "canary", "label": "Canary first"}, {"id": "full", "label": "Full"}]}
|
|
29
|
-
\`\`\`
|
|
30
|
-
`);
|
|
19
|
+
const askPage = sourceFile('ask.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Rollout scope"><meta name="crtr-page-subtitle" content="Pick tonight’s rollout"><meta name="crtr-page-placement" content="inline"></head><body><p>Which rollout do you want?</p><crtr-pages><crtr-page><crtr-options id="rollout"><script type="application/json">{"mode":"single","options":[{"id":"canary","label":"Canary first"},{"id":"full","label":"Full"}]}</script></crtr-options></crtr-page></crtr-pages></body></html>');
|
|
31
20
|
const displayPage = sourceFile('display.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Nightly report"></head><body><h1>All green</h1></body></html>');
|
|
32
|
-
function submit(id, source, dialect, blockedSince, nodeId = 'node-1') {
|
|
33
|
-
|
|
21
|
+
function submit(id, source, dialect, blockedSince, nodeId = 'node-1', mode = 'ask') {
|
|
22
|
+
const dir = submitPage({ dir: ticketDir(id), sourceFile: source, dialect, source: { nodeId, blockedSince } }).dir;
|
|
23
|
+
atomicWriteJson(join(dir, 'run.json'), { mode });
|
|
24
|
+
return dir;
|
|
34
25
|
}
|
|
35
26
|
test('submitPage publishes the authored source, the derived manifest and the blockedSince stamp', () => {
|
|
36
|
-
const submitted = submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: '
|
|
27
|
+
const submitted = submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'html', source: { nodeId: 'node-1' } });
|
|
37
28
|
assert.equal(submitted.kind, 'page');
|
|
38
29
|
assert.equal(submitted.id, 'ask');
|
|
39
|
-
assert.equal(readFileSync(pagePath(submitted.dir, '
|
|
30
|
+
assert.equal(readFileSync(pagePath(submitted.dir, 'html'), 'utf8'), readFileSync(askPage, 'utf8'));
|
|
40
31
|
const manifest = JSON.parse(readFileSync(pageManifestPath(submitted.dir), 'utf8'));
|
|
41
32
|
assert.equal(manifest['schema'], 'crtr.page/v1');
|
|
42
|
-
assert.equal(manifest['document'], 'page.
|
|
33
|
+
assert.equal(manifest['document'], 'page.html');
|
|
43
34
|
assert.equal(manifest['placement'], 'inline');
|
|
44
35
|
const source = manifest['source'];
|
|
45
36
|
assert.equal(source.nodeId, 'node-1');
|
|
46
37
|
assert.ok(Date.parse(source.blockedSince) > 0, 'submission stamps blockedSince');
|
|
47
|
-
assert.throws(() => submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: '
|
|
38
|
+
assert.throws(() => submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'html' }), /already exists/);
|
|
39
|
+
atomicWriteJson(join(submitted.dir, 'run.json'), { mode: 'ask' });
|
|
48
40
|
const listed = scanInbox().find((item) => item.id === 'ask');
|
|
49
41
|
assert.equal(listed?.kind, 'page');
|
|
50
42
|
assert.equal(listed?.kind === 'page' ? listed.awaitsResponse : undefined, true);
|
|
51
43
|
assert.deepEqual(listed?.kind === 'page' ? listed.slotKinds : undefined, ['options']);
|
|
52
44
|
});
|
|
45
|
+
function tableSourcePage(dataFile) {
|
|
46
|
+
return `<!doctype html><html><head><meta name="crtr-page-title" content="Data"><meta name="crtr-page-placement" content="inline"></head><body><crtr-pages><crtr-page><crtr-table id="rows"><script type="application/json">${JSON.stringify({ columns: [{ id: 'name', label: 'Name' }], source: dataFile, rowSelect: 'single' })}</script></crtr-table></crtr-page></crtr-pages></body></html>`;
|
|
47
|
+
}
|
|
48
|
+
test('source data is embedded into the submitted manifest', () => {
|
|
49
|
+
const data = sourceFile('rows.json', JSON.stringify([{ id: 'one', cells: { name: 'One' } }]));
|
|
50
|
+
const page = sourceFile('source-table.html', tableSourcePage(data));
|
|
51
|
+
const submitted = submitPage({ dir: ticketDir('source-table'), sourceFile: page, dialect: 'html' });
|
|
52
|
+
const config = submitted.manifest.slots[0].config;
|
|
53
|
+
assert.deepEqual(config.rows, [{ id: 'one', cells: { name: 'One' } }]);
|
|
54
|
+
assert.equal('source' in config, false);
|
|
55
|
+
});
|
|
56
|
+
test('invalid source data fails before a ticket is written', () => {
|
|
57
|
+
const cases = [
|
|
58
|
+
['missing', join(sources, 'missing.json'), /does not exist/],
|
|
59
|
+
['invalid-json', sourceFile('invalid.json', '{nope'), /not valid JSON/],
|
|
60
|
+
['invalid-shape', sourceFile('invalid-shape.json', JSON.stringify([{ cells: { name: 'Missing id' } }])), /source data is invalid/],
|
|
61
|
+
];
|
|
62
|
+
for (const [id, data, expected] of cases) {
|
|
63
|
+
const page = sourceFile(`${id}.html`, tableSourcePage(data));
|
|
64
|
+
const dir = ticketDir(`source-${id}`);
|
|
65
|
+
assert.throws(() => preparePage({ sourceFile: page, dialect: 'html' }), expected);
|
|
66
|
+
assert.throws(() => submitPage({ dir, sourceFile: page, dialect: 'html' }), expected);
|
|
67
|
+
assert.equal(existsSync(dir), false, `${id} source validation writes no ticket`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
53
70
|
test('progress round-trips through validation and rejects malformed saved work', () => {
|
|
54
71
|
const dir = ticketDir('ask');
|
|
55
72
|
assert.equal(readPageProgress(dir), null, 'no progress file reads as null');
|
|
@@ -77,23 +94,27 @@ test('readPageTicket derives pending, resolved and canceled state and carries th
|
|
|
77
94
|
assert.equal(written['schema'], 'crtr.page-response/v1');
|
|
78
95
|
assert.equal(written['kind'], 'page');
|
|
79
96
|
assert.deepEqual(written['responses'], { rollout: { selectedOptionIds: ['full'], comments: [] } });
|
|
80
|
-
assert.equal(written['summary'], '
|
|
97
|
+
assert.equal(written['summary'], 'Full');
|
|
81
98
|
assert.equal(readTicketResult(dir)?.kind, 'page', 'the strict decoder accepts the page result');
|
|
82
99
|
assert.equal(existsSync(progressPath(dir)), false, 'completion clears owned work');
|
|
83
100
|
const resolved = readPageTicket(dir);
|
|
84
101
|
assert.equal(resolved.state, 'resolved');
|
|
85
102
|
assert.equal(resolved.progress, null);
|
|
86
103
|
assert.equal(scanInbox().some((item) => item.id === 'ask'), false, 'a resolved page leaves the pending list');
|
|
87
|
-
const canceledDir = submit('canceled', askPage, '
|
|
104
|
+
const canceledDir = submit('canceled', askPage, 'html', '2026-01-03T00:00:00.000Z');
|
|
88
105
|
cancelTicket(canceledDir, { reason: 'no longer needed' });
|
|
89
106
|
assert.equal(readPageTicket(canceledDir).state, 'canceled');
|
|
90
107
|
});
|
|
91
|
-
test('
|
|
108
|
+
test('page scan derives response expectation from the ticket mode', () => {
|
|
92
109
|
const dir = submit('display', displayPage, 'html', '2026-01-04T00:00:00.000Z');
|
|
93
110
|
const ticket = readPageTicket(dir);
|
|
94
111
|
assert.equal(ticket.manifest.dialect, 'html');
|
|
95
112
|
assert.equal(ticket.manifest.slots.length, 0);
|
|
96
|
-
|
|
113
|
+
const display = scanInbox().find((item) => item.id === 'display');
|
|
114
|
+
assert.equal(display?.kind === 'page' ? display.awaitsResponse : undefined, true, 'an ask waits even when its page has no response-bearing slots');
|
|
115
|
+
submit('notify-with-options', askPage, 'html', '2026-01-04T00:00:01.000Z', 'node-1', 'notify');
|
|
116
|
+
const notify = scanInbox().find((item) => item.id === 'notify-with-options');
|
|
117
|
+
assert.equal(notify?.kind === 'page' ? notify.awaitsResponse : undefined, false, 'a notify never waits even when its page has a response-bearing slot');
|
|
97
118
|
const acknowledged = takeoverAndCompletePage(dir, {}, { host: 'crtrd', pid: process.pid });
|
|
98
119
|
assert.equal(acknowledged.won, true);
|
|
99
120
|
const written = JSON.parse(readFileSync(responsePath(dir), 'utf8'));
|
|
@@ -102,11 +123,11 @@ test('a display page acknowledges with the empty responses map', () => {
|
|
|
102
123
|
assert.equal(written['summary'], '');
|
|
103
124
|
});
|
|
104
125
|
test('scanPageHistory returns every page of one node, oldest first, regardless of terminal file', () => {
|
|
105
|
-
submit('older', askPage, '
|
|
106
|
-
submit('other-node', askPage, '
|
|
126
|
+
submit('older', askPage, 'html', '2026-01-01T00:00:00.000Z');
|
|
127
|
+
submit('other-node', askPage, 'html', '2026-01-02T00:00:00.000Z', 'node-2');
|
|
107
128
|
const history = scanPageHistory('node-1');
|
|
108
|
-
assert.deepEqual(history.map((entry) => entry.id), ['older', 'canceled', 'display', 'ask']);
|
|
109
|
-
assert.deepEqual(history.map((entry) => entry.state), ['pending', 'canceled', 'resolved', 'resolved']);
|
|
129
|
+
assert.deepEqual(history.map((entry) => entry.id), ['older', 'canceled', 'display', 'notify-with-options', 'ask']);
|
|
130
|
+
assert.deepEqual(history.map((entry) => entry.state), ['pending', 'canceled', 'resolved', 'pending', 'resolved']);
|
|
110
131
|
assert.deepEqual(scanPageHistory('node-2').map((entry) => entry.id), ['other-node']);
|
|
111
132
|
assert.deepEqual(scanPageHistory('node-missing'), []);
|
|
112
133
|
});
|