@north-light/crouter 0.3.224 → 0.3.226
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/dto/inbox.d.ts +1 -0
- package/dist/clients/attach/render/page-block.js +2 -1
- package/dist/clients/attach/viewer.js +475 -475
- package/dist/clients/inbox/page-adapter.js +3 -2
- package/dist/clients/inbox/tui/slots.d.ts +1 -2
- package/dist/clients/inbox/tui/slots.js +0 -3
- package/dist/core/__tests__/seam/dormancy-release.test.js +8 -2
- package/dist/core/human/__tests__/page-tickets.test.js +16 -1
- package/dist/core/human/page-schema.d.ts +3 -0
- package/dist/core/human/page-schema.js +4 -0
- package/dist/core/human/scan.js +2 -2
- package/dist/core/human/types.d.ts +1 -0
- package/dist/core/runtime/node-read.js +26 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { pageAwaitsResponse } from '../../core/human/page-schema.js';
|
|
1
2
|
import { writePageProgress } from '../../core/human/tickets.js';
|
|
2
3
|
import { mountPanel } from './tui/panel.js';
|
|
3
|
-
import { awaitsResponse } from './tui/slots.js';
|
|
4
4
|
/** Embeds the page renderer in a controller-owned rectangle. */
|
|
5
5
|
export class PageAdapter {
|
|
6
6
|
opts;
|
|
@@ -25,7 +25,8 @@ export class PageAdapter {
|
|
|
25
25
|
opts.onProgress?.(responses);
|
|
26
26
|
},
|
|
27
27
|
onComplete: opts.onComplete,
|
|
28
|
-
|
|
28
|
+
// Leaving a page that asks something must not settle it on an empty answer.
|
|
29
|
+
onExit: () => { if (pageAwaitsResponse(this.manifest))
|
|
29
30
|
opts.onBack();
|
|
30
31
|
else
|
|
31
32
|
opts.onComplete({}); },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CommentAnchor,
|
|
1
|
+
import type { CommentAnchor, PageSlot, SlotResponse, TableConfig } from '../../../core/human/page-schema.js';
|
|
2
2
|
import type { TuiState } from './types.js';
|
|
3
3
|
export type SelectGroup = 'option' | 'card' | 'row' | 'column';
|
|
4
4
|
export type ActionRow = {
|
|
@@ -31,4 +31,3 @@ export declare function pageComplete(state: Pick<TuiState, 'manifest' | 'respons
|
|
|
31
31
|
export declare function commentIdFor(anchor: CommentAnchor): string;
|
|
32
32
|
/** The response-comment anchors this component may author: options take per-option, tables take rows only. */
|
|
33
33
|
export declare function commentAnchors(slot: PageSlot): CommentAnchor[];
|
|
34
|
-
export declare function awaitsResponse(manifest: PageManifest): boolean;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// they belong to the compiled seam tier; neither needs tmux.
|
|
20
20
|
import { after, before, test } from 'node:test';
|
|
21
21
|
import assert from 'node:assert/strict';
|
|
22
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
|
|
25
25
|
import { registerWarmSpare, subscribe, updateNode } from '../../canvas/index.js';
|
|
@@ -243,9 +243,15 @@ test('a connected broker viewer keeps an obligation-free resident live', { timeo
|
|
|
243
243
|
});
|
|
244
244
|
reviveNode(nodeId, { resume: true });
|
|
245
245
|
const boot = await h.awaitBoot(nodeId);
|
|
246
|
+
// awaitBoot returns on the boot proof, which is written BEFORE the broker's
|
|
247
|
+
// own `persistAttachState()` lays down the truthful zero state. Writing our
|
|
248
|
+
// presence first lets that boot write clobber it, and the daemon then parks a
|
|
249
|
+
// node the test believes is attended — wait for the broker's file to exist.
|
|
250
|
+
const attachPath = join(h.home, 'nodes', nodeId, 'job', 'attach.json');
|
|
251
|
+
await h.waitFor(() => existsSync(attachPath), { timeoutMs: 20_000, intervalMs: 20, label: 'broker persists its boot attach state' });
|
|
246
252
|
// This is the presence written by a helloed direct attach, which has no
|
|
247
253
|
// focuses-table row because it did not pass through the focus opener.
|
|
248
|
-
writeFileSync(
|
|
254
|
+
writeFileSync(attachPath, JSON.stringify({ viewers: 1 }));
|
|
249
255
|
try {
|
|
250
256
|
const seenAt = 15_000;
|
|
251
257
|
await h.tick(seenAt);
|
|
@@ -116,7 +116,7 @@ test('readPageTicket derives pending, resolved and canceled state and carries th
|
|
|
116
116
|
cancelTicket(canceledDir, { reason: 'no longer needed' });
|
|
117
117
|
assert.equal(readPageTicket(canceledDir).state, 'canceled');
|
|
118
118
|
});
|
|
119
|
-
test('page scan derives response expectation from
|
|
119
|
+
test('page scan derives response expectation from the page shape and its reply route', () => {
|
|
120
120
|
const dir = submit('display', displayPage, '2026-01-04T00:00:00.000Z');
|
|
121
121
|
const ticket = readPageTicket(dir);
|
|
122
122
|
assert.equal(ticket.manifest.dialect, 'jsx');
|
|
@@ -135,6 +135,21 @@ test('page scan derives response expectation from manifest delivery', () => {
|
|
|
135
135
|
assert.deepEqual(written['responses'], {});
|
|
136
136
|
assert.equal(written['summary'], '');
|
|
137
137
|
});
|
|
138
|
+
test('an inbox page that asks something waits on the person with no reply route', () => {
|
|
139
|
+
const stamp = (emittedAt) => ({ nodeId: 'node-durable', emittedAt });
|
|
140
|
+
// The programmatic request shape: answerable from the inbox, never routed to an agent bridge.
|
|
141
|
+
const durable = submitPage({ dir: ticketDir('durable-request'), sourceFile: askPage, source: stamp('2026-01-06T00:00:00.000Z'), delivery: { placement: 'panel', inbox: true, reply: false } }).dir;
|
|
142
|
+
submitPage({ dir: ticketDir('durable-notice'), sourceFile: displayPage, source: stamp('2026-01-06T00:00:01.000Z'), delivery: { placement: 'panel', inbox: true, reply: false } });
|
|
143
|
+
const listed = scanInbox();
|
|
144
|
+
const request = listed.find((item) => item.id === 'durable-request');
|
|
145
|
+
assert.equal(request?.kind === 'page' ? request.awaitsResponse : undefined, true, 'a page with response-bearing slots waits on the person even with no bridge to reply to');
|
|
146
|
+
const notice = listed.find((item) => item.id === 'durable-notice');
|
|
147
|
+
assert.equal(notice?.kind === 'page' ? notice.awaitsResponse : undefined, false, 'a display-only notice waits on nothing');
|
|
148
|
+
const claim = claimTicket(durable);
|
|
149
|
+
const answered = completePage(durable, { rollout: { selectedOptionIds: ['canary'], comments: [] } }, claim.token);
|
|
150
|
+
assert.equal(answered.won, true, 'a request with no reply route still takes a real answer');
|
|
151
|
+
assert.deepEqual(JSON.parse(readFileSync(responsePath(durable), 'utf8'))['responses'], { rollout: { selectedOptionIds: ['canary'], comments: [] } });
|
|
152
|
+
});
|
|
138
153
|
test('scanPageHistory returns every page of one node, oldest first, regardless of terminal file', () => {
|
|
139
154
|
submit('older', askPage, '2026-01-01T00:00:00.000Z');
|
|
140
155
|
submit('other-node', askPage, '2026-01-02T00:00:00.000Z', 'node-2');
|
|
@@ -219,6 +219,7 @@ export interface PageSource {
|
|
|
219
219
|
export interface PageDelivery {
|
|
220
220
|
placement: 'inline' | 'panel';
|
|
221
221
|
inbox: boolean;
|
|
222
|
+
/** Routing only: whether an answer travels back to a resident agent's bridge. Never whether the page can be answered — {@link pageAwaitsResponse} decides that. */
|
|
222
223
|
reply: boolean;
|
|
223
224
|
}
|
|
224
225
|
export interface PageManifest {
|
|
@@ -269,6 +270,8 @@ export interface PageOverviewActions {
|
|
|
269
270
|
}
|
|
270
271
|
/** Derive both overview facts from a manifest alone. Pure: no ticket state, no second read. */
|
|
271
272
|
export declare function pageOverviewActions(manifest: PageManifest): PageOverviewActions;
|
|
273
|
+
/** Whether this page is waiting on a person: it asks something, or an agent bridge is parked on its reply. */
|
|
274
|
+
export declare function pageAwaitsResponse(manifest: PageManifest): boolean;
|
|
272
275
|
/** The canonical response-bearing predicate used by every page consumer. */
|
|
273
276
|
export declare function isResponseBearingSlot(slot: PageSlot): boolean;
|
|
274
277
|
/** Validate a persisted manifest. Publish-time validation made its slots authoritative, so reads do not resolve a component catalog. */
|
|
@@ -149,6 +149,10 @@ export function pageOverviewActions(manifest) {
|
|
|
149
149
|
fastAction: { kind: 'answer', responses: { [slot.id]: { selectedOptionIds: [recommendation.optionId], comments: [] } } },
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
|
+
/** Whether this page is waiting on a person: it asks something, or an agent bridge is parked on its reply. */
|
|
153
|
+
export function pageAwaitsResponse(manifest) {
|
|
154
|
+
return manifest.delivery.reply || manifest.slots.some(isResponseBearingSlot);
|
|
155
|
+
}
|
|
152
156
|
/** The canonical response-bearing predicate used by every page consumer. */
|
|
153
157
|
export function isResponseBearingSlot(slot) {
|
|
154
158
|
if (slot.display === true)
|
package/dist/core/human/scan.js
CHANGED
|
@@ -3,7 +3,7 @@ import { basename, join } from 'node:path';
|
|
|
3
3
|
import { claimPath, isResolved, pageManifestPath, reviewPath } from './convention.js';
|
|
4
4
|
import { readJsonOrNull } from '../fs-utils.js';
|
|
5
5
|
import { validateReviewDescriptor } from './review-schema.js';
|
|
6
|
-
import { pageOverviewActions } from './page-schema.js';
|
|
6
|
+
import { pageAwaitsResponse, pageOverviewActions } from './page-schema.js';
|
|
7
7
|
import { parsePage } from './page.js';
|
|
8
8
|
import { pageTicketState, readTicketResult } from './tickets.js';
|
|
9
9
|
import { ticketsRoot } from './root.js';
|
|
@@ -56,7 +56,7 @@ function pageSummary(dir, id) {
|
|
|
56
56
|
inbox: manifest.delivery.inbox,
|
|
57
57
|
steps: manifest.steps,
|
|
58
58
|
slotKinds: manifest.slots.map((slot) => slot.kind),
|
|
59
|
-
awaitsResponse: manifest
|
|
59
|
+
awaitsResponse: pageAwaitsResponse(manifest),
|
|
60
60
|
source: manifest.source ?? {},
|
|
61
61
|
emittedAt,
|
|
62
62
|
state,
|
|
@@ -64,6 +64,7 @@ export interface PageTicketSummary {
|
|
|
64
64
|
inbox: boolean;
|
|
65
65
|
steps: number;
|
|
66
66
|
slotKinds: string[];
|
|
67
|
+
/** This ticket is waiting on the person: it asks something, or an agent bridge is parked on its reply. */
|
|
67
68
|
awaitsResponse: boolean;
|
|
68
69
|
/** Every question's recommended option, for a row that says what is suggested without opening the page. */
|
|
69
70
|
recommendedOptions: import('./page-schema.js').RecommendedOption[];
|
|
@@ -55,6 +55,19 @@ function resolveSessionFile(nodeId, metaFile) {
|
|
|
55
55
|
return raw;
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
58
|
+
/** The node's readable session file, or `null` when the node exists but nothing
|
|
59
|
+
* has been written to its session yet. pi's SessionManager computes a session's
|
|
60
|
+
* `.jsonl` path at `newSession()` and writes the file on the FIRST appended
|
|
61
|
+
* entry, so a freshly created node carries a bound `pi_session_file` that is
|
|
62
|
+
* not on disk until something is said. Throws only when the node itself is
|
|
63
|
+
* unknown. */
|
|
64
|
+
function readableSessionFile(nodeId) {
|
|
65
|
+
const node = getNode(nodeId);
|
|
66
|
+
if (node === null) {
|
|
67
|
+
throw new InputError({ error: 'not_found', message: `no node: ${nodeId}`, field: 'id', next: 'List nodes with `crtr node inspect list`.' });
|
|
68
|
+
}
|
|
69
|
+
return resolveSessionFile(nodeId, node.pi_session_file);
|
|
70
|
+
}
|
|
58
71
|
function emptyStats(sessionId, sessionFile) {
|
|
59
72
|
return {
|
|
60
73
|
sessionFile,
|
|
@@ -266,6 +279,19 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
|
|
|
266
279
|
next: 'Use a limit from 1 through 500.',
|
|
267
280
|
});
|
|
268
281
|
}
|
|
282
|
+
// A node that exists but has said nothing has an EMPTY history, not a fault:
|
|
283
|
+
// its session file is not on disk until the first entry is appended, and a
|
|
284
|
+
// transcript read is how a UI opens a new conversation. Only this paged read
|
|
285
|
+
// can answer honestly — `/snapshot` would have to invent the engine state a
|
|
286
|
+
// never-run node does not have, so it and the raw `/session` export still
|
|
287
|
+
// reject the same state through `reconstructVisibleMessages`.
|
|
288
|
+
// Same shape a session-file-backed empty read produces: `messageIds` stays
|
|
289
|
+
// aligned 1:1 with `messages` (an absent `message_ids` would claim this read
|
|
290
|
+
// cannot identify entries, which is a different statement than "there are
|
|
291
|
+
// none"), and `nextCursor: null` is the same end-of-history marker an
|
|
292
|
+
// exhausted backward walk returns.
|
|
293
|
+
if (readableSessionFile(nodeId) === null)
|
|
294
|
+
return { messages: [], messageIds: [], nextCursor: null };
|
|
269
295
|
const { messages, identified } = await reconstructVisibleMessages(nodeId);
|
|
270
296
|
const before = cursor === undefined ? messages.length : decodeMessagesCursor(cursor, messages.length);
|
|
271
297
|
let start = Math.max(0, before - limit);
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.226",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.226",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|