@crouton-kit/crouter 0.3.35 → 0.3.37
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/build-root.js +2 -6
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-personas/runtime-base.md +1 -1
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +4 -4
- package/dist/builtin-views/canvas/core.mjs +3 -2
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +21 -205
- package/dist/clients/attach/attach-cmd.js +383 -386
- package/dist/clients/web/web-cmd.js +7 -7
- package/dist/commands/__tests__/human.test.js +1 -15
- package/dist/commands/attention.js +9 -6
- package/dist/commands/canvas-history/search.js +2 -2
- package/dist/commands/canvas-history/show.js +3 -3
- package/dist/commands/canvas-history.js +1 -1
- package/dist/commands/canvas-issue.js +1 -0
- package/dist/commands/canvas-snapshot.js +1 -0
- package/dist/commands/canvas.js +10 -11
- package/dist/commands/chord.js +1 -0
- package/dist/commands/human/prompts.js +0 -1
- package/dist/commands/human/queue.js +1 -0
- package/dist/commands/node-snapshot.js +1 -0
- package/dist/commands/node.js +12 -11
- package/dist/commands/push.d.ts +0 -1
- package/dist/commands/push.js +2 -228
- package/dist/commands/revive.js +2 -2
- package/dist/commands/{canvas-tmux-spread.js → surface-tmux-spread.js} +9 -6
- package/dist/commands/surface.js +3 -2
- package/dist/commands/sys/daemon.d.ts +2 -0
- package/dist/commands/{daemon.js → sys/daemon.js} +12 -12
- package/dist/commands/sys/doctor.js +1 -0
- package/dist/commands/sys.js +4 -3
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/daemon-wedge.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-wedge.test.js +87 -0
- package/dist/core/__tests__/hearth-bootstrap.test.js +2 -2
- package/dist/core/__tests__/history-inbox.test.d.ts +1 -0
- package/dist/core/__tests__/history-inbox.test.js +105 -0
- package/dist/core/__tests__/kickoff.test.js +3 -3
- package/dist/core/__tests__/revive.test.js +39 -1
- package/dist/core/canvas/history.d.ts +1 -1
- package/dist/core/canvas/history.js +90 -3
- package/dist/core/canvas/paths.d.ts +6 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/canvas/status-glyph.js +1 -0
- package/dist/core/fault-classifier.d.ts +2 -2
- package/dist/core/feed/inbox.d.ts +7 -12
- package/dist/core/feed/inbox.js +45 -29
- package/dist/core/hearth/providers/blaxel-bootstrap.js +1 -1
- package/dist/core/preview-registry.js +14 -112
- package/dist/core/render.js +1 -1
- package/dist/core/runtime/busy.d.ts +15 -0
- package/dist/core/runtime/busy.js +32 -1
- package/dist/core/runtime/close.d.ts +10 -0
- package/dist/core/runtime/close.js +18 -13
- package/dist/core/runtime/fault-recovery.js +4 -0
- package/dist/core/runtime/kickoff.js +5 -7
- package/dist/core/runtime/placement.js +1 -1
- package/dist/core/runtime/revive.js +9 -1
- package/dist/core/runtime/tmux.d.ts +2 -2
- package/dist/core/runtime/tmux.js +2 -2
- package/dist/daemon/crtrd-cli.js +1 -1
- package/dist/daemon/crtrd.d.ts +24 -0
- package/dist/daemon/crtrd.js +213 -5
- package/dist/daemon/manage.js +2 -2
- package/dist/pi-extensions/canvas-nav.js +2 -2
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +59 -2
- package/dist/web-client/assets/{index-IAJVtuVe.js → index-MSKSWwV0.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/package.json +1 -1
- package/dist/commands/daemon.d.ts +0 -2
- /package/dist/commands/{canvas-tmux-spread.d.ts → surface-tmux-spread.d.ts} +0 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/history-inbox.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Locks in the invariants of the `inbox` history source: it stays opt-in
|
|
4
|
+
// (never part of the default corpus), its refs resolve/round-trip as
|
|
5
|
+
// `<node>:inbox/NNNNNN`, direct-message bodies stay full/unclipped, and the
|
|
6
|
+
// sender (`from`) is visible in both the read body and the artifact title.
|
|
7
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { createNode } from '../canvas/canvas.js';
|
|
13
|
+
import { closeDb } from '../canvas/db.js';
|
|
14
|
+
import { appendInbox } from '../feed/inbox.js';
|
|
15
|
+
import { buildCorpus, resolveRef } from '../canvas/history.js';
|
|
16
|
+
let home;
|
|
17
|
+
function node(id, cwd) {
|
|
18
|
+
return {
|
|
19
|
+
node_id: id,
|
|
20
|
+
name: id,
|
|
21
|
+
created: new Date().toISOString(),
|
|
22
|
+
cwd,
|
|
23
|
+
kind: 'general',
|
|
24
|
+
mode: 'base',
|
|
25
|
+
lifecycle: 'terminal',
|
|
26
|
+
status: 'active',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
before(() => {
|
|
30
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-history-inbox-'));
|
|
31
|
+
process.env['CRTR_HOME'] = home;
|
|
32
|
+
});
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
closeDb();
|
|
35
|
+
rmSync(home, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
after(() => {
|
|
38
|
+
closeDb();
|
|
39
|
+
rmSync(home, { recursive: true, force: true });
|
|
40
|
+
delete process.env['CRTR_HOME'];
|
|
41
|
+
});
|
|
42
|
+
test('default history excludes inbox; --type inbox includes it, with full sender-attributed bodies', () => {
|
|
43
|
+
const cwd = '/tmp/hist-work';
|
|
44
|
+
createNode(node('sender-1', cwd));
|
|
45
|
+
const receiver = createNode(node('receiver-1', cwd));
|
|
46
|
+
const longBody = 'a'.repeat(4000); // well past any old label-clip length
|
|
47
|
+
appendInbox(receiver.node_id, {
|
|
48
|
+
from: 'sender-1',
|
|
49
|
+
tier: 'urgent',
|
|
50
|
+
kind: 'message',
|
|
51
|
+
label: 'first line of a long message',
|
|
52
|
+
data: { body: longBody },
|
|
53
|
+
});
|
|
54
|
+
// (a) default corpus (no explicit types) excludes inbox entirely.
|
|
55
|
+
const defaultCorpus = buildCorpus({ nodes: [receiver.node_id] }, {});
|
|
56
|
+
assert.equal(defaultCorpus.some((a) => a.source === 'inbox'), false, 'inbox is opt-in, not part of the default corpus');
|
|
57
|
+
// (b) explicit --type inbox includes it.
|
|
58
|
+
const inboxCorpus = buildCorpus({ nodes: [receiver.node_id] }, { types: ['inbox'] });
|
|
59
|
+
assert.equal(inboxCorpus.length, 1, 'exactly the one appended entry');
|
|
60
|
+
const artifact = inboxCorpus[0];
|
|
61
|
+
assert.equal(artifact.source, 'inbox');
|
|
62
|
+
// (c) ref resolves as <node>:inbox/000000 and round-trips through resolveRef.
|
|
63
|
+
const expectedRef = `${receiver.node_id}:inbox/000000`;
|
|
64
|
+
assert.equal(artifact.ref, expectedRef);
|
|
65
|
+
const resolved = resolveRef(expectedRef);
|
|
66
|
+
assert.ok(resolved !== null, 'ref resolves back to the entry');
|
|
67
|
+
assert.equal(resolved.source, 'inbox');
|
|
68
|
+
// (d) the direct-message body is full/unclipped in both the corpus artifact
|
|
69
|
+
// and the resolveRef read path.
|
|
70
|
+
assert.ok(artifact.loadBody().includes(longBody), 'corpus artifact body is unclipped');
|
|
71
|
+
assert.ok(resolved.body.includes(longBody), 'resolveRef body is unclipped');
|
|
72
|
+
// (e) the sender is visible — in the read body (not just resolvable via the
|
|
73
|
+
// node the inbox belongs to) and in the artifact title used by list rows.
|
|
74
|
+
assert.ok(resolved.body.includes('from: sender-1'), 'read body names the sender');
|
|
75
|
+
assert.ok(artifact.title.includes('sender-1'), 'artifact title names the sender for list/search rows');
|
|
76
|
+
});
|
|
77
|
+
test('a spilled message body is read RAW — a leading frontmatter-shaped block is not stripped', () => {
|
|
78
|
+
const cwd = '/tmp/hist-work-3';
|
|
79
|
+
createNode(node('sender-3', cwd));
|
|
80
|
+
const receiver = createNode(node('receiver-3', cwd));
|
|
81
|
+
// Long enough to force the spill-to-messages/ path (past BODY_MAX_CHARS),
|
|
82
|
+
// and starts with a block that LOOKS like crouter report frontmatter but
|
|
83
|
+
// isn't one — a spilled message has no frontmatter contract, so this must
|
|
84
|
+
// survive intact, unlike a report ref (which always has real frontmatter
|
|
85
|
+
// stripped by fileBodyLoader).
|
|
86
|
+
const body = '---\nfoo: bar\n---\n' + 'a'.repeat(4000);
|
|
87
|
+
appendInbox(receiver.node_id, {
|
|
88
|
+
from: 'sender-3',
|
|
89
|
+
tier: 'normal',
|
|
90
|
+
kind: 'message',
|
|
91
|
+
label: 'first line',
|
|
92
|
+
data: { body },
|
|
93
|
+
});
|
|
94
|
+
const resolved = resolveRef(`${receiver.node_id}:inbox/000000`);
|
|
95
|
+
assert.ok(resolved !== null);
|
|
96
|
+
assert.ok(resolved.body.includes('---\nfoo: bar\n---'), 'leading frontmatter-shaped block is preserved, not stripped');
|
|
97
|
+
});
|
|
98
|
+
test('a null-from (system) inbox entry reads as "system", not blank or thrown', () => {
|
|
99
|
+
const cwd = '/tmp/hist-work-2';
|
|
100
|
+
const receiver = createNode(node('receiver-2', cwd));
|
|
101
|
+
appendInbox(receiver.node_id, { from: null, tier: 'normal', kind: 'update', label: 'system note' });
|
|
102
|
+
const resolved = resolveRef(`${receiver.node_id}:inbox/000000`);
|
|
103
|
+
assert.ok(resolved !== null);
|
|
104
|
+
assert.ok(resolved.body.includes('from: system'), 'null sender renders as "system"');
|
|
105
|
+
});
|
|
@@ -58,7 +58,7 @@ test('drainBearings consumes the yield note + advances the cursor exactly once',
|
|
|
58
58
|
assert.equal(b2.yieldMsg, null);
|
|
59
59
|
assert.equal(b2.unreadDigest, null);
|
|
60
60
|
});
|
|
61
|
-
test('the feed block frames awaiting workers as alive + auto-waking, so a fresh revive has no reason to
|
|
61
|
+
test('the feed block frames awaiting workers as alive + auto-waking, so a fresh revive has no reason to poll', () => {
|
|
62
62
|
const parent = createNode(node('p1'));
|
|
63
63
|
const child = createNode(node('c1')); // status 'active' by default
|
|
64
64
|
subscribe(parent.node_id, child.node_id); // parent awaits child
|
|
@@ -66,7 +66,7 @@ test('the feed block frames awaiting workers as alive + auto-waking, so a fresh
|
|
|
66
66
|
// The roster names the live child...
|
|
67
67
|
assert.ok(msg.includes(child.node_id), 'awaiting roster lists the live child');
|
|
68
68
|
// ...and asserts aliveness + the automatic wake at the source, so the node
|
|
69
|
-
//
|
|
69
|
+
// has nothing to check, poll, or verify separately.
|
|
70
70
|
assert.ok(/alive and running/.test(msg), 'states the worker is alive and running');
|
|
71
71
|
assert.ok(/wake you the moment/.test(msg), 'states the wake is automatic on push');
|
|
72
72
|
assert.ok(/still working, not stalled/.test(msg), 'frames the empty feed as expected, not a problem');
|
|
@@ -101,7 +101,7 @@ test('a fresh revive is pointed at its subscriptions\' on-disk report history (c
|
|
|
101
101
|
assert.ok(!msg.includes(newer), 'absolute report paths not rendered');
|
|
102
102
|
assert.ok(/canvas history read <ref>/.test(msg), 'points at one-report expansion');
|
|
103
103
|
assert.ok(/canvas history show <node-id> --type report/.test(msg), 'points at full per-node expansion');
|
|
104
|
-
assert.ok(/
|
|
104
|
+
assert.ok(/canvas history show <node-id> --type inbox/.test(msg), 'points at the cursor-independent full-history replay');
|
|
105
105
|
});
|
|
106
106
|
test('buildReviveKickoff is pure — building twice eats nothing', () => {
|
|
107
107
|
const id = 'n2';
|
|
@@ -34,7 +34,8 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
34
34
|
import { tmpdir } from 'node:os';
|
|
35
35
|
import { join } from 'node:path';
|
|
36
36
|
import { spawn, spawnSync } from 'node:child_process';
|
|
37
|
-
import { createNode } from '../canvas/canvas.js';
|
|
37
|
+
import { createNode, subscribe } from '../canvas/canvas.js';
|
|
38
|
+
import { readInboxSince } from '../feed/inbox.js';
|
|
38
39
|
import { closeDb } from '../canvas/db.js';
|
|
39
40
|
import { buildPiArgv } from '../runtime/launch.js';
|
|
40
41
|
import { headlessBrokerHost } from '../runtime/host.js';
|
|
@@ -298,3 +299,40 @@ test('reviveNode crashes and clears pid when launch/preflight throws', async ()
|
|
|
298
299
|
await h.dispose();
|
|
299
300
|
}
|
|
300
301
|
});
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Crash-to-dead doctrine wake — a launch-refusal marks the node dead; a
|
|
304
|
+
// manager waiting dormant on it must be told, or it hangs forever (the
|
|
305
|
+
// daemon no longer supervises a dead node). Mirrors closeNode's step-4 fan-out.
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
test('reviveNode fans a doctrine wake to subscribers when launch/preflight throws (crash-to-dead)', async () => {
|
|
308
|
+
const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-crashwake' });
|
|
309
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
310
|
+
try {
|
|
311
|
+
const root = h.spawnRoot('crash-wake suite root');
|
|
312
|
+
const sessionFile = join(home, 'sessions', 'crtr-revive-crashwake.jsonl');
|
|
313
|
+
mkdirSync(join(home, 'sessions'), { recursive: true });
|
|
314
|
+
writeFileSync(sessionFile, '{}\n', 'utf8');
|
|
315
|
+
const M = h.fabricateBrokerNode({
|
|
316
|
+
parent: root,
|
|
317
|
+
status: 'active',
|
|
318
|
+
intent: null,
|
|
319
|
+
pi_pid: deadPid(),
|
|
320
|
+
pi_session_id: 'uuid-3',
|
|
321
|
+
pi_session_file: sessionFile,
|
|
322
|
+
});
|
|
323
|
+
// spawnChild-equivalent auto-subscription — done explicitly since this node
|
|
324
|
+
// was fabricated directly, not spawned through the CLI.
|
|
325
|
+
subscribe(root, M, true);
|
|
326
|
+
headlessBrokerHost.launch = (_nodeId, _inv, _opts) => { throw new Error('boom'); };
|
|
327
|
+
assert.throws(() => reviveNode(M, { resume: true }), /refusing to revive/i);
|
|
328
|
+
assert.equal(h.node(M).status, 'dead', 'failed revive marks the node dead');
|
|
329
|
+
const entries = readInboxSince(root, undefined);
|
|
330
|
+
const notice = entries.find((e) => e.data?.['reason'] === 'child-crashed' && e.data?.['child'] === M);
|
|
331
|
+
assert.ok(notice !== undefined, 'the surviving parent got a doctrine wake for the dead child');
|
|
332
|
+
assert.equal(notice.from, M, 'the notice is attributed to the crashed child');
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
336
|
+
await h.dispose();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { NodeStatus } from './types.js';
|
|
2
|
-
export type HistorySource = 'report' | 'doc' | 'roadmap' | 'meta';
|
|
2
|
+
export type HistorySource = 'report' | 'doc' | 'roadmap' | 'meta' | 'inbox';
|
|
3
3
|
/** One searchable unit of the episodic record: a report, a context doc, a
|
|
4
4
|
* node's roadmap, or a node's meta (name/description/kind). */
|
|
5
5
|
export interface HistoryArtifact {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { statSync, readdirSync } from 'node:fs';
|
|
13
13
|
import { join, relative, sep } from 'node:path';
|
|
14
14
|
import { getNode, listNodes, view } from './canvas.js';
|
|
15
|
-
import { contextDir, reportsDir, nodeDir } from './paths.js';
|
|
15
|
+
import { contextDir, reportsDir, nodeDir, inboxPath } from './paths.js';
|
|
16
16
|
import { readTextIfExists, walkFiles, pathExists } from '../fs-utils.js';
|
|
17
17
|
import { parseFrontmatterGeneric } from '../frontmatter.js';
|
|
18
18
|
/** The cwd the caller means by default: the calling node's cwd (resolved from
|
|
@@ -126,6 +126,68 @@ function docArtifact(row, desc, abs) {
|
|
|
126
126
|
loadBody: load,
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
|
+
/** Every entry ever appended to a node's inbox.jsonl, oldest first, cursor-
|
|
130
|
+
* independent (the cursor only gates the live wake path, not this read).
|
|
131
|
+
* Empty when the node has no inbox file yet. */
|
|
132
|
+
function allInboxEntries(nodeId) {
|
|
133
|
+
const raw = readTextIfExists(inboxPath(nodeId));
|
|
134
|
+
if (raw === null)
|
|
135
|
+
return [];
|
|
136
|
+
return raw
|
|
137
|
+
.split('\n')
|
|
138
|
+
.filter((l) => l.trim() !== '')
|
|
139
|
+
.map((l) => JSON.parse(l));
|
|
140
|
+
}
|
|
141
|
+
/** One inbox entry's full content: a direct message or system alert's
|
|
142
|
+
* un-clipped body — dereferenced from its spilled ref file when the inline
|
|
143
|
+
* preview clipped, else the inline body itself — or, for a push pointer
|
|
144
|
+
* (which carries no body of its own), its kind/label plus the sender-side
|
|
145
|
+
* report path it points to.
|
|
146
|
+
*
|
|
147
|
+
* A spilled ref (message/system notice too long to inline) is arbitrary
|
|
148
|
+
* text with no crouter frontmatter contract — read it RAW, unlike a report
|
|
149
|
+
* ref, which always carries the `---\n...\n---` header stripped by
|
|
150
|
+
* fileBodyLoader. The presence of an inline `data.body` preview is what
|
|
151
|
+
* distinguishes the two: only a spilled message/notice ever carries one
|
|
152
|
+
* (a push pointer's entry has no `data` field at all). */
|
|
153
|
+
function inboxEntryBody(e) {
|
|
154
|
+
const meta = [`from: ${e.from ?? 'system'}`, `tier: ${e.tier}`].join('\n');
|
|
155
|
+
const header = `[${e.kind}] ${e.label}`;
|
|
156
|
+
const inlinePreview = typeof e.data?.['body'] === 'string' ? e.data['body'].trim() : '';
|
|
157
|
+
const isSpilledMessage = typeof e.data?.['body'] === 'string';
|
|
158
|
+
const full = e.ref !== undefined
|
|
159
|
+
? isSpilledMessage
|
|
160
|
+
? (readTextIfExists(e.ref) ?? '')
|
|
161
|
+
: fileBodyLoader(e.ref).load()
|
|
162
|
+
: inlinePreview;
|
|
163
|
+
const hasBody = full !== '' && full !== e.label;
|
|
164
|
+
const parts = [meta, header];
|
|
165
|
+
if (e.ref !== undefined)
|
|
166
|
+
parts.push(`ref: ${e.ref}`);
|
|
167
|
+
if (hasBody)
|
|
168
|
+
parts.push(full);
|
|
169
|
+
return parts.join('\n\n');
|
|
170
|
+
}
|
|
171
|
+
function inboxArtifact(row, desc, entry, index) {
|
|
172
|
+
const relpath = `inbox/${String(index).padStart(6, '0')}`;
|
|
173
|
+
const body = inboxEntryBody(entry);
|
|
174
|
+
return {
|
|
175
|
+
ref: `${row.node_id}:${relpath}`,
|
|
176
|
+
nodeId: row.node_id,
|
|
177
|
+
relpath,
|
|
178
|
+
source: 'inbox',
|
|
179
|
+
ts: entry.ts,
|
|
180
|
+
tsMs: toMs(entry.ts),
|
|
181
|
+
title: `[${entry.kind}] from ${entry.from ?? 'system'}: ${entry.label}`,
|
|
182
|
+
nodeName: row.name,
|
|
183
|
+
nodeKind: row.kind,
|
|
184
|
+
nodeStatus: row.status,
|
|
185
|
+
nodeCwd: row.cwd,
|
|
186
|
+
nodeDesc: desc,
|
|
187
|
+
path: null,
|
|
188
|
+
loadBody: () => body,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
129
191
|
function metaArtifact(row, desc) {
|
|
130
192
|
const body = `${row.name}\n${desc}\n${row.kind}`;
|
|
131
193
|
return {
|
|
@@ -189,7 +251,9 @@ export function buildCorpus(scope, corpus) {
|
|
|
189
251
|
return false;
|
|
190
252
|
return true;
|
|
191
253
|
});
|
|
192
|
-
// --report-kind implies type=report.
|
|
254
|
+
// --report-kind implies type=report. `inbox` is opt-in only — never part of
|
|
255
|
+
// the default set — since a node's raw inbox history is unbounded diagnostic
|
|
256
|
+
// detail, not curated past work.
|
|
193
257
|
const wantReportKind = corpus.reportKind;
|
|
194
258
|
const types = corpus.types !== undefined && corpus.types.length > 0
|
|
195
259
|
? corpus.types
|
|
@@ -199,7 +263,7 @@ export function buildCorpus(scope, corpus) {
|
|
|
199
263
|
const want = new Set(types);
|
|
200
264
|
const out = [];
|
|
201
265
|
for (const row of survivors) {
|
|
202
|
-
const desc = want.has('meta') || want.has('report') || want.has('doc') || want.has('roadmap')
|
|
266
|
+
const desc = want.has('meta') || want.has('report') || want.has('doc') || want.has('roadmap') || want.has('inbox')
|
|
203
267
|
? nodeDescOf(row.node_id)
|
|
204
268
|
: '';
|
|
205
269
|
if (want.has('meta'))
|
|
@@ -224,6 +288,9 @@ export function buildCorpus(scope, corpus) {
|
|
|
224
288
|
out.push(a);
|
|
225
289
|
}
|
|
226
290
|
}
|
|
291
|
+
if (want.has('inbox')) {
|
|
292
|
+
allInboxEntries(row.node_id).forEach((entry, i) => out.push(inboxArtifact(row, desc, entry, i)));
|
|
293
|
+
}
|
|
227
294
|
}
|
|
228
295
|
// Time window on artifact ts.
|
|
229
296
|
return out.filter((a) => {
|
|
@@ -274,6 +341,22 @@ export function resolveRef(ref) {
|
|
|
274
341
|
path: null,
|
|
275
342
|
};
|
|
276
343
|
}
|
|
344
|
+
if (relpath.startsWith('inbox/')) {
|
|
345
|
+
const index = Number(relpath.slice('inbox/'.length));
|
|
346
|
+
const entry = allInboxEntries(nodeId)[index];
|
|
347
|
+
if (entry === undefined)
|
|
348
|
+
return null;
|
|
349
|
+
return {
|
|
350
|
+
nodeId,
|
|
351
|
+
relpath,
|
|
352
|
+
source: 'inbox',
|
|
353
|
+
ts: entry.ts,
|
|
354
|
+
nodeName: node.name,
|
|
355
|
+
body: inboxEntryBody(entry),
|
|
356
|
+
raw: null,
|
|
357
|
+
path: null,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
277
360
|
if (relpath.includes('..') || relpath.startsWith('/')) {
|
|
278
361
|
throw new Error(`illegal relpath in ref: ${relpath}`);
|
|
279
362
|
}
|
|
@@ -329,6 +412,7 @@ export function nodeArtifacts(nodeId, types) {
|
|
|
329
412
|
tmux_session: node.tmux_session ?? null,
|
|
330
413
|
pane: node.pane ?? null,
|
|
331
414
|
};
|
|
415
|
+
// `inbox` is opt-in only, same as buildCorpus — must be requested explicitly.
|
|
332
416
|
const want = new Set(types !== undefined && types.length > 0 ? types : ['report', 'doc', 'roadmap']);
|
|
333
417
|
const desc = node.description ?? '';
|
|
334
418
|
const out = [];
|
|
@@ -349,6 +433,9 @@ export function nodeArtifacts(nodeId, types) {
|
|
|
349
433
|
out.push(a);
|
|
350
434
|
}
|
|
351
435
|
}
|
|
436
|
+
if (want.has('inbox')) {
|
|
437
|
+
allInboxEntries(nodeId).forEach((entry, i) => out.push(inboxArtifact(row, desc, entry, i)));
|
|
438
|
+
}
|
|
352
439
|
return out;
|
|
353
440
|
}
|
|
354
441
|
/** Bounded per-cwd aggregate for the branch `-h` `<corpus>` block. Counts files
|
|
@@ -26,6 +26,12 @@ export declare function nodeDir(nodeId: string): string;
|
|
|
26
26
|
export declare function contextDir(nodeId: string): string;
|
|
27
27
|
export declare function jobDir(nodeId: string): string;
|
|
28
28
|
export declare function reportsDir(nodeId: string): string;
|
|
29
|
+
/** Overflow store for inbox entries whose inline body exceeds the digest's
|
|
30
|
+
* bounded preview (a direct `node msg`, a daemon trigger/deadline notice, a
|
|
31
|
+
* close/cancel doctrine wake) — the full body spills here, mirroring how a
|
|
32
|
+
* push's body always lives in a file, never inline; the inbox entry then
|
|
33
|
+
* carries `ref` at this path, same as a push pointer. */
|
|
34
|
+
export declare function messagesDir(nodeId: string): string;
|
|
29
35
|
export declare function nodeMetaPath(nodeId: string): string;
|
|
30
36
|
export declare function inboxPath(nodeId: string): string;
|
|
31
37
|
/** Passive-subscription accumulator. Pushes from publishers this node subscribes
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// context/ roadmap.md, initial-prompt.md, explore-*.md, artifacts
|
|
8
8
|
// job/ log.jsonl, telemetry.json
|
|
9
9
|
// reports/ append-only push history (<ts>-<kind>.md)
|
|
10
|
+
// messages/ overflow bodies for inbox entries too long to inline
|
|
10
11
|
// inbox.jsonl messages + coalesced subscription feed
|
|
11
12
|
// transcript.jsonl mirror/pointer of the pi session
|
|
12
13
|
// session.ptr pi session id/path
|
|
@@ -73,6 +74,14 @@ export function jobDir(nodeId) {
|
|
|
73
74
|
export function reportsDir(nodeId) {
|
|
74
75
|
return join(nodeDir(nodeId), 'reports');
|
|
75
76
|
}
|
|
77
|
+
/** Overflow store for inbox entries whose inline body exceeds the digest's
|
|
78
|
+
* bounded preview (a direct `node msg`, a daemon trigger/deadline notice, a
|
|
79
|
+
* close/cancel doctrine wake) — the full body spills here, mirroring how a
|
|
80
|
+
* push's body always lives in a file, never inline; the inbox entry then
|
|
81
|
+
* carries `ref` at this path, same as a push pointer. */
|
|
82
|
+
export function messagesDir(nodeId) {
|
|
83
|
+
return join(nodeDir(nodeId), 'messages');
|
|
84
|
+
}
|
|
76
85
|
export function nodeMetaPath(nodeId) {
|
|
77
86
|
return join(nodeDir(nodeId), 'meta.json');
|
|
78
87
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type FaultLink = 'pi→provider' | 'viewer↔broker' | 'relay↔broker' | 'browser↔relay' | 'cli→exa';
|
|
2
|
-
export type FaultKind = 'rate-limit' | 'overloaded' | 'connection' | 'auth' | 'protocol' | 'other';
|
|
1
|
+
export type FaultLink = 'pi→provider' | 'viewer↔broker' | 'relay↔broker' | 'browser↔relay' | 'cli→exa' | 'daemon→node';
|
|
2
|
+
export type FaultKind = 'rate-limit' | 'overloaded' | 'connection' | 'auth' | 'protocol' | 'other' | 'wedged';
|
|
3
3
|
export type FaultRetryDisposition = 'auto' | 'manual' | 'fatal';
|
|
4
4
|
export type FaultRetryOwner = 'sdk' | 'daemon' | 'client';
|
|
5
5
|
export interface FaultRetry {
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
export type InboxTier = 'critical' | 'urgent' | 'normal' | 'deferred';
|
|
2
2
|
export type InboxKind = 'update' | 'urgent' | 'final' | 'message' | 'completed';
|
|
3
|
-
/** A single inbox entry
|
|
3
|
+
/** A single inbox entry: a push pointer (`ref` to a report, no inline body),
|
|
4
|
+
* or a direct message/system notice (short bodies inline via `data.body`;
|
|
5
|
+
* bodies too long to inline spill to `messages/` and carry both `ref` and a
|
|
6
|
+
* bounded inline preview). */
|
|
4
7
|
export interface InboxEntry {
|
|
5
|
-
/** Short stable handle for addressing this entry from the CLI (`feed message
|
|
6
|
-
* <id>`). Set when the entry carries an inline body too long to fully inline
|
|
7
|
-
* in the digest, so the receiver can read the full text back by id. */
|
|
8
|
-
id?: string;
|
|
9
8
|
/** ISO 8601 timestamp of delivery. */
|
|
10
9
|
ts: string;
|
|
11
10
|
/** Node id of the sender, or null for system-generated entries. */
|
|
@@ -14,7 +13,9 @@ export interface InboxEntry {
|
|
|
14
13
|
tier: InboxTier;
|
|
15
14
|
/** Semantic kind of the push event. */
|
|
16
15
|
kind: InboxKind;
|
|
17
|
-
/** Absolute path to the report file
|
|
16
|
+
/** Absolute path to the sender-side report file (push pointer) or, for a
|
|
17
|
+
* direct message/system notice too long to inline, the spilled body file
|
|
18
|
+
* under `messages/`. */
|
|
18
19
|
ref?: string;
|
|
19
20
|
/** First ~80 chars of the body's first line — enough to decide if it matters. */
|
|
20
21
|
label: string;
|
|
@@ -26,8 +27,6 @@ export interface InboxEntry {
|
|
|
26
27
|
* Fills `ts` (current ISO time). Returns the completed entry.
|
|
27
28
|
*/
|
|
28
29
|
export declare function appendInbox(nodeId: string, entry: Omit<InboxEntry, 'ts'>): InboxEntry;
|
|
29
|
-
/** Find one inbox entry by its short `id` handle (see `feed message <id>`). */
|
|
30
|
-
export declare function readInboxEntryById(nodeId: string, id: string): InboxEntry | undefined;
|
|
31
30
|
/**
|
|
32
31
|
* Return all inbox entries strictly after `cursorIso`.
|
|
33
32
|
* When `cursorIso` is undefined, returns every entry in the file.
|
|
@@ -42,10 +41,6 @@ export declare function readCursor(nodeId: string): string | undefined;
|
|
|
42
41
|
* Persist a new cursor ISO for a node's inbox (atomic tmp+rename).
|
|
43
42
|
*/
|
|
44
43
|
export declare function writeCursor(nodeId: string, iso: string): void;
|
|
45
|
-
/** True when an entry's inline body is long enough that the digest will clip it
|
|
46
|
-
* — the trigger for minting an addressable `id` so the full text stays
|
|
47
|
-
* recoverable via `feed message <id>`. */
|
|
48
|
-
export declare function bodyExceedsPreview(e: Pick<InboxEntry, 'data' | 'label'>): boolean;
|
|
49
44
|
/** Clip a body to a bounded preview, reporting whether anything was dropped. */
|
|
50
45
|
export declare function clipBody(body: string): {
|
|
51
46
|
text: string;
|
package/dist/core/feed/inbox.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// Per-node inbox.jsonl primitive for the pi-native canvas runtime.
|
|
2
2
|
//
|
|
3
|
-
// An inbox entry is a lightweight
|
|
4
|
-
//
|
|
5
|
-
// enough to
|
|
3
|
+
// An inbox entry is a lightweight pointer for a push (~30 tokens, body lives
|
|
4
|
+
// in nodes/<id>/reports/) or carries a direct message inline. A direct
|
|
5
|
+
// message short enough to inline stays entirely in the jsonl line; one that
|
|
6
|
+
// would clip spills its full body to nodes/<id>/messages/ and the entry
|
|
7
|
+
// carries `ref` to it alongside a bounded inline preview.
|
|
6
8
|
//
|
|
7
9
|
// Layout:
|
|
8
10
|
// nodes/<id>/inbox.jsonl — one JSON line per entry, append-only
|
|
9
11
|
// nodes/<id>/inbox.jsonl.cursor — ISO 8601 of last-read entry (sidecar)
|
|
10
12
|
import { appendFileSync, existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, } from 'node:fs';
|
|
11
|
-
import { dirname } from 'node:path';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
12
14
|
import { randomUUID } from 'node:crypto';
|
|
13
|
-
import { inboxPath } from '../canvas/index.js';
|
|
15
|
+
import { inboxPath, messagesDir } from '../canvas/index.js';
|
|
14
16
|
// ---------------------------------------------------------------------------
|
|
15
17
|
// Cursor sidecar path
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
@@ -20,16 +22,42 @@ function cursorPath(nodeId) {
|
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Append
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
25
|
+
/** Spill an oversized inline body to a durable file — the SAME atomic
|
|
26
|
+
* tmp+rename pattern a push report uses — and return its absolute path.
|
|
27
|
+
* Called only when the body would clip in the digest; short bodies never
|
|
28
|
+
* touch disk beyond the jsonl line itself. */
|
|
29
|
+
function writeMessageBody(nodeId, ts, body) {
|
|
30
|
+
const dir = messagesDir(nodeId);
|
|
31
|
+
if (!existsSync(dir))
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
const stamp = ts.replace(/[^0-9]/g, '');
|
|
34
|
+
const finalPath = join(dir, `${stamp}-${randomUUID().slice(0, 6)}.md`);
|
|
35
|
+
const tmpPath = `${finalPath}.tmp`;
|
|
36
|
+
writeFileSync(tmpPath, body, 'utf8');
|
|
37
|
+
renameSync(tmpPath, finalPath);
|
|
38
|
+
return finalPath;
|
|
39
|
+
}
|
|
23
40
|
/**
|
|
24
41
|
* Atomically append one inbox entry to `nodes/<nodeId>/inbox.jsonl`.
|
|
25
42
|
* Fills `ts` (current ISO time). Returns the completed entry.
|
|
26
43
|
*/
|
|
27
44
|
export function appendInbox(nodeId, entry) {
|
|
28
45
|
const full = { ts: new Date().toISOString(), ...entry };
|
|
29
|
-
// A
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
// A push pointer already carries `ref` (the report file) — its body lives
|
|
47
|
+
// there, never inline. A direct message / system notice has no ref yet: when
|
|
48
|
+
// its inline body would clip in the digest, spill the FULL text to a durable
|
|
49
|
+
// file (same atomic tmp+rename as a report) and point `ref` at it, exactly
|
|
50
|
+
// like a push, then keep only the bounded preview inline. A short body is
|
|
51
|
+
// left fully inline, untouched — uniform with how a push's body always
|
|
52
|
+
// lives in a file, never inline, dereferenced by reading `ref`.
|
|
53
|
+
if (full.ref === undefined) {
|
|
54
|
+
const body = inlineBody(full);
|
|
55
|
+
const { text, clipped } = clipBody(body);
|
|
56
|
+
if (clipped) {
|
|
57
|
+
full.ref = writeMessageBody(nodeId, full.ts, body);
|
|
58
|
+
full.data = { ...full.data, body: text };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
33
61
|
const line = JSON.stringify(full) + '\n';
|
|
34
62
|
// Ensure the parent directory exists (inbox.jsonl lives directly under the
|
|
35
63
|
// node dir, which ensureNodeDirs creates — but guard anyway for callers that
|
|
@@ -46,10 +74,6 @@ export function appendInbox(nodeId, entry) {
|
|
|
46
74
|
// ---------------------------------------------------------------------------
|
|
47
75
|
// Read
|
|
48
76
|
// ---------------------------------------------------------------------------
|
|
49
|
-
/** Find one inbox entry by its short `id` handle (see `feed message <id>`). */
|
|
50
|
-
export function readInboxEntryById(nodeId, id) {
|
|
51
|
-
return readInboxSince(nodeId, undefined).find((e) => e.id === id);
|
|
52
|
-
}
|
|
53
77
|
/**
|
|
54
78
|
* Return all inbox entries strictly after `cursorIso`.
|
|
55
79
|
* When `cursorIso` is undefined, returns every entry in the file.
|
|
@@ -108,13 +132,6 @@ function inlineBody(e) {
|
|
|
108
132
|
const body = typeof e.data?.['body'] === 'string' ? e.data['body'].trim() : '';
|
|
109
133
|
return body === '' || body === e.label ? '' : body;
|
|
110
134
|
}
|
|
111
|
-
/** True when an entry's inline body is long enough that the digest will clip it
|
|
112
|
-
* — the trigger for minting an addressable `id` so the full text stays
|
|
113
|
-
* recoverable via `feed message <id>`. */
|
|
114
|
-
export function bodyExceedsPreview(e) {
|
|
115
|
-
const body = inlineBody(e);
|
|
116
|
-
return body !== '' && clipBody(body).clipped;
|
|
117
|
-
}
|
|
118
135
|
/** Clip a body to a bounded preview, reporting whether anything was dropped. */
|
|
119
136
|
export function clipBody(body) {
|
|
120
137
|
let text = body;
|
|
@@ -135,10 +152,11 @@ export function clipBody(body) {
|
|
|
135
152
|
*
|
|
136
153
|
* A push pointer (has a `ref`) stays a pointer — the body lives in the report
|
|
137
154
|
* file, dereferenced on demand by reading that path. A direct `node msg` or
|
|
138
|
-
* system alert
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
155
|
+
* system alert without a `ref` inlines its full body (already bounded — a
|
|
156
|
+
* clipping body is spilled to a file and `ref`'d at append time, see
|
|
157
|
+
* appendInbox). When an entry DOES carry both an inline preview and a `ref`
|
|
158
|
+
* (the clipped case), the tail points at the ref path, same shape as a push —
|
|
159
|
+
* read the file for the rest, no special dereference verb.
|
|
142
160
|
*/
|
|
143
161
|
function renderEntry(e) {
|
|
144
162
|
const body = inlineBody(e);
|
|
@@ -149,11 +167,9 @@ function renderEntry(e) {
|
|
|
149
167
|
}
|
|
150
168
|
const { text, clipped } = clipBody(body);
|
|
151
169
|
const indented = text.split('\n').map((l) => ` ${l}`).join('\n');
|
|
152
|
-
const more =
|
|
153
|
-
? (e.
|
|
154
|
-
|
|
155
|
-
: '\n … (body clipped)')
|
|
156
|
-
: '';
|
|
170
|
+
const more = e.ref !== undefined
|
|
171
|
+
? `\n … (full body: ${e.ref})`
|
|
172
|
+
: clipped ? '\n … (body clipped)' : '';
|
|
157
173
|
return ` [${e.kind}]\n${indented}${more}`;
|
|
158
174
|
}
|
|
159
175
|
/**
|
|
@@ -129,7 +129,7 @@ export function buildBootstrapScript(opts) {
|
|
|
129
129
|
}
|
|
130
130
|
appendOauthAdapterCachePrime(lines, opts.guestHome);
|
|
131
131
|
// Tmpfs sock/pidfile dirs are already mkdir'd above, before this line.
|
|
132
|
-
lines.push(`${shellQuote(BAKED_CRTR_PATH)}
|
|
132
|
+
lines.push(`${shellQuote(BAKED_CRTR_PATH)} sys daemon start </dev/null >>"$HOME/.crtr-runtime/daemon.log" 2>&1`);
|
|
133
133
|
if (opts.isFirstProvision) {
|
|
134
134
|
lines.push('home_node_output="$(', `${shellQuote(BAKED_CRTR_PATH)} --json node new --root --kind general --name home <<'SEED'`, 'Hearth home seed for the Blaxel home backend.', 'SEED', ')"', "home_node_id=\"$(node -e 'const o = JSON.parse(process.argv[1]); process.stdout.write(String(o.node_id ?? o.nodeId ?? o.id ?? \"\"));' \"$home_node_output\")\"", 'if [ -z "$home_node_id" ]; then', ' printf "failed to parse home node id from crtr node new output\n%s\n" "$home_node_output" >&2', ' exit 1', 'fi', 'printf "__HEARTH_NODE_ID__=%s\n" "$home_node_id"',
|
|
135
135
|
// The provisioner polls this file (background exec stdout is not captured).
|