@north-light/crouter 0.3.192 → 0.3.194
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 +31 -10
- package/dist/api/client.js +83 -24
- package/dist/api/dto/bash-jobs.d.ts +24 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/human.d.ts +3 -4
- package/dist/api/dto/inbox.d.ts +106 -65
- package/dist/api/dto/inbox.js +2 -8
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
- package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/input/overlay-owner.js +1 -1
- package/dist/clients/attach/overlays/auth.js +34 -8
- package/dist/clients/attach/overlays/pickers.d.ts +5 -8
- package/dist/clients/attach/overlays/pickers.js +37 -43
- package/dist/clients/attach/viewer.js +643 -620
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
- package/dist/clients/inbox/controller.d.ts +2 -3
- package/dist/clients/inbox/controller.js +41 -42
- package/dist/clients/inbox/page-adapter.d.ts +33 -0
- package/dist/clients/inbox/page-adapter.js +54 -0
- package/dist/clients/inbox/resolve.d.ts +2 -2
- package/dist/clients/inbox/resolve.js +1 -1
- package/dist/clients/inbox/review-visibility.d.ts +1 -1
- package/dist/clients/inbox/review-visibility.js +1 -1
- package/dist/clients/inbox/tui/ansi.d.ts +1 -1
- package/dist/clients/inbox/tui/ansi.js +2 -2
- package/dist/clients/inbox/tui/input.d.ts +1 -3
- package/dist/clients/inbox/tui/input.js +242 -553
- package/dist/clients/inbox/tui/panel.d.ts +3 -1
- package/dist/clients/inbox/tui/panel.js +59 -187
- package/dist/clients/inbox/tui/render.d.ts +0 -25
- package/dist/clients/inbox/tui/render.js +165 -416
- package/dist/clients/inbox/tui/slots.d.ts +32 -0
- package/dist/clients/inbox/tui/slots.js +156 -0
- package/dist/clients/inbox/tui/types.d.ts +25 -13
- package/dist/clients/inbox/tui.js +5 -3
- package/dist/commands/attention.js +2 -2
- package/dist/commands/human/doc.js +66 -20
- package/dist/commands/human/prompts.d.ts +3 -3
- package/dist/commands/human/prompts.js +158 -112
- package/dist/commands/human/queue.d.ts +0 -1
- package/dist/commands/human/queue.js +34 -94
- package/dist/commands/human/shared.d.ts +10 -4
- package/dist/commands/human/shared.js +62 -13
- package/dist/commands/human.js +8 -4
- package/dist/commands/memory/read.js +13 -5
- package/dist/commands/node/bash.js +18 -30
- package/dist/commands/node/lifecycle.js +2 -2
- package/dist/commands/surface/node/focus.js +1 -1
- package/dist/core/__tests__/human-deliver.test.js +40 -46
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
- package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
- package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
- package/dist/core/auth-interaction.d.ts +2 -2
- package/dist/core/bash-jobs.d.ts +43 -0
- package/dist/core/bash-jobs.js +96 -2
- package/dist/core/canvas/__tests__/attention.test.js +16 -10
- package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
- package/dist/core/canvas/attention.js +1 -1
- package/dist/core/canvas/browse/app.js +1 -1
- package/dist/core/canvas/nav-model.d.ts +1 -1
- package/dist/core/canvas/nav-model.js +1 -1
- package/dist/core/canvas/remote-canvas-source.js +1 -1
- package/dist/core/canvas/types.d.ts +2 -1
- package/dist/core/config.js +4 -1
- package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-render.test.js +49 -0
- package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-tickets.test.js +112 -0
- package/dist/core/human/__tests__/page.test.d.ts +1 -0
- package/dist/core/human/__tests__/page.test.js +139 -0
- package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
- package/dist/core/human/claim.js +2 -2
- package/dist/core/human/convention.d.ts +4 -4
- package/dist/core/human/convention.js +6 -13
- package/dist/core/human/markdown-html.d.ts +8 -0
- package/dist/core/human/markdown-html.js +581 -0
- package/dist/core/human/page-catalog.d.ts +6 -0
- package/dist/core/human/page-catalog.js +43 -0
- package/dist/core/human/page-render.d.ts +3 -0
- package/dist/core/human/page-render.js +131 -0
- package/dist/core/human/page-schema.d.ts +312 -0
- package/dist/core/human/page-schema.js +291 -0
- package/dist/core/human/page-synth.d.ts +9 -0
- package/dist/core/human/page-synth.js +17 -0
- package/dist/core/human/page.d.ts +22 -0
- package/dist/core/human/page.js +427 -0
- package/dist/core/human/review-schema.d.ts +19 -0
- package/dist/core/human/review-schema.js +34 -0
- package/dist/core/human/scan.d.ts +12 -3
- package/dist/core/human/scan.js +69 -26
- package/dist/core/human/summary.d.ts +5 -6
- package/dist/core/human/summary.js +54 -37
- package/dist/core/human/tickets.d.ts +61 -11
- package/dist/core/human/tickets.js +100 -57
- package/dist/core/human/types.d.ts +21 -66
- package/dist/core/human/types.js +1 -5
- package/dist/core/inspector/model.d.ts +5 -0
- package/dist/core/inspector/model.js +5 -0
- package/dist/core/inspector/text.js +2 -2
- package/dist/core/inspector/tui.js +10 -3
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +4 -2
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/ticket-filter.d.ts +1 -1
- package/dist/core/review/ticket-filter.js +2 -8
- package/dist/core/runtime/broker/read-ops.js +3 -0
- package/dist/core/runtime/broker.js +19 -10
- package/dist/core/runtime/package-health.js +1 -1
- package/dist/core/runtime/persona.js +2 -1
- package/dist/core/runtime/tmux-bindings.js +8 -1
- package/dist/core/termrender/termrender.d.ts +1 -1
- package/dist/core/termrender/termrender.js +2 -2
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/tui/page-host.d.ts +1 -1
- package/dist/core/tui/page-host.js +2 -2
- package/dist/core/user-settings.d.ts +19 -0
- package/dist/core/user-settings.js +22 -0
- package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
- package/dist/daemon/api/handlers/bash-jobs.js +90 -0
- package/dist/daemon/api/handlers/human.js +16 -14
- package/dist/daemon/api/handlers/inbox.js +311 -227
- package/dist/daemon/api/router.d.ts +5 -1
- package/dist/daemon/api/router.js +5 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +23 -10
- package/dist/daemon/human/finish.d.ts +8 -3
- package/dist/daemon/human/finish.js +35 -15
- package/dist/daemon/reconcilers/broker-supervision.js +7 -1
- package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
- package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
- package/dist/daemon/reconcilers/live-obligation.js +11 -0
- package/dist/pages/bundle.css +1 -0
- package/dist/pages/bundle.js +1347 -0
- package/dist/pages/comments.d.ts +34 -0
- package/dist/pages/comments.js +72 -0
- package/dist/pages/elements/cards.d.ts +17 -0
- package/dist/pages/elements/cards.js +785 -0
- package/dist/pages/elements/chart.d.ts +1 -0
- package/dist/pages/elements/chart.js +648 -0
- package/dist/pages/elements/options.d.ts +21 -0
- package/dist/pages/elements/options.js +604 -0
- package/dist/pages/elements/pages.d.ts +24 -0
- package/dist/pages/elements/pages.js +355 -0
- package/dist/pages/elements/slot.d.ts +21 -0
- package/dist/pages/elements/slot.js +105 -0
- package/dist/pages/elements/table.d.ts +19 -0
- package/dist/pages/elements/table.js +906 -0
- package/dist/pages/elements/text.d.ts +25 -0
- package/dist/pages/elements/text.js +951 -0
- package/dist/pages/entry.d.ts +8 -0
- package/dist/pages/entry.js +8 -0
- package/dist/pages/host.d.ts +132 -0
- package/dist/pages/host.js +149 -0
- package/dist/pages/register.d.ts +2 -0
- package/dist/pages/register.js +3 -0
- package/dist/pages/slot-config.d.ts +36 -0
- package/dist/pages/slot-config.js +50 -0
- package/dist/pages/types.d.ts +98 -0
- package/dist/pages/types.js +23 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
- package/dist/pi-extensions/canvas-bash-valve.js +25 -7
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/package.json +8 -8
- package/runtime.lock.json +156 -101
- package/scripts/install-runtime.mjs +172 -5
- package/dist/clients/inbox/deck-adapter.d.ts +0 -29
- package/dist/clients/inbox/deck-adapter.js +0 -63
- package/dist/core/human/deck-factories.d.ts +0 -8
- package/dist/core/human/deck-factories.js +0 -15
- package/dist/core/human/deck-schema.d.ts +0 -70
- package/dist/core/human/deck-schema.js +0 -92
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
const home = mkdtempSync(join(tmpdir(), 'crtr-page-tickets-'));
|
|
7
|
+
process.env['CRTR_HOME'] = home;
|
|
8
|
+
const { atomicWriteJson, pageManifestPath, pagePath, progressPath, responsePath } = await import('../convention.js');
|
|
9
|
+
const { claimTicket } = await import('../claim.js');
|
|
10
|
+
const { scanInbox, scanPageHistory } = await import('../scan.js');
|
|
11
|
+
const { ticketDir } = await import('../root.js');
|
|
12
|
+
const { cancelTicket, completePage, readPageProgress, readPageTicket, readTicketResult, submitPage, takeoverAndCompletePage, writePageProgress, } = await import('../tickets.js');
|
|
13
|
+
const sources = mkdtempSync(join(tmpdir(), 'crtr-page-sources-'));
|
|
14
|
+
function sourceFile(name, body) {
|
|
15
|
+
const path = join(sources, name);
|
|
16
|
+
writeFileSync(path, body);
|
|
17
|
+
return path;
|
|
18
|
+
}
|
|
19
|
+
const askPage = sourceFile('ask.md', `---
|
|
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
|
+
`);
|
|
31
|
+
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
|
+
return submitPage({ dir: ticketDir(id), sourceFile: source, dialect, source: { nodeId, blockedSince } }).dir;
|
|
34
|
+
}
|
|
35
|
+
test('submitPage publishes the authored source, the derived manifest and the blockedSince stamp', () => {
|
|
36
|
+
const submitted = submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'md', source: { nodeId: 'node-1' } });
|
|
37
|
+
assert.equal(submitted.kind, 'page');
|
|
38
|
+
assert.equal(submitted.id, 'ask');
|
|
39
|
+
assert.equal(readFileSync(pagePath(submitted.dir, 'md'), 'utf8'), readFileSync(askPage, 'utf8'));
|
|
40
|
+
const manifest = JSON.parse(readFileSync(pageManifestPath(submitted.dir), 'utf8'));
|
|
41
|
+
assert.equal(manifest['schema'], 'crtr.page/v1');
|
|
42
|
+
assert.equal(manifest['document'], 'page.md');
|
|
43
|
+
assert.equal(manifest['placement'], 'inline');
|
|
44
|
+
const source = manifest['source'];
|
|
45
|
+
assert.equal(source.nodeId, 'node-1');
|
|
46
|
+
assert.ok(Date.parse(source.blockedSince) > 0, 'submission stamps blockedSince');
|
|
47
|
+
assert.throws(() => submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'md' }), /already exists/);
|
|
48
|
+
const listed = scanInbox().find((item) => item.id === 'ask');
|
|
49
|
+
assert.equal(listed?.kind, 'page');
|
|
50
|
+
assert.equal(listed?.kind === 'page' ? listed.awaitsResponse : undefined, true);
|
|
51
|
+
assert.deepEqual(listed?.kind === 'page' ? listed.slotKinds : undefined, ['options']);
|
|
52
|
+
});
|
|
53
|
+
test('progress round-trips through validation and rejects malformed saved work', () => {
|
|
54
|
+
const dir = ticketDir('ask');
|
|
55
|
+
assert.equal(readPageProgress(dir), null, 'no progress file reads as null');
|
|
56
|
+
writePageProgress(dir, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
|
|
57
|
+
assert.deepEqual(readPageProgress(dir), { rollout: { selectedOptionIds: ['canary'], comments: [] } });
|
|
58
|
+
assert.throws(() => writePageProgress(dir, { rollout: { selectedOptionIds: ['nope'], comments: [] } }), /nope/);
|
|
59
|
+
assert.throws(() => writePageProgress(dir, { unknown: { selectedOptionIds: [], comments: [] } }), /unknown/);
|
|
60
|
+
atomicWriteJson(progressPath(dir), { kind: 'page', responses: { rollout: 'not an object' }, savedAt: new Date().toISOString() });
|
|
61
|
+
assert.throws(() => readPageProgress(dir), /malformed/, 'malformed progress is an error, never silently discarded');
|
|
62
|
+
writePageProgress(dir, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
|
|
63
|
+
});
|
|
64
|
+
test('readPageTicket derives pending, resolved and canceled state and carries the document', () => {
|
|
65
|
+
const dir = ticketDir('ask');
|
|
66
|
+
const pending = readPageTicket(dir);
|
|
67
|
+
assert.equal(pending.state, 'pending');
|
|
68
|
+
assert.equal(pending.manifest.title, 'Rollout scope');
|
|
69
|
+
assert.ok(pending.document.includes('Which rollout do you want?'));
|
|
70
|
+
assert.deepEqual(pending.progress, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
|
|
71
|
+
const claim = claimTicket(dir);
|
|
72
|
+
assert.notEqual(claim, null, 'a page ticket is claimable');
|
|
73
|
+
assert.throws(() => completePage(dir, { rollout: { selectedOptionIds: [], comments: [] } }, claim.token), /rollout/, 'single-select requires one selection');
|
|
74
|
+
const completed = completePage(dir, { rollout: { selectedOptionIds: ['full'], comments: [] }, }, claim.token);
|
|
75
|
+
assert.equal(completed.won, true);
|
|
76
|
+
const written = JSON.parse(readFileSync(responsePath(dir), 'utf8'));
|
|
77
|
+
assert.equal(written['schema'], 'crtr.page-response/v1');
|
|
78
|
+
assert.equal(written['kind'], 'page');
|
|
79
|
+
assert.deepEqual(written['responses'], { rollout: { selectedOptionIds: ['full'], comments: [] } });
|
|
80
|
+
assert.equal(written['summary'], 'rollout: Full');
|
|
81
|
+
assert.equal(readTicketResult(dir)?.kind, 'page', 'the strict decoder accepts the page result');
|
|
82
|
+
assert.equal(existsSync(progressPath(dir)), false, 'completion clears owned work');
|
|
83
|
+
const resolved = readPageTicket(dir);
|
|
84
|
+
assert.equal(resolved.state, 'resolved');
|
|
85
|
+
assert.equal(resolved.progress, null);
|
|
86
|
+
assert.equal(scanInbox().some((item) => item.id === 'ask'), false, 'a resolved page leaves the pending list');
|
|
87
|
+
const canceledDir = submit('canceled', askPage, 'md', '2026-01-03T00:00:00.000Z');
|
|
88
|
+
cancelTicket(canceledDir, { reason: 'no longer needed' });
|
|
89
|
+
assert.equal(readPageTicket(canceledDir).state, 'canceled');
|
|
90
|
+
});
|
|
91
|
+
test('a display page acknowledges with the empty responses map', () => {
|
|
92
|
+
const dir = submit('display', displayPage, 'html', '2026-01-04T00:00:00.000Z');
|
|
93
|
+
const ticket = readPageTicket(dir);
|
|
94
|
+
assert.equal(ticket.manifest.dialect, 'html');
|
|
95
|
+
assert.equal(ticket.manifest.slots.length, 0);
|
|
96
|
+
assert.equal(scanInbox().find((item) => item.id === 'display')?.kind === 'page', true);
|
|
97
|
+
const acknowledged = takeoverAndCompletePage(dir, {}, { host: 'crtrd', pid: process.pid });
|
|
98
|
+
assert.equal(acknowledged.won, true);
|
|
99
|
+
const written = JSON.parse(readFileSync(responsePath(dir), 'utf8'));
|
|
100
|
+
assert.equal(written['schema'], 'crtr.page-response/v1');
|
|
101
|
+
assert.deepEqual(written['responses'], {});
|
|
102
|
+
assert.equal(written['summary'], '');
|
|
103
|
+
});
|
|
104
|
+
test('scanPageHistory returns every page of one node, oldest first, regardless of terminal file', () => {
|
|
105
|
+
submit('older', askPage, 'md', '2026-01-01T00:00:00.000Z');
|
|
106
|
+
submit('other-node', askPage, 'md', '2026-01-02T00:00:00.000Z', 'node-2');
|
|
107
|
+
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']);
|
|
110
|
+
assert.deepEqual(scanPageHistory('node-2').map((entry) => entry.id), ['other-node']);
|
|
111
|
+
assert.deepEqual(scanPageHistory('node-missing'), []);
|
|
112
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { derivePageManifest, parsePageSource } from '../page.js';
|
|
7
|
+
import { isResponseBearingSlot, validatePageResponses, validatePartialPageResponses } from '../page-schema.js';
|
|
8
|
+
function withSource(name, source, run) {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-page-test-'));
|
|
10
|
+
try {
|
|
11
|
+
const path = join(dir, name);
|
|
12
|
+
writeFileSync(path, source);
|
|
13
|
+
run(path);
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const htmlHead = '<!doctype html><html><head><meta name="crtr-page-title" content="Test"></head><body>';
|
|
20
|
+
const htmlTail = '</body></html>';
|
|
21
|
+
const optionsScript = '<script type="application/json">{"mode":"single","options":[{"id":"yes","label":"Yes"}]}</script>';
|
|
22
|
+
function deriveHtml(body, head = htmlHead) {
|
|
23
|
+
let manifest;
|
|
24
|
+
withSource('input.html', `${head}${body}${htmlTail}`, (path) => { manifest = derivePageManifest(path, 'html'); });
|
|
25
|
+
return manifest;
|
|
26
|
+
}
|
|
27
|
+
function expectHtmlError(body, pattern, head = htmlHead) {
|
|
28
|
+
withSource('input.html', `${head}${body}${htmlTail}`, (path) => assert.throws(() => derivePageManifest(path, 'html'), pattern));
|
|
29
|
+
}
|
|
30
|
+
test('md dialect derives a normalized manifest and preserves top-level slot scanning', () => {
|
|
31
|
+
const source = `---
|
|
32
|
+
title: Rollout
|
|
33
|
+
subtitle: Choose carefully
|
|
34
|
+
placement: inline
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
Before.
|
|
38
|
+
|
|
39
|
+
\`\`\`\`markdown
|
|
40
|
+
\`\`\`slot:text
|
|
41
|
+
{"id":"nested","initialText":"not a slot"}
|
|
42
|
+
\`\`\`
|
|
43
|
+
\`\`\`\`
|
|
44
|
+
|
|
45
|
+
\`\`\`slot:table
|
|
46
|
+
{"columns":[{"id":"day","label":"Day"}],"rows":[{"id":"r1","cells":{"day":"Mon"}}]}
|
|
47
|
+
\`\`\`
|
|
48
|
+
|
|
49
|
+
\`\`\`slot:options
|
|
50
|
+
{"id":"rollout","mode":"single","options":[{"id":"yes","label":"Yes"}]}
|
|
51
|
+
\`\`\`
|
|
52
|
+
`;
|
|
53
|
+
withSource('input.md', source, (path) => {
|
|
54
|
+
const manifest = derivePageManifest(path, 'md');
|
|
55
|
+
// A display-only table's schema defaults must survive the manifest→source round trip.
|
|
56
|
+
assert.deepEqual(parsePageSource(source, manifest).at(-1), { kind: 'slot', slotIndex: 1 });
|
|
57
|
+
assert.equal(manifest.title, 'Rollout');
|
|
58
|
+
assert.equal(manifest.subtitle, 'Choose carefully');
|
|
59
|
+
assert.equal(manifest.placement, 'inline');
|
|
60
|
+
assert.equal(manifest.document, 'page.md');
|
|
61
|
+
assert.equal(manifest.steps, 1);
|
|
62
|
+
assert.deepEqual(manifest.slots.map(({ id, kind, step }) => ({ id, kind, step })), [{ id: undefined, kind: 'table', step: 0 }, { id: 'rollout', kind: 'options', step: 0 }]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
test('html dialect derives document-order slots and wrapper steps', () => {
|
|
66
|
+
const body = `<crtr-pages><crtr-page><section><crtr-options id="choice">${optionsScript}</crtr-options></section></crtr-page><crtr-page><crtr-chart><script type="application/json">{"kind":"bar","x":"day","y":[{"field":"count"}]}</script></crtr-chart></crtr-page></crtr-pages>`;
|
|
67
|
+
const manifest = deriveHtml(body, '<!doctype html><html placement="inline"><head><meta name="crtr-page-title" content="HTML"><meta name="crtr-page-subtitle" content="Two steps"><meta name="crtr-page-placement" content="inline"></head><body>');
|
|
68
|
+
assert.equal(manifest.title, 'HTML');
|
|
69
|
+
assert.equal(manifest.placement, 'inline');
|
|
70
|
+
assert.equal(manifest.steps, 2);
|
|
71
|
+
assert.deepEqual(manifest.slots.map(({ kind, step }) => ({ kind, step })), [{ kind: 'options', step: 0 }, { kind: 'chart', step: 1 }]);
|
|
72
|
+
});
|
|
73
|
+
test('html wrapper rejects multiple crtr-pages', () => expectHtmlError('<crtr-pages><crtr-page></crtr-page></crtr-pages><crtr-pages><crtr-page></crtr-page></crtr-pages>', /more than one <crtr-pages>/));
|
|
74
|
+
test('html wrapper rejects crtr-page outside crtr-pages', () => expectHtmlError('<crtr-page></crtr-page>', /<crtr-page> outside <crtr-pages>/));
|
|
75
|
+
test('html wrapper rejects empty crtr-pages', () => expectHtmlError('<crtr-pages></crtr-pages>', /empty <crtr-pages>/));
|
|
76
|
+
test('html wrapper rejects a response-bearing element outside a step with the fix', () => expectHtmlError(`<crtr-options id="choice">${optionsScript}</crtr-options>`, /response-bearing element <crtr-options>[\s\S]*<crtr-pages><crtr-page>…<\/crtr-page><\/crtr-pages>/));
|
|
77
|
+
test('html wrapper rejects nested crtr-pages', () => expectHtmlError('<crtr-pages><crtr-page><crtr-pages><crtr-page></crtr-page></crtr-pages></crtr-page></crtr-pages>', /nested <crtr-pages>/));
|
|
78
|
+
test('md front matter names unknown, empty-title, and placement errors', () => {
|
|
79
|
+
for (const [source, pattern] of [
|
|
80
|
+
['---\ntitle: T\nother: no\n---\n', /unknown page front matter key "other"/],
|
|
81
|
+
['---\ntitle: \n---\n', /title is required and must be nonempty/],
|
|
82
|
+
['---\ntitle: T\nplacement: sidebar\n---\n', /placement must be exactly inline or panel/],
|
|
83
|
+
])
|
|
84
|
+
withSource('input.md', source, (path) => assert.throws(() => derivePageManifest(path, 'md'), pattern));
|
|
85
|
+
});
|
|
86
|
+
test('html metadata enforces cardinality, known names, and placement content', () => {
|
|
87
|
+
const duplicateTitle = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-title" content="B"></head><body>';
|
|
88
|
+
const duplicateSubtitle = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-subtitle" content="A"><meta name="crtr-page-subtitle" content="B"></head><body>';
|
|
89
|
+
const unknown = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-extra" content="x"></head><body>';
|
|
90
|
+
const badPlacement = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-placement" content="drawer"></head><body>';
|
|
91
|
+
expectHtmlError('', /Expected exactly one <meta name="crtr-page-title">, found 2/, duplicateTitle);
|
|
92
|
+
expectHtmlError('', /Expected at most one <meta name="crtr-page-subtitle">, found 2/, duplicateSubtitle);
|
|
93
|
+
expectHtmlError('', /unknown page metadata element <meta name="crtr-page-extra">/, unknown);
|
|
94
|
+
expectHtmlError('', /content must be exactly inline or panel/, badPlacement);
|
|
95
|
+
});
|
|
96
|
+
test('the response-bearing predicate handles table selection and catalog edges', () => {
|
|
97
|
+
const slot = (kind, config, unvalidated) => ({ kind, step: 0, config, ...(unvalidated ? { unvalidated } : {}) });
|
|
98
|
+
assert.equal(isResponseBearingSlot(slot('table', { columns: [{ id: 'c', label: 'C' }], rowSelect: 'none', columnSelect: 'none' })), false);
|
|
99
|
+
assert.equal(isResponseBearingSlot(slot('table', { columns: [{ id: 'c', label: 'C' }], rowSelect: 'single', columnSelect: 'none' })), true);
|
|
100
|
+
assert.equal(isResponseBearingSlot(slot('chart', { kind: 'bar', x: 'x', y: [{ field: 'y' }] })), false);
|
|
101
|
+
assert.equal(isResponseBearingSlot(slot('product-kind', {}, true)), true);
|
|
102
|
+
});
|
|
103
|
+
test('final and partial response validation share slot-addressed rules', () => {
|
|
104
|
+
const manifest = deriveHtml(`<crtr-pages><crtr-page><crtr-options id="choice">${optionsScript}</crtr-options><crtr-text id="note"><script type="application/json">{"initialText":""}</script></crtr-text></crtr-page></crtr-pages>`);
|
|
105
|
+
assert.deepEqual(validatePartialPageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] } }), { choice: { selectedOptionIds: ['yes'], comments: [] } });
|
|
106
|
+
assert.throws(() => validatePageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] } }), /missing response-bearing slot id "note"/);
|
|
107
|
+
assert.deepEqual(validatePageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] }, note: { text: '', edited: false, comments: [] } }), { choice: { selectedOptionIds: ['yes'], comments: [] }, note: { text: '', edited: false, comments: [] } });
|
|
108
|
+
assert.throws(() => validatePartialPageResponses(manifest, { choice: { selectedOptionIds: ['maybe'], comments: [] } }), /unknown option id "maybe"/);
|
|
109
|
+
assert.throws(() => validatePartialPageResponses(manifest, { extra: {} }), /unknown or non-response-bearing slot id "extra"/);
|
|
110
|
+
});
|
|
111
|
+
test('PageSourceProjection associates leading, interstitial, and trailing prose', () => {
|
|
112
|
+
const source = `---
|
|
113
|
+
title: Projection
|
|
114
|
+
---
|
|
115
|
+
Leading prose.
|
|
116
|
+
|
|
117
|
+
\`\`\`slot:text
|
|
118
|
+
{"id":"first","initialText":""}
|
|
119
|
+
\`\`\`
|
|
120
|
+
|
|
121
|
+
Interstitial prose.
|
|
122
|
+
|
|
123
|
+
\`\`\`slot:text
|
|
124
|
+
{"id":"second","initialText":""}
|
|
125
|
+
\`\`\`
|
|
126
|
+
|
|
127
|
+
Trailing prose.
|
|
128
|
+
`;
|
|
129
|
+
withSource('input.md', source, (path) => {
|
|
130
|
+
const manifest = derivePageManifest(path, 'md');
|
|
131
|
+
assert.deepEqual(parsePageSource(source, manifest), [
|
|
132
|
+
{ kind: 'prose', markdown: 'Leading prose.' },
|
|
133
|
+
{ kind: 'slot', slotIndex: 0 },
|
|
134
|
+
{ kind: 'prose', markdown: 'Interstitial prose.' },
|
|
135
|
+
{ kind: 'slot', slotIndex: 1 },
|
|
136
|
+
{ kind: 'prose', markdown: 'Trailing prose.' },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -3,19 +3,29 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync,
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { submitPage, submitReview, completePage, cancelTicket, readTicketResult } from '../../tickets.js';
|
|
7
7
|
import { claimTicket } from '../../claim.js';
|
|
8
8
|
import { scanInbox } from '../../scan.js';
|
|
9
9
|
import { atomicWriteJson, progressPath, responsePath } from '../../convention.js';
|
|
10
|
-
import { validateDeck } from '../../deck-schema.js';
|
|
11
10
|
import { ticketDir } from '../../root.js';
|
|
12
11
|
const temp = mkdtempSync(join(tmpdir(), 'crtr-inbox-core-'));
|
|
13
12
|
process.env['CRTR_HOME'] = temp;
|
|
14
13
|
const source = join(temp, 'source.md');
|
|
15
14
|
writeFileSync(source, '# Source\n');
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const pageSource = join(temp, 'page.md');
|
|
16
|
+
writeFileSync(pageSource, [
|
|
17
|
+
'---',
|
|
18
|
+
'title: A page',
|
|
19
|
+
'subtitle: Go? requires your attention.',
|
|
20
|
+
'---',
|
|
21
|
+
'',
|
|
22
|
+
'```slot:text',
|
|
23
|
+
'{ "id": "go", "initialText": "", "editable": true }',
|
|
24
|
+
'```',
|
|
25
|
+
'',
|
|
26
|
+
].join('\n'));
|
|
27
|
+
const page = (id, blockedSince) => submitPage({ dir: ticketDir(id), sourceFile: pageSource, dialect: 'md', source: { blockedSince, nodeId: 'node-1' } });
|
|
28
|
+
const answer = { go: { text: 'yes', edited: true, comments: [] } };
|
|
19
29
|
async function waitFor(paths) {
|
|
20
30
|
for (let attempts = 0; !paths.every(existsSync); attempts++) {
|
|
21
31
|
if (attempts > 1_000)
|
|
@@ -23,60 +33,60 @@ async function waitFor(paths) {
|
|
|
23
33
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1));
|
|
24
34
|
}
|
|
25
35
|
}
|
|
26
|
-
|
|
27
|
-
const
|
|
36
|
+
page('z-last', '2025-01-01T00:00:00.000Z');
|
|
37
|
+
const newPage = page('a-first', '2025-01-02T00:00:00.000Z');
|
|
28
38
|
const review = submitReview({ dir: ticketDir('review'), review: { file: source, title: 'Review source', subtitle: 'Review findings require your decision.', source: { nodeId: 'node-1' }, blockedSince: '2025-01-02T00:00:00.000Z' } });
|
|
29
39
|
assert.throws(() => submitReview({ dir: ticketDir('missing-review-subtitle'), review: { file: source, title: 'Missing subtitle', source: {} } }), /subtitle/, 'a review without an authored subtitle is invalid');
|
|
30
40
|
assert.throws(() => submitReview({ dir: ticketDir('bad-review'), review: { file: join(temp, 'missing.md'), title: 'Bad', subtitle: 'This invalid review must be rejected.', source: {} } }), /existing absolute path/);
|
|
31
41
|
assert.equal(existsSync(ticketDir('bad-review')), false, 'invalid submission does not strand an id');
|
|
32
42
|
const prepared = ticketDir('prepared');
|
|
33
43
|
mkdirSync(prepared);
|
|
34
|
-
writeFileSync(join(prepared, '
|
|
35
|
-
const preparedTicket =
|
|
44
|
+
writeFileSync(join(prepared, 'asset.png'), 'prepared asset');
|
|
45
|
+
const preparedTicket = submitPage({ dir: prepared, sourceFile: pageSource, dialect: 'md', source: { blockedSince: '2025-01-02T00:00:00.000Z', nodeId: 'node-1' } });
|
|
36
46
|
assert.equal(preparedTicket.dir, realpathSync(prepared), 'submission accepts a crouter-precreated direct child and colocated assets');
|
|
37
47
|
// Same timestamp is stable by id; progress is resumable work, never a hidden lease.
|
|
38
|
-
atomicWriteJson(progressPath(
|
|
48
|
+
atomicWriteJson(progressPath(newPage.dir), { kind: 'page', responses: {}, savedAt: new Date().toISOString() });
|
|
39
49
|
const scanned = scanInbox();
|
|
40
50
|
assert.deepEqual(scanned.map((item) => item.id), ['a-first', 'prepared', 'review', 'z-last']);
|
|
41
51
|
assert.equal(scanned.find((item) => item.id === 'a-first')?.claim, undefined);
|
|
42
52
|
assert.equal(scanned.find((item) => item.id === 'review')?.kind, 'review');
|
|
43
53
|
assert.equal(scanned.find((item) => item.id === 'review')?.subtitle, 'Review findings require your decision.', 'review scanning exposes its required subtitle');
|
|
44
|
-
const firstClaim = claimTicket(
|
|
54
|
+
const firstClaim = claimTicket(newPage.dir);
|
|
45
55
|
assert.notEqual(firstClaim, null);
|
|
46
|
-
assert.equal(claimTicket(
|
|
56
|
+
assert.equal(claimTicket(newPage.dir), null, 'a second controller cannot claim a live ticket');
|
|
47
57
|
// A separate process writes a genuine remote claim whose heartbeat is already
|
|
48
58
|
// stale, then exits; this process recovers it — cross-process stale recovery.
|
|
49
|
-
const staleDir =
|
|
59
|
+
const staleDir = page('stale', '2025-01-03T00:00:00.000Z').dir;
|
|
50
60
|
const staleClaimReady = join(temp, 'stale-claim-ready');
|
|
51
61
|
await new Promise((resolveWorker, rejectWorker) => spawn(process.execPath, ['--conditions=crtr-src', '--import', 'tsx', 'src/core/human/__tests__/inbox-stale-claim-worker.ts', staleDir, staleClaimReady], { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'inherit'] }).once('exit', (code) => code === 0 ? resolveWorker() : rejectWorker(new Error('stale-claim worker failed'))));
|
|
52
62
|
assert.notEqual(JSON.parse(readFileSync(staleClaimReady, 'utf8')), null, 'a separate process wrote a stale remote claim on disk');
|
|
53
63
|
assert.notEqual(claimTicket(staleDir), null, 'a remote claim written by another process recovers once its heartbeat has expired');
|
|
54
|
-
const malformedDir =
|
|
64
|
+
const malformedDir = page('malformed-claim', '2025-01-03T00:00:00.000Z').dir;
|
|
55
65
|
writeFileSync(join(malformedDir, 'claim.json'), '{');
|
|
56
66
|
assert.notEqual(claimTicket(malformedDir), null, 'partial claim crash artifact recovers');
|
|
57
|
-
const final =
|
|
67
|
+
const final = completePage(newPage.dir, answer, firstClaim.token);
|
|
58
68
|
assert.equal(final.won, true);
|
|
59
|
-
assert.equal(readTicketResult(
|
|
60
|
-
assert.equal(claimTicket(
|
|
69
|
+
assert.equal(readTicketResult(newPage.dir)?.kind, 'page');
|
|
70
|
+
assert.equal(claimTicket(newPage.dir), null, 'terminal tickets cannot be claimed');
|
|
61
71
|
const exactResults = [
|
|
62
|
-
{ schema: '
|
|
72
|
+
{ schema: 'crtr.page-response/v1', kind: 'page', responses: {}, summary: '', completedAt: '2025-01-04T00:00:00.000Z' },
|
|
63
73
|
{ schema: 'humanloop.review-response/v1', kind: 'review', result: { file: source, submitted: true, approved: true, comments: [], submittedAt: '2025-01-04T00:00:00.000Z', savedAt: '2025-01-04T00:00:00.000Z', reviewId: 'review-1', changed: false, baselineSha256: 'baseline', approvedSha256: 'approved', companionNodeId: 'companion-1', commentsTotal: 0, commentsUnresolved: 0 }, completedAt: '2025-01-04T00:00:00.000Z' },
|
|
64
74
|
{ schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: '2025-01-04T00:00:00.000Z', reason: 'stop', actor: 'test' },
|
|
65
75
|
];
|
|
66
76
|
for (const [index, result] of exactResults.entries()) {
|
|
67
|
-
const dir =
|
|
77
|
+
const dir = page(`strict-${index}`, '2025-01-03T00:00:00.000Z').dir;
|
|
68
78
|
writeFileSync(responsePath(dir), JSON.stringify(result));
|
|
69
79
|
assert.equal(readTicketResult(dir)?.kind, result.kind, `strict decoder accepts exact ${result.kind} result`);
|
|
70
80
|
}
|
|
71
81
|
const validFeedback = exactResults[1].result;
|
|
72
82
|
const iso = '2025-01-04T00:00:00.000Z';
|
|
73
83
|
const rejectFixtures = [
|
|
74
|
-
{ why: 'a wrong kind for a known schema', result: { schema: '
|
|
75
|
-
{ why: 'an unknown schema id', result: { schema: '
|
|
76
|
-
{ why: 'a
|
|
77
|
-
{ why: 'a
|
|
78
|
-
{ why: 'a
|
|
79
|
-
{ why: 'a
|
|
84
|
+
{ why: 'a wrong kind for a known schema', result: { schema: 'crtr.page-response/v1', kind: 'review', responses: {}, summary: '', completedAt: iso } },
|
|
85
|
+
{ why: 'an unknown schema id', result: { schema: 'crtr.page-response/v9', kind: 'page', responses: {}, summary: '', completedAt: iso } },
|
|
86
|
+
{ why: 'a page missing its required completedAt', result: { schema: 'crtr.page-response/v1', kind: 'page', responses: {}, summary: '' } },
|
|
87
|
+
{ why: 'a page with a non-ISO completedAt', result: { schema: 'crtr.page-response/v1', kind: 'page', responses: {}, summary: '', completedAt: 'now' } },
|
|
88
|
+
{ why: 'a page whose responses are not a slot-keyed map', result: { schema: 'crtr.page-response/v1', kind: 'page', responses: [{ id: 'go' }], summary: '', completedAt: iso } },
|
|
89
|
+
{ why: 'a page with an extra top-level field', result: { schema: 'crtr.page-response/v1', kind: 'page', responses: {}, summary: '', completedAt: iso, extra: true } },
|
|
80
90
|
{ why: 'a review missing its required result', result: { schema: 'humanloop.review-response/v1', kind: 'review', completedAt: iso } },
|
|
81
91
|
{ why: 'a review whose nested result is not submitted', result: { schema: 'humanloop.review-response/v1', kind: 'review', result: { ...validFeedback, submitted: false }, completedAt: iso } },
|
|
82
92
|
{ why: 'a review with incomplete comment columns', result: { schema: 'humanloop.review-response/v1', kind: 'review', result: { ...validFeedback, approved: true, comments: [{ id: 'c1', line: 1, endLine: 1, colStart: 1, lineText: '# Source', comment: 'x', createdAt: iso }] }, completedAt: iso } },
|
|
@@ -87,29 +97,29 @@ const rejectFixtures = [
|
|
|
87
97
|
{ why: 'a canceled result with an extra top-level field', result: { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: iso, extra: true } },
|
|
88
98
|
];
|
|
89
99
|
for (const [index, fixture] of rejectFixtures.entries()) {
|
|
90
|
-
const dir =
|
|
100
|
+
const dir = page(`strict-reject-${index}`, '2025-01-03T00:00:00.000Z').dir;
|
|
91
101
|
writeFileSync(responsePath(dir), JSON.stringify(fixture.result));
|
|
92
102
|
assert.equal(readTicketResult(dir), null, `strict decoder rejects ${fixture.why}`);
|
|
93
103
|
}
|
|
94
104
|
const protocolDir = ticketDir('stale-protocol');
|
|
95
105
|
mkdirSync(protocolDir);
|
|
96
106
|
writeFileSync(responsePath(protocolDir), '{}');
|
|
97
|
-
assert.throws(() =>
|
|
98
|
-
const cancelDir =
|
|
107
|
+
assert.throws(() => submitPage({ dir: protocolDir, sourceFile: pageSource, dialect: 'md' }), /protocol state/, 'precreated assets may not include humanloop lifecycle state');
|
|
108
|
+
const cancelDir = page('cancel-race', '2025-01-03T00:00:00.000Z').dir;
|
|
99
109
|
const canceled = cancelTicket(cancelDir, { reason: 'stop' });
|
|
100
110
|
assert.equal(canceled.status, 'canceled');
|
|
101
111
|
assert.equal(cancelTicket(cancelDir).status, 'already_resolved', 'first final writer wins');
|
|
102
|
-
const finalRaceDir =
|
|
112
|
+
const finalRaceDir = page('final-race', '2025-01-03T00:00:00.000Z').dir;
|
|
103
113
|
const finalRaceClaim = claimTicket(finalRaceDir);
|
|
104
114
|
const finalBarrier = join(temp, 'final-race-barrier');
|
|
105
115
|
const finalReady = join(temp, 'final-race-ready');
|
|
106
116
|
const cancelWorker = spawn(process.execPath, ['--conditions=crtr-src', '--import', 'tsx', 'src/core/human/__tests__/inbox-cancel-worker.ts', finalRaceDir, finalBarrier, finalReady], { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
107
117
|
await waitFor([finalReady]);
|
|
108
118
|
writeFileSync(finalBarrier, 'go');
|
|
109
|
-
const finalRace =
|
|
119
|
+
const finalRace = completePage(finalRaceDir, answer, finalRaceClaim.token);
|
|
110
120
|
await new Promise((resolveWorker, rejectWorker) => cancelWorker.once('exit', (code) => code === 0 ? resolveWorker() : rejectWorker(new Error('cancel worker failed'))));
|
|
111
|
-
assert.equal(readTicketResult(finalRaceDir)?.kind, finalRace.won ? '
|
|
112
|
-
const concurrentDir =
|
|
121
|
+
assert.equal(readTicketResult(finalRaceDir)?.kind, finalRace.won ? 'page' : 'canceled', 'submit and cancellation race has one canonical winner');
|
|
122
|
+
const concurrentDir = page('concurrent-claim', '2025-01-03T00:00:00.000Z').dir;
|
|
113
123
|
const claimBarrier = join(temp, 'claim-race-barrier');
|
|
114
124
|
// Both workers block on this release until the parent has read both results, so
|
|
115
125
|
// neither winner's pid dies during the peer's attempt (a dead same-host pid is
|
package/dist/core/human/claim.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { claimPath,
|
|
4
|
+
import { claimPath, pageManifestPath, responsePath, reviewPath } from './convention.js';
|
|
5
5
|
import { readJsonOrNull } from '../fs-utils.js';
|
|
6
6
|
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
7
7
|
const REMOTE_STALE_MS = 30_000;
|
|
@@ -30,7 +30,7 @@ export function isStaleClaim(claim, now = Date.now(), localHost = hostname()) {
|
|
|
30
30
|
/** Acquire a visible claim only for a still-pending ticket. Malformed crash artifacts are stale claims. */
|
|
31
31
|
export function claimTicket(dir, opts = {}) {
|
|
32
32
|
return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => {
|
|
33
|
-
if (existsSync(responsePath(dir)) || (!existsSync(
|
|
33
|
+
if (existsSync(responsePath(dir)) || (!existsSync(pageManifestPath(dir)) && !existsSync(reviewPath(dir))))
|
|
34
34
|
return null;
|
|
35
35
|
const path = claimPath(dir);
|
|
36
36
|
const existing = readTicketClaim(dir);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type { PageManifest } from './page-schema.js';
|
|
2
|
+
/** The authored page source, colocated with its derived manifest in the bridge node directory. */
|
|
3
|
+
export declare function pagePath(dir: string, dialect: PageManifest['dialect']): string;
|
|
4
|
+
export declare function pageManifestPath(dir: string): string;
|
|
3
5
|
export declare function reviewPath(dir: string): string;
|
|
4
6
|
export declare function responsePath(dir: string): string;
|
|
5
7
|
export declare function progressPath(dir: string): string;
|
|
@@ -13,6 +15,4 @@ export declare function requireTicket(dir: string): string;
|
|
|
13
15
|
export declare function atomicWriteJson(path: string, value: unknown): void;
|
|
14
16
|
/** Publish one immutable JSON record without replacing an existing winner. */
|
|
15
17
|
export declare function publishJsonExclusive(path: string, value: unknown): boolean;
|
|
16
|
-
export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string, deck?: Deck): string;
|
|
17
|
-
export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
|
|
18
18
|
export declare function clearProgress(dir: string): void;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { existsSync, linkSync, mkdirSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
|
|
4
|
-
export function
|
|
3
|
+
/** The authored page source, colocated with its derived manifest in the bridge node directory. */
|
|
4
|
+
export function pagePath(dir, dialect) { return `${dir}/page.${dialect}`; }
|
|
5
|
+
export function pageManifestPath(dir) { return `${dir}/page.json`; }
|
|
5
6
|
export function reviewPath(dir) { return `${dir}/review.json`; }
|
|
6
7
|
export function responsePath(dir) { return `${dir}/response.json`; }
|
|
7
8
|
export function progressPath(dir) { return `${dir}/progress.json`; }
|
|
8
9
|
export function claimPath(dir) { return `${dir}/claim.json`; }
|
|
9
10
|
export function interactionState(dir) {
|
|
10
|
-
if (!existsSync(
|
|
11
|
+
if (!existsSync(pageManifestPath(dir)) && !existsSync(reviewPath(dir)))
|
|
11
12
|
return 'missing';
|
|
12
13
|
if (existsSync(responsePath(dir)))
|
|
13
14
|
return 'resolved';
|
|
@@ -18,8 +19,8 @@ export function isClaimed(dir) { return existsSync(claimPath(dir)); }
|
|
|
18
19
|
/** Resolve and verify a ticket directory without a registry. */
|
|
19
20
|
export function requireTicket(dir) {
|
|
20
21
|
const canonicalDir = realpathSync(dir);
|
|
21
|
-
if (!existsSync(
|
|
22
|
-
throw new Error('ticket directory has no
|
|
22
|
+
if (!existsSync(pageManifestPath(canonicalDir)) && !existsSync(reviewPath(canonicalDir))) {
|
|
23
|
+
throw new Error('ticket directory has no page or review');
|
|
23
24
|
}
|
|
24
25
|
return canonicalDir;
|
|
25
26
|
}
|
|
@@ -47,14 +48,6 @@ export function publishJsonExclusive(path, value) {
|
|
|
47
48
|
unlinkSync(tmp);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
|
-
export function writeResponse(dir, responses, completedAt, deck) {
|
|
51
|
-
const summary = deck === undefined ? '' : buildSummary(deck, responses);
|
|
52
|
-
atomicWriteJson(responsePath(dir), { schema: 'humanloop.response/v2', kind: 'deck', responses, summary, completedAt });
|
|
53
|
-
return responsePath(dir);
|
|
54
|
-
}
|
|
55
|
-
export function writeProgress(dir, responses) {
|
|
56
|
-
atomicWriteJson(progressPath(dir), { kind: 'deck', responses, savedAt: new Date().toISOString() });
|
|
57
|
-
}
|
|
58
51
|
export function clearProgress(dir) {
|
|
59
52
|
try {
|
|
60
53
|
unlinkSync(progressPath(dir));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Escape text for an HTML text node. */
|
|
2
|
+
export declare function escapeHtmlText(raw: string): string;
|
|
3
|
+
/** Escape a value for a double-quoted HTML attribute. */
|
|
4
|
+
export declare function escapeHtmlAttribute(raw: string): string;
|
|
5
|
+
/** Render one markdown document fragment as HTML. */
|
|
6
|
+
export declare function renderMarkdownToHtml(markdown: string): string;
|
|
7
|
+
/** Render inline markdown. Exported for callers rendering a single line. */
|
|
8
|
+
export declare function renderInline(source: string): string;
|