@north-light/crouter 0.3.157 → 0.3.159
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 +4 -1
- package/dist/api/client.js +5 -0
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +15 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/internal/storage-tiers.md +2 -0
- package/dist/builtin-views/chat/core.mjs +6 -51
- package/dist/builtin-views/chat/tui.mjs +6 -14
- package/dist/builtin-views/chat/web.jsx +2 -7
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
- package/dist/clients/attach/chrome/bash-jobs.d.ts +0 -5
- package/dist/clients/attach/chrome/bash-jobs.js +5 -24
- package/dist/clients/attach/chrome/roster.d.ts +4 -3
- package/dist/clients/attach/chrome/roster.js +20 -36
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +23 -8
- package/dist/clients/attach/input/controller.js +59 -29
- package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
- package/dist/clients/attach/overlays/graph.d.ts +4 -1
- package/dist/clients/attach/overlays/graph.js +25 -7
- package/dist/clients/attach/session/context.d.ts +7 -0
- package/dist/clients/attach/session/frames.js +1 -8
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +21 -11
- package/dist/clients/attach/session/mode.d.ts +4 -3
- package/dist/clients/attach/session/mode.js +6 -1
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +6 -7
- package/dist/clients/attach/session/state-sync.d.ts +1 -1
- package/dist/clients/attach/session/state-sync.js +2 -2
- package/dist/clients/attach/slash/dispatch.d.ts +13 -1
- package/dist/clients/attach/slash/dispatch.js +65 -17
- package/dist/clients/attach/viewer.js +497 -499
- package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
- package/dist/commands/node.js +142 -4
- package/dist/commands/surface-inspect.js +4 -3
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
- package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
- package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +15 -26
- package/dist/core/bash-jobs.d.ts +11 -0
- package/dist/core/bash-jobs.js +41 -1
- package/dist/core/feed/inbox.d.ts +3 -3
- package/dist/core/feed/inbox.js +10 -6
- package/dist/core/inspector/core.d.ts +17 -2
- package/dist/core/inspector/core.js +172 -23
- package/dist/core/inspector/model.d.ts +30 -1
- package/dist/core/inspector/model.js +39 -0
- package/dist/core/inspector/text.js +14 -1
- package/dist/core/inspector/tui.js +71 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +3 -4
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +9 -13
- package/dist/core/runtime/boot-root.d.ts +2 -2
- package/dist/core/runtime/boot-root.js +7 -7
- package/dist/core/runtime/broker-protocol.d.ts +23 -27
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +5 -11
- package/dist/core/runtime/broker.d.ts +21 -13
- package/dist/core/runtime/broker.js +151 -186
- package/dist/core/runtime/interactive-deliver.js +4 -5
- package/dist/core/runtime/model-swap.d.ts +2 -3
- package/dist/core/runtime/model-swap.js +3 -4
- package/dist/core/runtime/node-read.d.ts +20 -0
- package/dist/core/runtime/node-read.js +34 -1
- package/dist/core/runtime/resume-root.d.ts +1 -1
- package/dist/core/runtime/resume-root.js +6 -6
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/session-model/session-state.d.ts +8 -6
- package/dist/core/session-model/session-state.js +6 -16
- package/dist/core/tui/host.js +21 -6
- package/dist/daemon/api/handlers/nodes.js +18 -1
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/pi-extensions/canvas-bash-valve.js +9 -3
- package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
- package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v5.md +175 -0
- package/docs/public-api.md +2 -2
- package/package.json +2 -2
- package/runtime.lock.json +6 -6
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-control-preempt.test.js +0 -61
|
@@ -78,10 +78,10 @@ export interface SessionStatsSummary {
|
|
|
78
78
|
assistant_messages: number;
|
|
79
79
|
cost?: number;
|
|
80
80
|
}
|
|
81
|
-
/** Viewer
|
|
81
|
+
/** Viewer presence for a node — a truthful count of attached views. There is
|
|
82
|
+
* no owner: every live interactive view is writable at the same time. */
|
|
82
83
|
export interface Presence {
|
|
83
84
|
viewers: number;
|
|
84
|
-
controller: string | null;
|
|
85
85
|
}
|
|
86
86
|
/** Git working-tree change counts for the meta strip. */
|
|
87
87
|
export interface GitStatus {
|
|
@@ -276,7 +276,9 @@ export interface ResolveDeckResponse {
|
|
|
276
276
|
* `isStreaming` (spec C.10).
|
|
277
277
|
*/
|
|
278
278
|
export type SessionState = RpcSessionState;
|
|
279
|
-
/**
|
|
279
|
+
/** Fixed capability of one browser tab's session connection, stated by the
|
|
280
|
+
* broker's `welcome`: `controller` is writable, `observer` is read-only. It is
|
|
281
|
+
* a per-client capability, never ownership of a shared slot. */
|
|
280
282
|
export type WebRole = 'observer' | 'controller';
|
|
281
283
|
/** Upstream broker connectivity, surfaced to the tab (spec §6.2). */
|
|
282
284
|
export type BrokerStatus = 'connected' | 'reconnecting' | 'down' | 'revived';
|
package/dist/commands/node.js
CHANGED
|
@@ -27,7 +27,8 @@ import { profilesStateBlock } from '../core/profiles/state-block.js';
|
|
|
27
27
|
import { stateBlock } from '../core/help.js';
|
|
28
28
|
import { fuzzyMatch } from '../core/canvas/browse/model.js';
|
|
29
29
|
import { loadPins, savePins } from '../core/canvas/browse/pins.js';
|
|
30
|
-
import {
|
|
30
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobPaths, formatBashElapsed, jobStartedAtMs, readJobPgid, tailJobLog } from '../core/bash-jobs.js';
|
|
31
32
|
// The API seam — every canvas mutation/read routes through the daemon (B-1).
|
|
32
33
|
import { cliClient, cliCanvasSource, ApiCanvasSource, rethrowAsCliError, getNodeOrNull } from './api-client.js';
|
|
33
34
|
// Past this much context, an ORCHESTRATOR that spawns a managed child is better
|
|
@@ -808,7 +809,7 @@ const nodeBashBackground = defineLeaf({
|
|
|
808
809
|
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Per handed-off job {job_id, elapsed_ms} — the foreground elapsed time in milliseconds at the moment of handoff.' },
|
|
809
810
|
],
|
|
810
811
|
outputKind: 'object',
|
|
811
|
-
effects: ['Signals every foreground bash command currently running for the node to return from its tool call immediately while its detached supervisor keeps the command running.', 'Each handed-off command retains its log under the node context jobs directory and sends the node an inbox message when it exits.'],
|
|
812
|
+
effects: ['Signals every foreground bash command currently running for the node to return from its tool call immediately while its detached supervisor keeps the command running.', 'Each handed-off command retains its log under the node context jobs directory and sends the node an urgent inbox message when it exits, which steers the node mid-turn.'],
|
|
812
813
|
},
|
|
813
814
|
run: async (input) => {
|
|
814
815
|
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
@@ -842,16 +843,153 @@ const nodeBashBackground = defineLeaf({
|
|
|
842
843
|
return `Handed ${result['backgrounded']} bash command(s) to the background job system for ${result['node_id']} · ran ${formatBashElapsed(oldestElapsedMs)} in the foreground.`;
|
|
843
844
|
},
|
|
844
845
|
});
|
|
846
|
+
const nodeBashList = defineLeaf({
|
|
847
|
+
name: 'list',
|
|
848
|
+
description: 'list a node’s live background bash jobs',
|
|
849
|
+
whenToUse: 'the Inspector’s jobs section reads this to show what a node handed off to the background',
|
|
850
|
+
tier: 'hidden',
|
|
851
|
+
help: {
|
|
852
|
+
name: 'node bash list',
|
|
853
|
+
summary: 'read every still-running background bash job owned by one node',
|
|
854
|
+
params: [
|
|
855
|
+
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node whose background jobs to list. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
|
|
856
|
+
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
|
|
857
|
+
{ kind: 'flag', name: 'tail', type: 'int', required: false, default: 0, constraint: 'Include this many trailing log lines per job (0 = none).' },
|
|
858
|
+
],
|
|
859
|
+
output: [
|
|
860
|
+
{ name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
|
|
861
|
+
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job.' },
|
|
862
|
+
],
|
|
863
|
+
outputKind: 'object',
|
|
864
|
+
effects: ['Reads the node context jobs directory. Changes nothing.'],
|
|
865
|
+
},
|
|
866
|
+
run: async (input) => {
|
|
867
|
+
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
868
|
+
let id = input['node'];
|
|
869
|
+
if (id === undefined || id === '')
|
|
870
|
+
id = nodeInPane(pane);
|
|
871
|
+
if (id === undefined || id === '')
|
|
872
|
+
id = process.env['CRTR_NODE_ID'];
|
|
873
|
+
if (id === undefined || id === '') {
|
|
874
|
+
throw new InputError({ error: 'no_node', message: 'no node found for the bash job list', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
|
|
875
|
+
}
|
|
876
|
+
const node = await getNodeOrNull(id);
|
|
877
|
+
if (node === null) {
|
|
878
|
+
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
879
|
+
}
|
|
880
|
+
const now = Date.now();
|
|
881
|
+
const tail = Math.max(0, Math.trunc(Number(input['tail'] ?? 0)));
|
|
882
|
+
const dir = contextDir(node.node_id);
|
|
883
|
+
const jobs = activeBackgroundBashJobs(dir).map((job) => ({
|
|
884
|
+
job_id: job.jobId,
|
|
885
|
+
command: job.command,
|
|
886
|
+
started_at: new Date(job.startedAtMs).toISOString(),
|
|
887
|
+
elapsed_ms: now - job.startedAtMs,
|
|
888
|
+
log_path: job.logPath,
|
|
889
|
+
pgid: readJobPgid(dir, job.jobId) ?? null,
|
|
890
|
+
log_tail: tailJobLog(job.logPath, tail),
|
|
891
|
+
}));
|
|
892
|
+
return { node_id: node.node_id, jobs };
|
|
893
|
+
},
|
|
894
|
+
render: (result) => {
|
|
895
|
+
const jobs = result['jobs'];
|
|
896
|
+
if (jobs.length === 0)
|
|
897
|
+
return `No background bash jobs for ${result['node_id']}.`;
|
|
898
|
+
return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}\n log: ${job.log_path}`).join('\n');
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
/** SIGTERM the job's process group, give it a moment, then SIGKILL. Returns
|
|
902
|
+
* false only when the group was already gone at the first signal. */
|
|
903
|
+
function stopJobGroup(pgid) {
|
|
904
|
+
try {
|
|
905
|
+
process.kill(-pgid, 'SIGTERM');
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
const nodeBashKill = defineLeaf({
|
|
913
|
+
name: 'kill',
|
|
914
|
+
description: 'stop one of a node’s background bash jobs',
|
|
915
|
+
whenToUse: 'a human watching the Inspector wants a long-running background job to stop now, or an agent is abandoning work it backgrounded',
|
|
916
|
+
tier: 'hidden',
|
|
917
|
+
help: {
|
|
918
|
+
name: 'node bash kill',
|
|
919
|
+
summary: 'terminate a background bash job’s process group and tell the owning node it was canceled',
|
|
920
|
+
params: [
|
|
921
|
+
{ kind: 'positional', name: 'job', required: true, constraint: 'Job id, as reported by node bash list.' },
|
|
922
|
+
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node owning the job. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
|
|
923
|
+
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
|
|
924
|
+
],
|
|
925
|
+
output: [
|
|
926
|
+
{ name: 'node_id', type: 'string', required: true, constraint: 'The owning node.' },
|
|
927
|
+
{ name: 'job_id', type: 'string', required: true, constraint: 'The job that was stopped.' },
|
|
928
|
+
{ name: 'signaled', type: 'boolean', required: true, constraint: 'True when the process group was still alive and received SIGTERM.' },
|
|
929
|
+
],
|
|
930
|
+
outputKind: 'object',
|
|
931
|
+
effects: [
|
|
932
|
+
'Sends SIGTERM (then SIGKILL) to the job’s whole process group, stopping the command and anything it forked.',
|
|
933
|
+
'Records the job’s exit so it stops showing as live, and sends the owning node an urgent inbox message saying the job was canceled — which steers that node mid-turn.',
|
|
934
|
+
],
|
|
935
|
+
},
|
|
936
|
+
run: async (input) => {
|
|
937
|
+
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
938
|
+
let id = input['node'];
|
|
939
|
+
if (id === undefined || id === '')
|
|
940
|
+
id = nodeInPane(pane);
|
|
941
|
+
if (id === undefined || id === '')
|
|
942
|
+
id = process.env['CRTR_NODE_ID'];
|
|
943
|
+
if (id === undefined || id === '') {
|
|
944
|
+
throw new InputError({ error: 'no_node', message: 'no node found for the bash job', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
|
|
945
|
+
}
|
|
946
|
+
const node = await getNodeOrNull(id);
|
|
947
|
+
if (node === null) {
|
|
948
|
+
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
949
|
+
}
|
|
950
|
+
const jobId = input['job'];
|
|
951
|
+
const dir = contextDir(node.node_id);
|
|
952
|
+
const paths = bashJobPaths(dir, jobId);
|
|
953
|
+
if (!existsSync(paths.dir)) {
|
|
954
|
+
throw new InputError({ error: 'not_found', message: `no background job ${jobId} for ${node.node_id}`, next: 'List live jobs with `crtr node bash list`.' });
|
|
955
|
+
}
|
|
956
|
+
const pgid = readJobPgid(dir, jobId);
|
|
957
|
+
if (pgid === undefined) {
|
|
958
|
+
throw new InputError({
|
|
959
|
+
error: 'not_stoppable',
|
|
960
|
+
message: `job ${jobId} recorded no process group`,
|
|
961
|
+
next: `It started before crtr persisted one. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
const signaled = stopJobGroup(pgid);
|
|
965
|
+
// The supervisor shares the group it leads, so it usually dies with the
|
|
966
|
+
// command and never writes job.exit. Record the cancellation ourselves —
|
|
967
|
+
// that sentinel is what retires the job from every live-jobs read.
|
|
968
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
969
|
+
try {
|
|
970
|
+
process.kill(-pgid, 'SIGKILL');
|
|
971
|
+
}
|
|
972
|
+
catch { /* already gone */ }
|
|
973
|
+
if (!existsSync(paths.jobExit))
|
|
974
|
+
writeFileSync(paths.jobExit, '143\n');
|
|
975
|
+
await cliClient().sendMessage(node.node_id, {
|
|
976
|
+
body: `Background bash job ${jobId} was canceled from the Inspector before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
|
|
977
|
+
tier: 'urgent',
|
|
978
|
+
});
|
|
979
|
+
return { node_id: node.node_id, job_id: jobId, signaled };
|
|
980
|
+
},
|
|
981
|
+
render: (result) => `Canceled background bash job ${result['job_id']} for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
|
|
982
|
+
});
|
|
845
983
|
const nodeBash = defineBranch({
|
|
846
984
|
name: 'bash',
|
|
847
985
|
description: 'control file-backed bash jobs owned by a node',
|
|
848
|
-
whenToUse: '
|
|
986
|
+
whenToUse: 'a running bash command must be handed off, listed, or stopped — the tmux prefix menu backgrounds, the Inspector’s jobs section lists and cancels',
|
|
849
987
|
tier: 'hidden',
|
|
850
988
|
help: {
|
|
851
989
|
name: 'node bash',
|
|
852
990
|
summary: 'control file-backed bash jobs owned by a node',
|
|
853
991
|
},
|
|
854
|
-
children: [nodeBashBackground],
|
|
992
|
+
children: [nodeBashBackground, nodeBashList, nodeBashKill],
|
|
855
993
|
});
|
|
856
994
|
export const surfaceNodeIdLeaf = defineLeaf({
|
|
857
995
|
name: 'id',
|
|
@@ -8,17 +8,18 @@ import { runCoreView } from '../core/tui/host.js';
|
|
|
8
8
|
export const surfaceInspectLeaf = defineLeaf({
|
|
9
9
|
name: 'inspect',
|
|
10
10
|
description: 'host the native Inspector for one node',
|
|
11
|
-
whenToUse: 'you want a live native screen for a node’s overview, raw record,
|
|
11
|
+
whenToUse: 'you want a live native screen for a node’s overview, raw record, owner-scoped crons, and live background bash jobs. Use node inspect show for the underlying one-shot data read instead.',
|
|
12
12
|
help: {
|
|
13
13
|
name: 'surface inspect',
|
|
14
14
|
summary: 'host the native Inspector in this pane (the attach viewer opens it as a tmux popup)',
|
|
15
|
+
// sections: overview · schedule (armed crons) · jobs (live background bash) · raw
|
|
15
16
|
params: [
|
|
16
17
|
{ kind: 'positional', name: 'node', required: true, constraint: 'Node id or resolvable reference to inspect.' },
|
|
17
|
-
{ kind: 'flag', name: 'section', type: 'enum', choices: ['overview', 'schedule', 'raw'], required: false, default: 'overview', constraint: 'Section focused when the Inspector opens.' },
|
|
18
|
+
{ kind: 'flag', name: 'section', type: 'enum', choices: ['overview', 'schedule', 'jobs', 'raw'], required: false, default: 'overview', constraint: 'Section focused when the Inspector opens.' },
|
|
18
19
|
],
|
|
19
20
|
output: [],
|
|
20
21
|
outputKind: 'object',
|
|
21
|
-
effects: ['Reads node and
|
|
22
|
+
effects: ['Reads node, cron, and background bash job data through crtr subprocesses and crtrd. Takes over the hosting pane (or popup) until it quits.'],
|
|
22
23
|
},
|
|
23
24
|
run: async (input) => {
|
|
24
25
|
const node = input['node'];
|
|
@@ -240,16 +240,16 @@ test('C3h — a cooling managed launch target boots model-less rather than falli
|
|
|
240
240
|
// `<project_context>` block is environment-only. Covered by
|
|
241
241
|
// context-intro.test.ts's environment-only regression.)
|
|
242
242
|
// ===========================================================================
|
|
243
|
-
// C2 — zero-
|
|
243
|
+
// C2 — zero-writable dialogs resolve to deny/cancel IMMEDIATELY (noOp), never
|
|
244
244
|
// hanging the turn and never waiting on a per-dialog timeout. Drives the REAL
|
|
245
245
|
// makeBrokerUiContext (the exact ExtensionUIContext the SDK hands extensions),
|
|
246
|
-
// with a
|
|
246
|
+
// with a writable() snapshot that reports zero writable viewers.
|
|
247
247
|
// ===========================================================================
|
|
248
|
-
test('C2 — zero-
|
|
248
|
+
test('C2 — zero-writable UI context resolves dialogs to deny/cancel immediately (noOp, no hang)', async () => {
|
|
249
249
|
const ctx = makeBrokerUiContext({
|
|
250
|
-
|
|
250
|
+
writable: () => [], // ZERO writable viewers attached
|
|
251
251
|
forward: () => {
|
|
252
|
-
throw new Error('C2: must NOT forward a dialog when no
|
|
252
|
+
throw new Error('C2: must NOT forward a dialog when no writable viewer is attached');
|
|
253
253
|
},
|
|
254
254
|
pending: new Map(),
|
|
255
255
|
broadcast: () => { },
|
|
@@ -274,7 +274,7 @@ test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately
|
|
|
274
274
|
test('broker UI exposes the live provider pin policy to package extensions', () => {
|
|
275
275
|
let pinned = false;
|
|
276
276
|
const ctx = makeBrokerUiContext({
|
|
277
|
-
|
|
277
|
+
writable: () => [],
|
|
278
278
|
forward: () => { },
|
|
279
279
|
pending: new Map(),
|
|
280
280
|
broadcast: () => { },
|
|
@@ -291,36 +291,36 @@ test('broker UI exposes the live provider pin policy to package extensions', ()
|
|
|
291
291
|
});
|
|
292
292
|
// ===========================================================================
|
|
293
293
|
// M2 (review mq5wkqep / T4) — REPLACES the Wave-0 M-1 cancel-on-detach. A dialog
|
|
294
|
-
// forwarded to a
|
|
295
|
-
// pending so a brief detach/reattach (or
|
|
296
|
-
// not lose an answerable dialog. The broker-side default timeout is the ONLY
|
|
294
|
+
// forwarded to a writable viewer that then DETACHES must NOT be cancelled: it
|
|
295
|
+
// stays pending so a brief detach/reattach (or another writable viewer joining)
|
|
296
|
+
// does not lose an answerable dialog. The broker-side default timeout is the ONLY
|
|
297
297
|
// non-answer resolution (proven here with a short per-dialog timeout standing in
|
|
298
298
|
// for the 120s default). Guards against regressing to the over-eager cancel.
|
|
299
299
|
// ===========================================================================
|
|
300
|
-
test('M2 — a forwarded dialog stays pending
|
|
300
|
+
test('M2 — a forwarded dialog stays pending when its writable viewer detaches, resolving only on the broker-side timeout', async () => {
|
|
301
301
|
const pending = new Map();
|
|
302
302
|
let attached = true;
|
|
303
303
|
const ctx = makeBrokerUiContext({
|
|
304
304
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
305
|
-
|
|
305
|
+
writable: () => (attached ? [{ id: 'c1' }] : []),
|
|
306
306
|
forward: () => {
|
|
307
|
-
/* a real
|
|
307
|
+
/* a real writable viewer would receive the request over its socket */
|
|
308
308
|
},
|
|
309
309
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
310
310
|
pending: pending,
|
|
311
311
|
broadcast: () => { },
|
|
312
312
|
});
|
|
313
|
-
//
|
|
314
|
-
// request retained for welcome.pending_dialog /
|
|
315
|
-
// resolve yet. A short per-dialog timeout stands in for the 120s broker default.
|
|
313
|
+
// A writable viewer attached → confirm() forwards + registers a pending dialog
|
|
314
|
+
// (with the request retained for welcome.pending_dialog / replay, T4) and does
|
|
315
|
+
// NOT resolve yet. A short per-dialog timeout stands in for the 120s broker default.
|
|
316
316
|
const p = ctx.confirm('proceed?', 'really?', { timeout: 80 });
|
|
317
|
-
assert.equal(pending.size, 1, 'M2: a forwarded dialog is pending while a
|
|
317
|
+
assert.equal(pending.size, 1, 'M2: a forwarded dialog is pending while a writable viewer is attached');
|
|
318
318
|
const entry = [...pending.values()][0];
|
|
319
319
|
assert.ok(entry.request, 'M2: the pending entry retains the request (welcome/re-route need it)');
|
|
320
320
|
assert.equal(entry.cancel, undefined, 'M2: there is no cancel-on-detach path anymore');
|
|
321
|
-
//
|
|
321
|
+
// The writable viewer detaches → the dialog STAYS pending (the broker no longer cancels it).
|
|
322
322
|
attached = false;
|
|
323
|
-
assert.equal(pending.size, 1, 'M2: detach does NOT cancel the in-flight dialog');
|
|
323
|
+
assert.equal(pending.size, 1, 'M2: writable-viewer detach does NOT cancel the in-flight dialog');
|
|
324
324
|
// Only the broker-side timeout resolves it, to the SAFE default (deny).
|
|
325
325
|
const resolved = await Promise.race([
|
|
326
326
|
p,
|
|
@@ -75,7 +75,7 @@ async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
|
|
|
75
75
|
await wait(stepMs);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
-
test('coalesce inlines short
|
|
78
|
+
test('coalesce inlines short report bodies with a trailing artifact pointer and only hints for remaining canonical refs', () => {
|
|
79
79
|
freshNode('child');
|
|
80
80
|
const child = createNode({
|
|
81
81
|
node_id: 'child',
|
|
@@ -93,8 +93,10 @@ test('coalesce inlines short reports with their absolute path and only hints for
|
|
|
93
93
|
const digest = coalesce([
|
|
94
94
|
finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'update', label: 'short report', ref: 'child:reports/short-update.md' }),
|
|
95
95
|
]);
|
|
96
|
-
|
|
97
|
-
assert.
|
|
96
|
+
// The label is NOT reprinted above the body it duplicates; the path trails it.
|
|
97
|
+
assert.doesNotMatch(digest, /\[update\] short report/);
|
|
98
|
+
assert.match(digest, /\[update\]\n {4}short report body\n/);
|
|
99
|
+
assert.match(digest, new RegExp(`\\(report: ${reportPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`));
|
|
98
100
|
assert.doesNotMatch(digest, /Dereference a ref with/);
|
|
99
101
|
const guidance = 'Dereference a ref with `crtr canvas history read <ref>`.';
|
|
100
102
|
const canonical = coalesce([
|
|
@@ -41,23 +41,16 @@ test('local invalid/no-node stream failures close asynchronously after the chann
|
|
|
41
41
|
assert.equal(events[1]?.startsWith('close:'), true, `${target}: follows with streamClose`);
|
|
42
42
|
}
|
|
43
43
|
});
|
|
44
|
-
test('
|
|
45
|
-
let current = {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
transcript: [],
|
|
55
|
-
draft: '',
|
|
56
|
-
notices: [],
|
|
57
|
-
pendingDialog: null,
|
|
58
|
-
queued: [],
|
|
59
|
-
contextTokens: undefined,
|
|
60
|
-
};
|
|
44
|
+
test('reconnect keeps the requested role while welcome and read_only remain authoritative', () => {
|
|
45
|
+
let current = core.init({ target: 'n1', role: 'controller' });
|
|
46
|
+
const sent = [];
|
|
47
|
+
const requestedRoles = [];
|
|
48
|
+
const closed = [];
|
|
49
|
+
const channels = [
|
|
50
|
+
{ id: 'first-channel', send(frame) { sent.push(frame); }, close() { closed.push('first-channel'); } },
|
|
51
|
+
{ id: 'replacement-channel', send(frame) { sent.push(frame); }, close() { closed.push('replacement-channel'); } },
|
|
52
|
+
];
|
|
53
|
+
let nextChannel = 0;
|
|
61
54
|
const ctx = {
|
|
62
55
|
get state() {
|
|
63
56
|
return current;
|
|
@@ -65,6 +58,13 @@ test('control_changed demotion updates reconnect intent without clobbering an ex
|
|
|
65
58
|
set(next) {
|
|
66
59
|
current = typeof next === 'function' ? next(current) : next;
|
|
67
60
|
},
|
|
61
|
+
connect(_stream, opts) {
|
|
62
|
+
requestedRoles.push(opts.role);
|
|
63
|
+
return channels[nextChannel++];
|
|
64
|
+
},
|
|
65
|
+
dispatch(intent) {
|
|
66
|
+
return core.intents[intent](ctx);
|
|
67
|
+
},
|
|
68
68
|
signal: {
|
|
69
69
|
clearBanner() { },
|
|
70
70
|
setBanner() { },
|
|
@@ -74,11 +74,32 @@ test('control_changed demotion updates reconnect intent without clobbering an ex
|
|
|
74
74
|
quit() { },
|
|
75
75
|
},
|
|
76
76
|
};
|
|
77
|
-
core.intents.
|
|
78
|
-
|
|
79
|
-
assert.equal(current.desiredRole, '
|
|
80
|
-
current
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
core.intents.refresh(ctx);
|
|
78
|
+
const firstChannel = current.channel;
|
|
79
|
+
assert.equal(current.desiredRole, 'controller');
|
|
80
|
+
assert.equal(current.conn, 'connecting');
|
|
81
|
+
assert.deepEqual(requestedRoles, ['controller']);
|
|
82
|
+
core.intents.streamOpen(ctx, firstChannel);
|
|
83
|
+
core.intents.streamFrame(ctx, { type: 'welcome', role: 'controller', snapshot: { state: {}, messages: [] } });
|
|
84
|
+
assert.equal(current.role, 'controller');
|
|
85
|
+
assert.equal(current.conn, 'open');
|
|
86
|
+
core.intents.reconnect(ctx);
|
|
87
|
+
assert.deepEqual(closed, ['first-channel']);
|
|
83
88
|
assert.equal(current.desiredRole, 'controller');
|
|
89
|
+
assert.equal(current.channel?.id, 'replacement-channel');
|
|
90
|
+
assert.deepEqual(requestedRoles, ['controller', 'controller']);
|
|
91
|
+
const replacementChannel = current.channel;
|
|
92
|
+
core.intents.streamOpen(ctx, replacementChannel);
|
|
93
|
+
core.intents.streamFrame(ctx, { type: 'welcome', role: 'observer', snapshot: { state: {}, messages: [] } });
|
|
94
|
+
assert.equal(current.role, 'observer', 'welcome.role is authoritative for the active connection');
|
|
95
|
+
assert.equal(current.desiredRole, 'controller', 'the requested controller role survives reconnect');
|
|
96
|
+
assert.equal(current.conn, 'open');
|
|
97
|
+
core.intents.streamClose(ctx, { channelId: 'first-channel', reason: 'closed' });
|
|
98
|
+
assert.equal(current.channel, replacementChannel, 'a stale close cannot clear the replacement channel');
|
|
99
|
+
assert.equal(current.conn, 'open');
|
|
100
|
+
core.intents.streamFrame(ctx, { type: 'error', code: 'read_only' });
|
|
101
|
+
core.intents.setDraft(ctx, 'hello');
|
|
102
|
+
core.intents.submitDraft(ctx);
|
|
103
|
+
assert.deepEqual(sent, [], 'read-only welcome state prevents writes');
|
|
104
|
+
assert.match(current.notices.at(-1).text, /read-only — sending is disabled/);
|
|
84
105
|
});
|
|
@@ -3,27 +3,32 @@
|
|
|
3
3
|
// FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process
|
|
4
4
|
// (~5s pi-SDK load), so it lives in full/ (CI), not the fast local loop.
|
|
5
5
|
//
|
|
6
|
-
// T8 — the `crtr surface attach` acceptance gate for G4/G7/G8:
|
|
7
|
-
//
|
|
6
|
+
// T8 — the `crtr surface attach` acceptance gate for G4/G7/G8: multi-writer
|
|
7
|
+
// admission, decoder overflow isolation, and backpressure shedding.
|
|
8
8
|
//
|
|
9
9
|
// 3-PART HEADER (headless):
|
|
10
|
-
// (1) CONTRACT —
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
10
|
+
// (1) CONTRACT — every client that hellos as `controller` is welcomed writable,
|
|
11
|
+
// simultaneously and independently; each writable client may submit, and
|
|
12
|
+
// every connected client (both writers plus a read-only observer) receives
|
|
13
|
+
// the merged stream of both submissions, while a client that hellos as
|
|
14
|
+
// `observer` is rejected with error{read_only} when it tries to drive (G4).
|
|
15
|
+
// An oversized client line is capped and dropped with frame_overflow while
|
|
16
|
+
// the broker and other clients continue operating (G7). A stalled
|
|
17
|
+
// non-reading viewer is shed at the 32 MiB backpressure HWM while the
|
|
18
|
+
// broker and fast viewers remain unaffected (G8).
|
|
19
|
+
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — role admission, decoder limits,
|
|
20
|
+
// and the backpressure HWM are broker-process and view.sock contracts. They
|
|
21
|
+
// are exercised over a pure Unix socket using the production ViewSocketClient
|
|
22
|
+
// and raw node:net peers, with a real broker process and no tmux pane or
|
|
23
|
+
// window.
|
|
20
24
|
// (3) HOW THE HEADLESS DRIVE ASSERTS THE CONTRACT — the real socket responses
|
|
21
|
-
// provide the G4 welcome roles
|
|
22
|
-
//
|
|
25
|
+
// provide the G4 welcome roles, per-writer relay frames and the read-only
|
|
26
|
+
// rejection, the G7 frame-overflow drop and broker survival, and the G8
|
|
27
|
+
// HWM-shed log line and broker survival.
|
|
23
28
|
//
|
|
24
|
-
// One shared broker is booted in before() and reused across all three gates.
|
|
25
|
-
//
|
|
26
|
-
//
|
|
29
|
+
// One shared broker is booted in before() and reused across all three gates. There
|
|
30
|
+
// is no writer slot to hand off, so every gate simply attaches at the role it
|
|
31
|
+
// wants.
|
|
27
32
|
import { test, before, after, afterEach } from 'node:test';
|
|
28
33
|
import assert from 'node:assert/strict';
|
|
29
34
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
@@ -32,9 +37,7 @@ import { createAttachKit, delay, tok, frameHas, brokerLogText, } from '../helper
|
|
|
32
37
|
let h;
|
|
33
38
|
let id; // ONE shared broker, reused across G4/G7/G8 (1 real boot, not 3)
|
|
34
39
|
const kit = createAttachKit(() => h);
|
|
35
|
-
const { attach,
|
|
36
|
-
// Admit a controller, waiting out any prior gate's controller-detach handoff.
|
|
37
|
-
const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
|
|
40
|
+
const { attach, connectRaw } = kit;
|
|
38
41
|
before(async () => {
|
|
39
42
|
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brklim' });
|
|
40
43
|
const root = h.spawnRoot('broker-attach-limits suite root');
|
|
@@ -49,23 +52,44 @@ afterEach(() => {
|
|
|
49
52
|
});
|
|
50
53
|
const brokerPid = () => h.node(id).pi_pid;
|
|
51
54
|
// ---------------------------------------------------------------------------
|
|
52
|
-
// G4 —
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// that
|
|
55
|
+
// G4 — multi-writer admission + read-only gating. Guards: two simultaneous
|
|
56
|
+
// `controller` hellos are BOTH welcomed writable, each one's own submission
|
|
57
|
+
// reaches the engine, and every connected client sees BOTH turns; an explicit
|
|
58
|
+
// observer that tries to drive is rejected with error{read_only}.
|
|
59
|
+
// Failure mode: a singleton writer slot demoting the second client, a writer
|
|
60
|
+
// whose prompt is silently dropped, fan-out that misses a viewer, or a
|
|
61
|
+
// read-only client that can drive the engine.
|
|
56
62
|
// ---------------------------------------------------------------------------
|
|
57
|
-
test('G4 —
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
test('G4 — two simultaneous clients are both welcomed controller, each submits, and both see both turns; an observer drive → error{read_only}', async () => {
|
|
64
|
+
const [w1, w2] = await Promise.all([
|
|
65
|
+
attach(id, 'controller', 'g4-writer-1'),
|
|
66
|
+
attach(id, 'controller', 'g4-writer-2'),
|
|
67
|
+
]);
|
|
68
|
+
assert.equal(w1.welcome.role, 'controller', 'first client is welcomed writable');
|
|
69
|
+
assert.equal(w2.welcome.role, 'controller', 'second SIMULTANEOUS client is ALSO welcomed writable (no singleton slot)');
|
|
70
|
+
const observer = await attach(id, 'observer', 'g4-observer');
|
|
71
|
+
assert.equal(observer.welcome.role, 'observer', 'an explicit observer hello stays read-only');
|
|
72
|
+
// A read-only client may not drive the engine — the role is per-client and
|
|
73
|
+
// fixed by its own hello, so this rejection is independent of who else is
|
|
74
|
+
// attached.
|
|
75
|
+
observer.send({ type: 'prompt', text: 'a read-only client must not drive' });
|
|
76
|
+
const err = await observer.waitFrame((f) => f.type === 'error', 'G4 observer prompt rejected');
|
|
77
|
+
assert.equal(err.code, 'read_only', 'G4: observer prompt → error{read_only}');
|
|
78
|
+
// Each writer submits its OWN token. Submissions are sequenced (a prompt sent
|
|
79
|
+
// mid-stream routes as a steer and produces no second turn), so each turn is
|
|
80
|
+
// driven to its terminal agent_end before the next writer submits.
|
|
81
|
+
const tokenA = tok('G4-WRITER-1');
|
|
82
|
+
w1.send({ type: 'prompt', text: tokenA });
|
|
83
|
+
await w1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenA), 'G4 writer 1 saw its own turn');
|
|
84
|
+
const tokenB = tok('G4-WRITER-2');
|
|
85
|
+
w2.send({ type: 'prompt', text: tokenB });
|
|
86
|
+
await w2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenB), 'G4 writer 2 saw its own turn');
|
|
87
|
+
// The merged stream: EVERY connected client — both writers and the read-only
|
|
88
|
+
// observer — receives BOTH writers' turns.
|
|
89
|
+
await w2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenA), 'G4 writer 2 ALSO received writer 1 turn');
|
|
90
|
+
await w1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenB), 'G4 writer 1 ALSO received writer 2 turn');
|
|
91
|
+
await observer.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenA), 'G4 observer received writer 1 turn');
|
|
92
|
+
await observer.waitFrame((f) => f.type === 'agent_end' && frameHas(f, tokenB), 'G4 observer received writer 2 turn');
|
|
69
93
|
});
|
|
70
94
|
// ---------------------------------------------------------------------------
|
|
71
95
|
// G7 — decoder overflow (guards C5 OOM). A client line over BROKER_READ_CAPS is
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
// The engine is hosted IN-PROCESS by the broker, so engine pid == broker pid ==
|
|
33
33
|
// node.pi_pid == boot.pid; "engine pid unchanged" == broker pid unchanged + no new
|
|
34
34
|
// boot. ONE shared broker is booted in before() and reused across all three gates
|
|
35
|
-
// (each just attaches fresh clients) — 1 real boot total, not 3.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
35
|
+
// (each just attaches fresh clients) — 1 real boot total, not 3. Each gate attaches
|
|
36
|
+
// a fresh writable client; afterEach closes prior sockets before the next gate begins,
|
|
37
|
+
// and each client's role remains fixed for its socket.
|
|
38
38
|
import { test, before, after, afterEach } from 'node:test';
|
|
39
39
|
import assert from 'node:assert/strict';
|
|
40
40
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
@@ -44,7 +44,7 @@ let h;
|
|
|
44
44
|
let id; // ONE shared broker, reused across G1/G1b/G2 (1 real boot, not 3)
|
|
45
45
|
const kit = createAttachKit(() => h);
|
|
46
46
|
const { attach, attachUntil } = kit;
|
|
47
|
-
//
|
|
47
|
+
// Attach a writable client for the gate; its role remains fixed for this socket.
|
|
48
48
|
const ctrl = (cid) => attachUntil(id, 'controller', cid, (a) => a.welcome.role === 'controller', `${cid} admitted controller`);
|
|
49
49
|
before(async () => {
|
|
50
50
|
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brkstrm' });
|