@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
|
@@ -4,16 +4,15 @@
|
|
|
4
4
|
// delivery is daemon-owned; no subprocess handler or delivery receipt exists.
|
|
5
5
|
import { test, before, beforeEach, afterEach, after } from 'node:test';
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
|
-
import { mkdtempSync, existsSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { ApiError, CrtrClient } from '../../../api/index.js';
|
|
11
11
|
import { createApiServer } from '../../../daemon/api/server.js';
|
|
12
12
|
import { apiSocketPath } from '../../canvas/paths.js';
|
|
13
|
-
import { validateDeck } from '../../human/deck-schema.js';
|
|
14
13
|
import { claimTicket, readTicketClaim } from '../../human/claim.js';
|
|
15
14
|
import { ticketDir } from '../../human/root.js';
|
|
16
|
-
import {
|
|
15
|
+
import { submitPage, submitReview } from '../../human/tickets.js';
|
|
17
16
|
import { spawnNode } from '../../runtime/nodes.js';
|
|
18
17
|
import { getNode, closeDb } from '../../canvas/index.js';
|
|
19
18
|
import { readInboxSince } from '../../feed/inbox.js';
|
|
@@ -63,6 +62,30 @@ async function expectBadResolve(nodeId, body) {
|
|
|
63
62
|
async function expectReviewSubmitGuidance(nodeId, body) {
|
|
64
63
|
await assert.rejects(() => client.request('POST', `/v1/human/tickets/${nodeId}/resolve`, body), (error) => error instanceof ApiError && error.status === 400 && error.code === 'invalid_request' && error.message.includes('POST /v1/human/reviews/:review_id/submit'));
|
|
65
64
|
}
|
|
65
|
+
/** Publish a one-text-slot page ticket into the bridge's ticket directory. */
|
|
66
|
+
function submitBridgePage(bridge) {
|
|
67
|
+
const dir = ticketDir(bridge);
|
|
68
|
+
const sourceDir = mkdtempSync(join(tmpdir(), 'crtr-e2e-page-'));
|
|
69
|
+
const sourceFile = join(sourceDir, 'page.md');
|
|
70
|
+
writeFileSync(sourceFile, [
|
|
71
|
+
'---',
|
|
72
|
+
'title: Ship it?',
|
|
73
|
+
'subtitle: Decide whether to ship now so the release can proceed.',
|
|
74
|
+
'---',
|
|
75
|
+
'',
|
|
76
|
+
'```slot:text',
|
|
77
|
+
'{ "id": "question", "initialText": "", "editable": true }',
|
|
78
|
+
'```',
|
|
79
|
+
'',
|
|
80
|
+
].join('\n'));
|
|
81
|
+
try {
|
|
82
|
+
submitPage({ dir, sourceFile, dialect: 'md' });
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rmSync(sourceDir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
return dir;
|
|
88
|
+
}
|
|
66
89
|
function reportBody(ref) {
|
|
67
90
|
const resolved = resolveRef(ref);
|
|
68
91
|
if (resolved === null || resolved.source !== 'report')
|
|
@@ -71,24 +94,17 @@ function reportBody(ref) {
|
|
|
71
94
|
}
|
|
72
95
|
test('malformed resolves leave the mounted ticket claim in place', async () => {
|
|
73
96
|
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}),
|
|
82
|
-
});
|
|
83
|
-
const deckClaim = claimTicket(deckDir);
|
|
84
|
-
assert.ok(deckClaim !== null);
|
|
85
|
-
for (const body of [{ responses: [null] }, { response: [] }, { responses: [], debug: true }]) {
|
|
86
|
-
await expectBadResolve(deckBridge, body);
|
|
87
|
-
assert.equal(readTicketClaim(deckDir)?.token, deckClaim.token, 'malformed deck input must not take over the live claim');
|
|
97
|
+
const pageBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
|
|
98
|
+
const pageDir = submitBridgePage(pageBridge);
|
|
99
|
+
const pageClaim = claimTicket(pageDir);
|
|
100
|
+
assert.ok(pageClaim !== null);
|
|
101
|
+
for (const body of [{ responses: [] }, { response: {} }, { responses: {}, debug: true }]) {
|
|
102
|
+
await expectBadResolve(pageBridge, body);
|
|
103
|
+
assert.equal(readTicketClaim(pageDir)?.token, pageClaim.token, 'malformed page input must not take over the live claim');
|
|
88
104
|
}
|
|
89
|
-
for (const body of [{ responses:
|
|
90
|
-
await expectReviewSubmitGuidance(
|
|
91
|
-
assert.equal(readTicketClaim(
|
|
105
|
+
for (const body of [{ responses: {}, feedback: {} }, { feedback: [] }]) {
|
|
106
|
+
await expectReviewSubmitGuidance(pageBridge, body);
|
|
107
|
+
assert.equal(readTicketClaim(pageDir)?.token, pageClaim.token, 'feedback-shaped input must not take over the live claim');
|
|
92
108
|
}
|
|
93
109
|
const reviewBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-review', lifecycle: 'terminal' }).node_id;
|
|
94
110
|
const reviewDir = ticketDir(reviewBridge);
|
|
@@ -97,22 +113,15 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
|
|
|
97
113
|
submitReview({ dir: reviewDir, review: { title: 'Review', subtitle: 'Review this file so implementation can proceed safely.', source: {}, file } });
|
|
98
114
|
const reviewClaim = claimTicket(reviewDir);
|
|
99
115
|
assert.ok(reviewClaim !== null);
|
|
100
|
-
await expectReviewSubmitGuidance(reviewBridge, { responses:
|
|
116
|
+
await expectReviewSubmitGuidance(reviewBridge, { responses: {} });
|
|
101
117
|
assert.equal(readTicketClaim(reviewDir)?.token, reviewClaim.token, 'review ticket resolve must not take over the live claim');
|
|
102
118
|
});
|
|
103
119
|
test('the ticket resolve route pushFinals the readable answer to the parent', async () => {
|
|
104
120
|
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
105
121
|
const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
|
|
106
|
-
const dir =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
deck: validateDeck({
|
|
110
|
-
title: 'Ship it?',
|
|
111
|
-
interactions: [{ id: 'question', title: 'Ship it?', subtitle: 'Decide whether to ship now so the release can proceed.', options: [], allowFreetext: true }],
|
|
112
|
-
}),
|
|
113
|
-
});
|
|
114
|
-
const result = await client.resolveHumanTicket(bridge, { responses: [{ id: 'question', freetext: 'yes, ship' }] });
|
|
115
|
-
assert.deepEqual(result, { delivered: true, kind: 'deck' });
|
|
122
|
+
const dir = submitBridgePage(bridge);
|
|
123
|
+
const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true, comments: [] } } });
|
|
124
|
+
assert.deepEqual(result, { delivered: true, kind: 'page' });
|
|
116
125
|
assert.equal(getNode(bridge)?.status, 'done', 'bridge finalized by crtrd');
|
|
117
126
|
const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
|
|
118
127
|
assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
|
|
@@ -8,6 +8,7 @@ import { tmpdir } from 'node:os';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { createServer } from 'node:net';
|
|
10
10
|
import { spawn as spawnProcess } from 'node:child_process';
|
|
11
|
+
import { findDaemonPids, stopDaemonProcess } from '../../../daemon/manage.js';
|
|
11
12
|
import { createNode, getNode, getRow, subscribersOf } from '../../canvas/canvas.js';
|
|
12
13
|
import { closeDb } from '../../canvas/db.js';
|
|
13
14
|
import { nodeDir } from '../../canvas/paths.js';
|
|
@@ -40,8 +41,9 @@ beforeEach(() => {
|
|
|
40
41
|
closeDb();
|
|
41
42
|
rmSync(home, { recursive: true, force: true });
|
|
42
43
|
});
|
|
43
|
-
after(() => {
|
|
44
|
+
after(async () => {
|
|
44
45
|
closeDb();
|
|
46
|
+
await Promise.all(findDaemonPids().map((pid) => stopDaemonProcess(pid)));
|
|
45
47
|
rmSync(home, { recursive: true, force: true });
|
|
46
48
|
});
|
|
47
49
|
test('managed child: parent on the spine + active subscription + provenance', () => {
|
|
@@ -18,6 +18,7 @@ import { closeDb } from '../canvas/db.js';
|
|
|
18
18
|
import { spawnNode } from '../runtime/nodes.js';
|
|
19
19
|
import { spawnChild } from '../runtime/spawn.js';
|
|
20
20
|
import { managedWorktreePath } from '../worktree.js';
|
|
21
|
+
import { findDaemonPids, stopDaemonProcess } from '../../daemon/manage.js';
|
|
21
22
|
let home;
|
|
22
23
|
let repoDir;
|
|
23
24
|
function run(args, cwd) {
|
|
@@ -41,8 +42,9 @@ beforeEach(() => {
|
|
|
41
42
|
rmSync(home, { recursive: true, force: true });
|
|
42
43
|
process.env['CRTR_HOME'] = home;
|
|
43
44
|
});
|
|
44
|
-
after(() => {
|
|
45
|
+
after(async () => {
|
|
45
46
|
closeDb();
|
|
47
|
+
await Promise.all(findDaemonPids().map((pid) => stopDaemonProcess(pid)));
|
|
46
48
|
rmSync(home, { recursive: true, force: true });
|
|
47
49
|
rmSync(repoDir, { recursive: true, force: true });
|
|
48
50
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ProviderAuthInteraction } from '@earendil-works/pi-ai';
|
|
2
2
|
import type { LoginDialogComponent } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
export type DialogInteractionOptions = {
|
|
4
4
|
/** Open the verification URL for `auth_url` / `device_code` events. */
|
|
@@ -16,4 +16,4 @@ export type DialogInteractionOptions = {
|
|
|
16
16
|
* the dialog occupies crouter's single inline slot in both surfaces, so swapping in pi's
|
|
17
17
|
* separate selector component (what pi's own interactive mode does) has nowhere to go.
|
|
18
18
|
*/
|
|
19
|
-
export declare function dialogAuthInteraction(dialog: LoginDialogComponent, options?: DialogInteractionOptions):
|
|
19
|
+
export declare function dialogAuthInteraction(dialog: LoginDialogComponent, options?: DialogInteractionOptions): ProviderAuthInteraction;
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -12,12 +12,53 @@ export interface BashJobPaths {
|
|
|
12
12
|
* Inspector's cancel) — without it on disk, only the agent's own handoff
|
|
13
13
|
* notice knows the group to signal. */
|
|
14
14
|
jobPgid: string;
|
|
15
|
+
/** Human-readable label supplied with the bash call, when any. */
|
|
16
|
+
jobPurpose: string;
|
|
15
17
|
}
|
|
18
|
+
/** A purpose is display text, never shell input. Keep the file-backed control
|
|
19
|
+
* plane to one bounded, visible line so old or hand-written job directories
|
|
20
|
+
* cannot inject control content into terminal or JSON presenters. */
|
|
21
|
+
export declare function normalizeBashJobPurpose(value: unknown): string | null;
|
|
16
22
|
export declare function bashJobsDir(contextDir: string): string;
|
|
23
|
+
/** Whether a job id can safely be used as one path segment. Generated ids are
|
|
24
|
+
* numeric epoch prefixes plus a hex suffix; this broader safe alphabet keeps
|
|
25
|
+
* older file-backed ids readable without allowing path or request delimiters. */
|
|
26
|
+
export declare function isSafeBashJobId(jobId: string): boolean;
|
|
17
27
|
export declare function bashJobPaths(contextDir: string, jobId: string): BashJobPaths;
|
|
18
28
|
/** The job's supervisor process group, or undefined for a job started before
|
|
19
29
|
* pgid was persisted (or a partially-written dir). */
|
|
20
30
|
export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
|
|
31
|
+
/** The optional human-readable job label. Jobs created before labels existed
|
|
32
|
+
* have no file and deliberately project null. */
|
|
33
|
+
export declare function readJobPurpose(contextDir: string, jobId: string): string | null;
|
|
34
|
+
/** Whether a persisted job supervisor process group is still present. EPERM
|
|
35
|
+
* means the group exists but cannot be signaled by this process; it is still
|
|
36
|
+
* live for the status surface. */
|
|
37
|
+
export declare function isBashJobProcessGroupAlive(pgid: number): boolean;
|
|
38
|
+
export type StopBackgroundBashJobResult = {
|
|
39
|
+
kind: 'stopped';
|
|
40
|
+
signaled: boolean;
|
|
41
|
+
pgid: number;
|
|
42
|
+
logPath: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'missing';
|
|
45
|
+
} | {
|
|
46
|
+
kind: 'retired';
|
|
47
|
+
logPath: string;
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'not-stoppable';
|
|
50
|
+
logPath: string;
|
|
51
|
+
};
|
|
52
|
+
/** Stop one live background job using the same process-group and sentinel
|
|
53
|
+
* semantics as `crtr node bash kill`. The caller owns the user-facing
|
|
54
|
+
* notification; this primitive owns only the job control-plane mutation.
|
|
55
|
+
*
|
|
56
|
+
* Only a job in the live-background state the roster projects (job.bg
|
|
57
|
+
* present, job.exit absent) may be signaled. Job directories persist after
|
|
58
|
+
* the supervisor exits, so a retired, still-foreground, or already-stopped
|
|
59
|
+
* job would otherwise have its recorded pgid signaled long after the OS
|
|
60
|
+
* could have reused that id for an unrelated process group. */
|
|
61
|
+
export declare function stopBackgroundBashJob(contextDir: string, jobId: string): Promise<StopBackgroundBashJobResult>;
|
|
21
62
|
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
22
63
|
* rather than the whole file, which for a long-running job can be enormous. */
|
|
23
64
|
export declare function tailJobLog(logPath: string, count: number): string[];
|
|
@@ -40,6 +81,8 @@ export interface BashJobStatus {
|
|
|
40
81
|
jobId: string;
|
|
41
82
|
/** Full text of cmd.sh. */
|
|
42
83
|
command: string;
|
|
84
|
+
/** Human-readable label, or null for an unlabeled or legacy job. */
|
|
85
|
+
purpose: string | null;
|
|
43
86
|
startedAtMs: number;
|
|
44
87
|
logPath: string;
|
|
45
88
|
}
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -3,11 +3,32 @@
|
|
|
3
3
|
// job.bg, while foreground completion renames it to job.done. The detached
|
|
4
4
|
// supervisor can therefore outlive pi and still decide whether to report exit.
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
|
-
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync } from 'node:fs';
|
|
6
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
const MAX_BASH_JOB_PURPOSE_BYTES = 280;
|
|
9
|
+
/** A purpose is display text, never shell input. Keep the file-backed control
|
|
10
|
+
* plane to one bounded, visible line so old or hand-written job directories
|
|
11
|
+
* cannot inject control content into terminal or JSON presenters. */
|
|
12
|
+
export function normalizeBashJobPurpose(value) {
|
|
13
|
+
if (typeof value !== 'string' || /[\p{Cc}\p{Cf}]/u.test(value))
|
|
14
|
+
return null;
|
|
15
|
+
const purpose = value.trim();
|
|
16
|
+
if (purpose === '')
|
|
17
|
+
return null;
|
|
18
|
+
return Buffer.byteLength(purpose, 'utf8') <= MAX_BASH_JOB_PURPOSE_BYTES ? purpose : null;
|
|
19
|
+
}
|
|
8
20
|
export function bashJobsDir(contextDir) {
|
|
9
21
|
return join(contextDir, 'jobs');
|
|
10
22
|
}
|
|
23
|
+
/** Whether a job id can safely be used as one path segment. Generated ids are
|
|
24
|
+
* numeric epoch prefixes plus a hex suffix; this broader safe alphabet keeps
|
|
25
|
+
* older file-backed ids readable without allowing path or request delimiters. */
|
|
26
|
+
export function isSafeBashJobId(jobId) {
|
|
27
|
+
return typeof jobId === 'string'
|
|
28
|
+
&& jobId !== '' && jobId !== '.' && jobId !== '..'
|
|
29
|
+
&& /^[A-Za-z0-9._~-]+$/u.test(jobId)
|
|
30
|
+
&& Buffer.byteLength(jobId, 'utf8') <= 128;
|
|
31
|
+
}
|
|
11
32
|
export function bashJobPaths(contextDir, jobId) {
|
|
12
33
|
const dir = join(bashJobsDir(contextDir), jobId);
|
|
13
34
|
return {
|
|
@@ -20,6 +41,7 @@ export function bashJobPaths(contextDir, jobId) {
|
|
|
20
41
|
jobBg: join(dir, 'job.bg'),
|
|
21
42
|
jobDone: join(dir, 'job.done'),
|
|
22
43
|
jobPgid: join(dir, 'job.pgid'),
|
|
44
|
+
jobPurpose: join(dir, 'purpose'),
|
|
23
45
|
};
|
|
24
46
|
}
|
|
25
47
|
/** The job's supervisor process group, or undefined for a job started before
|
|
@@ -33,6 +55,78 @@ export function readJobPgid(contextDir, jobId) {
|
|
|
33
55
|
return undefined;
|
|
34
56
|
}
|
|
35
57
|
}
|
|
58
|
+
/** The optional human-readable job label. Jobs created before labels existed
|
|
59
|
+
* have no file and deliberately project null. */
|
|
60
|
+
export function readJobPurpose(contextDir, jobId) {
|
|
61
|
+
try {
|
|
62
|
+
return normalizeBashJobPurpose(readFileSync(bashJobPaths(contextDir, jobId).jobPurpose, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Whether a persisted job supervisor process group is still present. EPERM
|
|
69
|
+
* means the group exists but cannot be signaled by this process; it is still
|
|
70
|
+
* live for the status surface. */
|
|
71
|
+
export function isBashJobProcessGroupAlive(pgid) {
|
|
72
|
+
try {
|
|
73
|
+
process.kill(-pgid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
return err.code === 'EPERM';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Stop one live background job using the same process-group and sentinel
|
|
81
|
+
* semantics as `crtr node bash kill`. The caller owns the user-facing
|
|
82
|
+
* notification; this primitive owns only the job control-plane mutation.
|
|
83
|
+
*
|
|
84
|
+
* Only a job in the live-background state the roster projects (job.bg
|
|
85
|
+
* present, job.exit absent) may be signaled. Job directories persist after
|
|
86
|
+
* the supervisor exits, so a retired, still-foreground, or already-stopped
|
|
87
|
+
* job would otherwise have its recorded pgid signaled long after the OS
|
|
88
|
+
* could have reused that id for an unrelated process group. */
|
|
89
|
+
export async function stopBackgroundBashJob(contextDir, jobId) {
|
|
90
|
+
const paths = bashJobPaths(contextDir, jobId);
|
|
91
|
+
if (!existsSync(paths.dir))
|
|
92
|
+
return { kind: 'missing' };
|
|
93
|
+
if (!existsSync(paths.jobBg) || existsSync(paths.jobExit))
|
|
94
|
+
return { kind: 'retired', logPath: paths.jobLog };
|
|
95
|
+
const pgid = readJobPgid(contextDir, jobId);
|
|
96
|
+
if (pgid === undefined)
|
|
97
|
+
return { kind: 'not-stoppable', logPath: paths.jobLog };
|
|
98
|
+
// Claim the stop by creating job.exit exclusively, before signaling. The
|
|
99
|
+
// loser of two concurrent stops — and a stop that races the supervisor's own
|
|
100
|
+
// completion — reports retired instead of signaling a departed group and
|
|
101
|
+
// sending the owning node a second "was stopped" notice.
|
|
102
|
+
try {
|
|
103
|
+
const fd = openSync(paths.jobExit, 'wx');
|
|
104
|
+
try {
|
|
105
|
+
writeFileSync(fd, '143\n');
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err.code === 'EEXIST')
|
|
113
|
+
return { kind: 'retired', logPath: paths.jobLog };
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
let signaled = true;
|
|
117
|
+
try {
|
|
118
|
+
process.kill(-pgid, 'SIGTERM');
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
signaled = false;
|
|
122
|
+
}
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
124
|
+
try {
|
|
125
|
+
process.kill(-pgid, 'SIGKILL');
|
|
126
|
+
}
|
|
127
|
+
catch { /* already gone */ }
|
|
128
|
+
return { kind: 'stopped', signaled, pgid, logPath: paths.jobLog };
|
|
129
|
+
}
|
|
36
130
|
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
37
131
|
* rather than the whole file, which for a long-running job can be enormous. */
|
|
38
132
|
export function tailJobLog(logPath, count) {
|
|
@@ -122,7 +216,7 @@ export function activeBackgroundBashJobs(contextDir) {
|
|
|
122
216
|
catch {
|
|
123
217
|
continue;
|
|
124
218
|
}
|
|
125
|
-
jobs.push({ jobId, command, startedAtMs, logPath: paths.jobLog });
|
|
219
|
+
jobs.push({ jobId, command, purpose: readJobPurpose(contextDir, jobId), startedAtMs, logPath: paths.jobLog });
|
|
126
220
|
}
|
|
127
221
|
return jobs.sort((a, b) => a.startedAtMs - b.startedAtMs);
|
|
128
222
|
}
|
|
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
|
|
|
4
4
|
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { atomicWriteJson,
|
|
7
|
+
import { atomicWriteJson, pageManifestPath } from '../../human/convention.js';
|
|
8
8
|
import { createNode } from '../canvas.js';
|
|
9
9
|
import { closeDb } from '../db.js';
|
|
10
10
|
import { nodeDir } from '../paths.js';
|
|
@@ -22,12 +22,18 @@ function meta(id, cwd, extra) {
|
|
|
22
22
|
...extra,
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
-
function
|
|
25
|
+
function seedPage(bridgeId) {
|
|
26
26
|
const dir = nodeDir(bridgeId);
|
|
27
27
|
mkdirSync(dir, { recursive: true });
|
|
28
|
-
atomicWriteJson(
|
|
28
|
+
atomicWriteJson(pageManifestPath(dir), {
|
|
29
|
+
schema: 'crtr.page/v1',
|
|
30
|
+
dialect: 'md',
|
|
29
31
|
title: bridgeId,
|
|
30
|
-
|
|
32
|
+
subtitle: 'Acknowledge this ticket so the request can proceed.',
|
|
33
|
+
placement: 'inline',
|
|
34
|
+
document: 'page.md',
|
|
35
|
+
steps: 1,
|
|
36
|
+
slots: [],
|
|
31
37
|
});
|
|
32
38
|
}
|
|
33
39
|
function bridge(id, parent, cwd) {
|
|
@@ -61,10 +67,10 @@ test('ticketCountsForNodes follows bridge parents even when asking nodes share a
|
|
|
61
67
|
bridge('ha2', 'a', sharedCwd);
|
|
62
68
|
bridge('hb', 'b', sharedCwd);
|
|
63
69
|
bridge('hc', 'c', soloCwd);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
seedPage('ha1');
|
|
71
|
+
seedPage('ha2');
|
|
72
|
+
seedPage('hb');
|
|
73
|
+
seedPage('hc');
|
|
68
74
|
assert.deepEqual(ticketCountsForNodes(['a', 'b', 'c']), { a: 2, b: 1, c: 1 });
|
|
69
75
|
});
|
|
70
76
|
test('pendingTicketsForView returns the asking node rather than a cwd bucket', () => {
|
|
@@ -72,8 +78,8 @@ test('pendingTicketsForView returns the asking node rather than a cwd bucket', (
|
|
|
72
78
|
createNode(meta('b', sharedCwd));
|
|
73
79
|
bridge('ha', 'a', sharedCwd);
|
|
74
80
|
bridge('hb', 'b', sharedCwd);
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
seedPage('ha');
|
|
82
|
+
seedPage('hb');
|
|
77
83
|
assert.deepEqual(pendingTicketsForView('a').map(({ node_id, count }) => ({ node_id, count })), [
|
|
78
84
|
{ node_id: 'a', count: 1 },
|
|
79
85
|
]);
|
|
@@ -19,7 +19,7 @@ import { createServer } from 'node:http';
|
|
|
19
19
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { tmpdir } from 'node:os';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
|
-
import { atomicWriteJson,
|
|
22
|
+
import { atomicWriteJson, pageManifestPath } from '#core/human/convention';
|
|
23
23
|
import { RemoteCanvasSource } from '../remote-canvas-source.js';
|
|
24
24
|
import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render.js';
|
|
25
25
|
import { createNode } from '../canvas.js';
|
|
@@ -113,9 +113,15 @@ function seedPoisonedLocalState() {
|
|
|
113
113
|
createNode(meta('a', { cwd: localCwd }));
|
|
114
114
|
const askDir = nodeDir('poison-ask');
|
|
115
115
|
mkdirSync(askDir, { recursive: true });
|
|
116
|
-
atomicWriteJson(
|
|
116
|
+
atomicWriteJson(pageManifestPath(askDir), {
|
|
117
|
+
schema: 'crtr.page/v1',
|
|
118
|
+
dialect: 'md',
|
|
117
119
|
title: 'poison',
|
|
118
|
-
|
|
120
|
+
subtitle: 'This local ticket must not affect the remote rendering.',
|
|
121
|
+
placement: 'inline',
|
|
122
|
+
document: 'page.md',
|
|
123
|
+
steps: 1,
|
|
124
|
+
slots: [],
|
|
119
125
|
});
|
|
120
126
|
// A local focus + broker viewer — poison both `viewed` signals.
|
|
121
127
|
openFocusRow('poison-focus', null, null, 'a');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Every ticket is a direct child of the single registered `<canvas>/nodes` root,
|
|
4
4
|
// named for its terminal human bridge node. The bridge's parent is the asking
|
|
5
5
|
// node, so storage identity and attribution come from the canvas graph rather
|
|
6
|
-
// than duplicated attribution fields in
|
|
6
|
+
// than duplicated attribution fields in page/review data.
|
|
7
7
|
import { basename } from 'node:path';
|
|
8
8
|
import { scanInbox } from '../human/scan.js';
|
|
9
9
|
import { filterTerminalReviewTickets } from '../review/ticket-filter.js';
|
|
@@ -55,7 +55,7 @@ export async function runBrowse(opts) {
|
|
|
55
55
|
process.stdout.write(await renderForestFromSource(source) + '\n');
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
|
-
// Snapshot the canvas. Drop kind:'human' control-plane
|
|
58
|
+
// Snapshot the canvas. Drop kind:'human' control-plane pages — they have no pi
|
|
59
59
|
// session, so `surface node focus` refuses them; they are never a navigation/resume
|
|
60
60
|
// target (mirrors the surface-node focus guard).
|
|
61
61
|
let rows;
|
|
@@ -3,7 +3,7 @@ import type { NodeMeta } from './index.js';
|
|
|
3
3
|
export { PI_MAX_WIDGET_LINES, VIEWPORT_FALLBACK_ROWS, ESC, RESET, BOLD, DIM, REVERSE, BG_ATTACHED, GREEN, RED, YELLOW, CYAN, GRAY, nodeGlyph, visibleWidth, truncate, fillWidth, fillBar, readTelemetry, fmtTokens, tokensCell, activityCell, cycleBadge, navLabel, shortId, askBadge, liveBelowBadge, isAttached, hasBrokerViewers, fetchAsksMap, graphWidgetBudget, GRAPH_HINT, type Telemetry, type FlatRow, type FoldState, type SubtreeActivity, } from './nav-render.js';
|
|
4
4
|
/** First manager (by created) — the UP step for the ancestry spine. */
|
|
5
5
|
export declare function managerOf(id: string): string | undefined;
|
|
6
|
-
/** A kind:'human' node is a control-plane ASK (a
|
|
6
|
+
/** A kind:'human' node is a control-plane ASK (a page on the human's
|
|
7
7
|
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
8
8
|
* boots a confused blank "you have been revived" pi. Its pending-ask signal
|
|
9
9
|
* already rides the ⚑ badge on the ASKING node (attention.ts follows the
|
|
@@ -73,7 +73,7 @@ function mgrsOf(id) {
|
|
|
73
73
|
export function managerOf(id) {
|
|
74
74
|
return mgrsOf(id)[0]?.node_id;
|
|
75
75
|
}
|
|
76
|
-
/** A kind:'human' node is a control-plane ASK (a
|
|
76
|
+
/** A kind:'human' node is a control-plane ASK (a page on the human's
|
|
77
77
|
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
78
78
|
* boots a confused blank "you have been revived" pi. Its pending-ask signal
|
|
79
79
|
* already rides the ⚑ badge on the ASKING node (attention.ts follows the
|
|
@@ -208,7 +208,7 @@ export class RemoteCanvasSource {
|
|
|
208
208
|
}
|
|
209
209
|
async ticketCountsForView(root) {
|
|
210
210
|
// The `canvas attention map` transport is unchanged; its `counts` values now
|
|
211
|
-
// mean all pending human tickets (
|
|
211
|
+
// mean all pending human tickets (page/review), not just asks.
|
|
212
212
|
const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
|
|
213
213
|
return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
|
|
214
214
|
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* only `dead` is a fault. */
|
|
12
12
|
export type NodeStatus = 'active' | 'idle' | 'done' | 'dead' | 'canceled';
|
|
13
13
|
/** Does stopping finalize the node? terminal = worker (finalizes on push --final);
|
|
14
|
-
* resident =
|
|
14
|
+
* resident = interactable while attached or holding a live obligation. An
|
|
15
|
+
* unattended obligation-free resident completes after the daemon grace. */
|
|
15
16
|
export type Lifecycle = 'terminal' | 'resident';
|
|
16
17
|
/** base = hands-on worker; orchestrator = delegating manager. Bespoke per kind. */
|
|
17
18
|
export type Mode = 'base' | 'orchestrator';
|
package/dist/core/config.js
CHANGED
|
@@ -10,6 +10,7 @@ import { profileRoot } from './profiles/manifest.js';
|
|
|
10
10
|
import { withExclusiveDirectoryLock } from './exclusive-lock.js';
|
|
11
11
|
import { SCOPE_CONFIG_KEYS } from './user-settings.js';
|
|
12
12
|
import { normalizeWorkingGerunds } from '../shared/working-activity.js';
|
|
13
|
+
import { normalizePageComponents } from './human/page-catalog.js';
|
|
13
14
|
function configPathFor(root) {
|
|
14
15
|
return join(root, CONFIG_FILE);
|
|
15
16
|
}
|
|
@@ -478,6 +479,8 @@ function mergeConfig(partial) {
|
|
|
478
479
|
? [...defaults.working_gerunds]
|
|
479
480
|
: normalizeWorkingGerunds(partial.working_gerunds);
|
|
480
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
|
+
const page_components = normalizePageComponents(partial.page_components);
|
|
481
484
|
const whip_messages = normalizeWhipMessages(partial.whip_messages);
|
|
482
485
|
const mouse_mode_default = MOUSE_MODE_DEFAULTS.includes(partial.mouse_mode_default)
|
|
483
486
|
? partial.mouse_mode_default
|
|
@@ -497,7 +500,7 @@ function mergeConfig(partial) {
|
|
|
497
500
|
const kinds = mergeKinds(partial.kinds);
|
|
498
501
|
const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
|
|
499
502
|
const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
|
|
500
|
-
return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, whip_messages, mouse_mode_default, live_cycles, condensed_history, 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 };
|
|
503
|
+
return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, html_pages, page_components, whip_messages, mouse_mode_default, live_cycles, condensed_history, 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 };
|
|
501
504
|
}
|
|
502
505
|
/** Raw (un-defaulted) partial config for one scope, or null if the scope has
|
|
503
506
|
* no root or no config.json. Used by `readMergedLaunchConfig` to layer
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Two properties of the default page template whose absence would be SILENT —
|
|
2
|
+
// every ordinary page still renders and still looks right when either breaks.
|
|
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.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
import { derivePageManifest } from '../page.js';
|
|
11
|
+
import { renderPageHtml } from '../page-render.js';
|
|
12
|
+
function render(name, source) {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-test-'));
|
|
14
|
+
try {
|
|
15
|
+
const path = join(dir, name);
|
|
16
|
+
writeFileSync(path, source);
|
|
17
|
+
return renderPageHtml(derivePageManifest(path, name.endsWith('.md') ? 'md' : 'html'), readFileSync(path, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
test('a config value carrying script markup cannot end its own <script> element', () => {
|
|
24
|
+
const html = render('page.md', [
|
|
25
|
+
'---', 'title: Escaping', '---', '',
|
|
26
|
+
'Pick one.', '',
|
|
27
|
+
'```slot:options',
|
|
28
|
+
'{"id":"esc","mode":"single","options":[{"id":"a","label":"</script><script>x</script>"},{"id":"b","label":"<!-- c -->"}]}',
|
|
29
|
+
'```', '',
|
|
30
|
+
].join('\n'));
|
|
31
|
+
const config = /<crtr-options slot-id="esc"><script type="application\/json">([\s\S]*?)<\/script>/.exec(html)?.[1];
|
|
32
|
+
assert.ok(config !== undefined, `no options config in payload:\n${html}`);
|
|
33
|
+
// The mutation this catches: dropping the `<` escape for a plain JSON.stringify.
|
|
34
|
+
assert.ok(!config.includes('<'), `config text still carries a raw "<": ${config}`);
|
|
35
|
+
assert.equal(JSON.parse(config).options[0].label, '</script><script>x</script>');
|
|
36
|
+
});
|
|
37
|
+
test('an authored html slot id is mirrored to the slot-id the runtime reads', () => {
|
|
38
|
+
const html = render('page.html', [
|
|
39
|
+
'<!doctype html><html><head><meta name="crtr-page-title" content="Sign-off"></head><body>',
|
|
40
|
+
'<crtr-pages><crtr-page label="Window">',
|
|
41
|
+
'<crtr-options id="window"><script type="application/json">{"mode":"single","options":[{"id":"sat","label":"Saturday"}]}</script></crtr-options>',
|
|
42
|
+
'</crtr-page></crtr-pages>',
|
|
43
|
+
'</body></html>',
|
|
44
|
+
].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
|
+
assert.match(html, /<crtr-options id="window" slot-id="window">/);
|
|
48
|
+
assert.match(html, /<crtr-page label="Window">/);
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|