@north-light/crouter 0.3.202 → 0.3.204
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/clients/attach/ansi-cells.d.ts +5 -1
- package/dist/clients/attach/ansi-cells.js +16 -5
- package/dist/clients/attach/render/transcript-search.js +5 -6
- package/dist/clients/attach/viewer.js +364 -364
- package/dist/commands/human/shared.d.ts +1 -0
- package/dist/commands/human/shared.js +5 -0
- package/dist/commands/human.js +1 -0
- package/dist/core/__tests__/human-deliver.test.js +34 -1
- package/dist/daemon/companion-retire.d.ts +2 -2
- package/dist/daemon/companion-retire.js +16 -9
- package/dist/daemon/human/finish.d.ts +2 -2
- package/dist/daemon/human/finish.js +4 -4
- package/dist/daemon/human/sweep.js +3 -2
- package/dist/daemon/review/finish.js +2 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { readConfig } from '../../core/config.js';
|
|
2
2
|
import { CORE_COMPONENT_SELECTION_LINES } from '../../core/human/component-docs.js';
|
|
3
|
+
// The reader's missing context is the gap syntax cannot close: an inbox page
|
|
4
|
+
// is opened away from the conversation that produced it, by someone who never
|
|
5
|
+
// followed the work.
|
|
6
|
+
const PAGE_BRIEF = 'Write the page for where it is read. A page projected to the inbox is opened away from this conversation by someone who has not followed the work, so it stands alone; an inline page can lean on what the conversation already said. Title names the topic; subtitle states the decision, your recommendation, and the stakes. Content carries only what is needed to decide, with the evidence behind it below the ask rather than ahead of it. Ask only what changes your next step, and offer only options you would actually take. A page that asks nothing states what happened and what it changes.';
|
|
3
7
|
const PAGE_AUTHORING = 'Write one `.tsx` module to `$CRTR_CONTEXT_DIR/pages/<name>.tsx` that default-exports a component whose root element is `<Page title="…" subtitle="…">`; a multi-step page puts its content in `<Step>` children. Or send a complete `.html` file with a nonempty `<title>`; HTML is delivered verbatim and collects no response. Delivery is chosen by `human send` flags, never a Page prop. Registry components and React are already in scope, so the module contains no `import` and no `require`. Full JS is yours: state, expressions, `.map` over your data, event handlers, plain elements with Tailwind classes. `usePageHost` is a reserved hook whose product-supplied members are opaque here. Never render submit or dismiss chrome — the host owns it. Display-only pages are first-class: a page with no questions may be sent without inbox or reply delivery. The module is compiled when you submit it and a compile error rejects the submit; there is no pre-check.';
|
|
4
8
|
const DISPLAY_SET_LINE = 'The shadcn display set — `Card`, `Badge`, `Button`, `Table`, `Tabs`, `Separator`, `Alert`, `Progress`, `Accordion` and their parts — is in scope too, for presentation only.';
|
|
5
9
|
/** The one page authoring model for `human -h`. */
|
|
6
10
|
export function pageHelpText(pageSurface = false) {
|
|
7
11
|
return {
|
|
12
|
+
brief: PAGE_BRIEF,
|
|
8
13
|
authoring: pageSurface
|
|
9
14
|
? `Use inline pages as the default way to put structured or interactive content in front of the user; the environment renders rich components, so a page is not a special occasion. ${PAGE_AUTHORING}`
|
|
10
15
|
: PAGE_AUTHORING,
|
package/dist/commands/human.js
CHANGED
|
@@ -7,11 +7,14 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'no
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { createApiServer } from '../../daemon/api/server.js';
|
|
10
|
-
import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket } from '../../daemon/human/finish.js';
|
|
10
|
+
import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket, settleFeedbackCompanion } from '../../daemon/human/finish.js';
|
|
11
11
|
import { humanSend } from '../../commands/human/prompts.js';
|
|
12
12
|
import { getNode, listNodes, closeDb } from '../canvas/index.js';
|
|
13
13
|
import { readInboxSince } from '../feed/inbox.js';
|
|
14
|
+
import { createPageFeedbackComment } from '../human/feedback.js';
|
|
14
15
|
import { resolveRef } from '../canvas/history.js';
|
|
16
|
+
import { transition } from '../runtime/lifecycle.js';
|
|
17
|
+
import { reviveNode } from '../runtime/revive.js';
|
|
15
18
|
import { apiSocketPath } from '../canvas/paths.js';
|
|
16
19
|
import { spawnNode } from '../runtime/nodes.js';
|
|
17
20
|
import { bindTicketReplyRoute } from '../human/convention.js';
|
|
@@ -142,6 +145,36 @@ describe('terminal ticket delivery', () => {
|
|
|
142
145
|
const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
|
|
143
146
|
assert.equal(finals.length, 1, 'replay after a terminal bridge pushes nothing further');
|
|
144
147
|
});
|
|
148
|
+
test('approved companions receive one canonical final while dismissed companions remain canceled', async () => {
|
|
149
|
+
const { bridge } = bridgeUnder();
|
|
150
|
+
const dir = submitBridgePage(bridge);
|
|
151
|
+
const companion = spawnNode({ kind: 'general', parent: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' }).node_id;
|
|
152
|
+
createPageFeedbackComment(dir, { quote: 'Ship it?', note: 'Confirm the release.', companionNodeId: companion });
|
|
153
|
+
transition(companion, 'finish');
|
|
154
|
+
await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
|
|
155
|
+
const finalized = getNode(companion);
|
|
156
|
+
assert.equal(finalized.status, 'done');
|
|
157
|
+
assert.equal(finalized.intent, 'done');
|
|
158
|
+
assert.ok(finalized.final_report);
|
|
159
|
+
assert.ok(finalized.finalized_at);
|
|
160
|
+
const finalReport = finalized.final_report;
|
|
161
|
+
const finalizedAt = finalized.finalized_at;
|
|
162
|
+
await deliverTerminalResult(bridge);
|
|
163
|
+
assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
|
|
164
|
+
assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
|
|
165
|
+
assert.throws(() => reviveNode(companion, { resume: true }), /finalization latch/);
|
|
166
|
+
const { bridge: dismissedBridge } = bridgeUnder();
|
|
167
|
+
const dismissedDir = submitBridgePage(dismissedBridge);
|
|
168
|
+
const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
|
|
169
|
+
createPageFeedbackComment(dismissedDir, { quote: 'Ship it?', note: 'Do not ship.', companionNodeId: dismissedCompanion });
|
|
170
|
+
await cancelHumanTicket(dismissedBridge, { reason: 'dismissed', actor: 'human' });
|
|
171
|
+
assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
|
|
172
|
+
assert.equal(getNode(dismissedCompanion)?.final_report, null);
|
|
173
|
+
const dismissedResult = readTicketResult(dismissedDir);
|
|
174
|
+
await settleFeedbackCompanion(dismissedBridge, dismissedResult.kind === 'canceled' ? 'canceled' : 'approved');
|
|
175
|
+
assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
|
|
176
|
+
assert.equal(getNode(dismissedCompanion)?.final_report, null);
|
|
177
|
+
});
|
|
145
178
|
test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
|
|
146
179
|
const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
|
|
147
180
|
const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
/** Retire a companion only through
|
|
2
|
-
export declare function retireCompanion(nodeId: string, outcome: 'approved' | 'canceled'): 'retired' | 'already' | 'missing'
|
|
1
|
+
/** Retire a companion only through the terminal transition legal for its current status. */
|
|
2
|
+
export declare function retireCompanion(nodeId: string, outcome: 'approved' | 'canceled'): Promise<'retired' | 'already' | 'missing'>;
|
|
@@ -3,25 +3,32 @@
|
|
|
3
3
|
// can retire a feedback companion without importing the review finisher (which
|
|
4
4
|
// itself imports human/finish.ts).
|
|
5
5
|
import { getNode } from '../core/canvas/canvas.js';
|
|
6
|
+
import { FinalizationError, pushFinal } from '../core/feed/feed.js';
|
|
6
7
|
import { transition } from '../core/runtime/lifecycle.js';
|
|
7
8
|
import { headlessBrokerHost } from '../core/runtime/host.js';
|
|
8
|
-
/** Retire a companion only through
|
|
9
|
-
export function retireCompanion(nodeId, outcome) {
|
|
9
|
+
/** Retire a companion only through the terminal transition legal for its current status. */
|
|
10
|
+
export async function retireCompanion(nodeId, outcome) {
|
|
10
11
|
const node = getNode(nodeId);
|
|
11
12
|
if (node === null)
|
|
12
13
|
return 'missing';
|
|
13
|
-
if (node.status === '
|
|
14
|
+
if (node.status === 'canceled' || (outcome === 'canceled' && node.status === 'done'))
|
|
14
15
|
return 'already';
|
|
15
|
-
if (outcome === 'approved' && (node.status === 'active' || node.status === 'idle')) {
|
|
16
|
-
|
|
16
|
+
if (outcome === 'approved' && (node.status === 'active' || node.status === 'idle' || node.status === 'done')) {
|
|
17
|
+
let result = 'retired';
|
|
18
|
+
try {
|
|
19
|
+
await pushFinal(nodeId, 'The companion conversation is complete.');
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (!(error instanceof FinalizationError) || error.code !== 'already_finalized')
|
|
23
|
+
throw error;
|
|
24
|
+
result = 'already';
|
|
25
|
+
}
|
|
17
26
|
try {
|
|
18
27
|
headlessBrokerHost.teardown(nodeId);
|
|
19
28
|
}
|
|
20
|
-
catch { /* the
|
|
21
|
-
return
|
|
29
|
+
catch { /* the canonical final is authoritative */ }
|
|
30
|
+
return result;
|
|
22
31
|
}
|
|
23
|
-
// Cancellation is legal from every status. An already-dead companion cannot
|
|
24
|
-
// be finished, so it is retired as canceled even when approval won.
|
|
25
32
|
transition(nodeId, 'cancel');
|
|
26
33
|
if (node.status === 'active' || node.status === 'idle') {
|
|
27
34
|
try {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { TicketResult } from '../../core/human/types.js';
|
|
2
2
|
/** Every ticket-ending action retires the ticket's feedback companion — done
|
|
3
|
-
*
|
|
3
|
+
* with a canonical final when approved, never left resident. Idempotent and replay-safe; a
|
|
4
4
|
* corrupt feedback file must not block result publication. Exported for the
|
|
5
5
|
* daemon-start sweep, which must settle terminal tickets whether or not a
|
|
6
6
|
* reply bridge exists. */
|
|
7
|
-
export declare function settleFeedbackCompanion(ticketId: string): void
|
|
7
|
+
export declare function settleFeedbackCompanion(ticketId: string, outcome: 'approved' | 'canceled'): Promise<void>;
|
|
8
8
|
/**
|
|
9
9
|
* The one completion path for a page ticket: claim takeover, exclusive result
|
|
10
10
|
* publication, then optional reply delivery. Every addressing surface routes
|
|
@@ -14,15 +14,15 @@ import { transition } from '../../core/runtime/lifecycle.js';
|
|
|
14
14
|
import { emitEvent } from '../../core/events/emit.js';
|
|
15
15
|
import { retireCompanion } from '../companion-retire.js';
|
|
16
16
|
/** Every ticket-ending action retires the ticket's feedback companion — done
|
|
17
|
-
*
|
|
17
|
+
* with a canonical final when approved, never left resident. Idempotent and replay-safe; a
|
|
18
18
|
* corrupt feedback file must not block result publication. Exported for the
|
|
19
19
|
* daemon-start sweep, which must settle terminal tickets whether or not a
|
|
20
20
|
* reply bridge exists. */
|
|
21
|
-
export function settleFeedbackCompanion(ticketId) {
|
|
21
|
+
export async function settleFeedbackCompanion(ticketId, outcome) {
|
|
22
22
|
try {
|
|
23
23
|
const companionNodeId = readPageFeedback(ticketDir(ticketId)).companionNodeId;
|
|
24
24
|
if (companionNodeId !== undefined)
|
|
25
|
-
retireCompanion(companionNodeId,
|
|
25
|
+
await retireCompanion(companionNodeId, outcome);
|
|
26
26
|
}
|
|
27
27
|
catch (error) {
|
|
28
28
|
emitEvent({ level: 'warn', event: 'human.feedback_companion.retire_failed', error });
|
|
@@ -155,7 +155,7 @@ export async function deliverTerminalResult(ticketId) {
|
|
|
155
155
|
// through here, and an inbox-only page with no reply bridge still settles
|
|
156
156
|
// its companion. The daemon-start sweep settles the tickets that die before
|
|
157
157
|
// reaching this call.
|
|
158
|
-
settleFeedbackCompanion(ticketId);
|
|
158
|
+
await settleFeedbackCompanion(ticketId, result.kind === 'canceled' ? 'canceled' : 'approved');
|
|
159
159
|
const replyRoute = readTicketReplyRoute(dir);
|
|
160
160
|
if (replyRoute === null)
|
|
161
161
|
return false;
|
|
@@ -21,7 +21,8 @@ export async function reconcileUndeliveredTickets() {
|
|
|
21
21
|
if (!statSync(dir).isDirectory())
|
|
22
22
|
continue;
|
|
23
23
|
scanned += 1;
|
|
24
|
-
|
|
24
|
+
const ticketResult = readTicketResult(dir);
|
|
25
|
+
if (ticketResult === null)
|
|
25
26
|
continue;
|
|
26
27
|
// Canonical reviews own terminal delivery and bridge retirement. Their
|
|
27
28
|
// finisher retries only when its winning operation is explicitly repeated.
|
|
@@ -30,7 +31,7 @@ export async function reconcileUndeliveredTickets() {
|
|
|
30
31
|
// A terminal page ticket retires its feedback companion whether or not a
|
|
31
32
|
// reply bridge exists: inbox-only pages have no bridge row, yet the
|
|
32
33
|
// daemon can die between result publication and companion retirement.
|
|
33
|
-
settleFeedbackCompanion(entry);
|
|
34
|
+
await settleFeedbackCompanion(entry, ticketResult.kind === 'canceled' ? 'canceled' : 'approved');
|
|
34
35
|
// The ROW is what carries status; `getNode` would answer from a pruned
|
|
35
36
|
// bridge's surviving meta.json with no status at all.
|
|
36
37
|
const bridge = getRow(entry);
|
|
@@ -89,7 +89,7 @@ async function finishApproved(review, result) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
current = requireVisibleReview(review.review_id);
|
|
92
|
-
retireCompanion(current.companion_node_id, 'approved');
|
|
92
|
+
await retireCompanion(current.companion_node_id, 'approved');
|
|
93
93
|
return requireVisibleReview(review.review_id);
|
|
94
94
|
}
|
|
95
95
|
async function finishCanceled(review, winningWrite, args) {
|
|
@@ -150,7 +150,7 @@ async function finishCanceled(review, winningWrite, args) {
|
|
|
150
150
|
current = requireVisibleReview(review.review_id);
|
|
151
151
|
if (canceledTicket === undefined)
|
|
152
152
|
canceledTicket = ticketResult(current);
|
|
153
|
-
retireCompanion(current.companion_node_id, 'canceled');
|
|
153
|
+
await retireCompanion(current.companion_node_id, 'canceled');
|
|
154
154
|
return { record: requireVisibleReview(review.review_id), ...(canceledTicket === undefined ? {} : { ticket_result: canceledTicket }) };
|
|
155
155
|
}
|
|
156
156
|
/** Tell the origin its review was submitted but is finishing behind the
|
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.204",
|
|
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.204",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|