@north-light/crouter 0.3.223 → 0.3.225

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ // Regression: the preview mirror is bounded. A `crtr integration run` in a
2
+ // canvas bash call once mirrored a 3 MB CRM dump into ToolResult.details via
3
+ // this channel — bypassing pi's 50 KB stdout truncation entirely — and five
4
+ // such calls made a 16 MB transcript that Northlight could no longer open.
5
+ // The writer is the one choke point, so the writer is what this pins.
6
+ import { strict as assert } from 'node:assert';
7
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { after, test } from 'node:test';
11
+ import { beginPreview, publishPreviewResult, PREVIEW_RESULT_MAX_BYTES } from '../io.js';
12
+ import { PREVIEW_RESULT_PATH_ENV } from '../preview-result-path.js';
13
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-preview-cap-'));
14
+ const priorEnv = process.env[PREVIEW_RESULT_PATH_ENV];
15
+ after(() => {
16
+ rmSync(dir, { recursive: true, force: true });
17
+ if (priorEnv === undefined)
18
+ delete process.env[PREVIEW_RESULT_PATH_ENV];
19
+ else
20
+ process.env[PREVIEW_RESULT_PATH_ENV] = priorEnv;
21
+ });
22
+ test('a record over PREVIEW_RESULT_MAX_BYTES is dropped, not mirrored', () => {
23
+ const resultPath = join(dir, 'oversized.json');
24
+ process.env[PREVIEW_RESULT_PATH_ENV] = resultPath;
25
+ beginPreview('integration run');
26
+ publishPreviewResult({ ok: true, output: 'x'.repeat(PREVIEW_RESULT_MAX_BYTES) });
27
+ assert.equal(existsSync(resultPath), false);
28
+ });
29
+ test('a bounded record still mirrors verbatim', () => {
30
+ const resultPath = join(dir, 'bounded.json');
31
+ process.env[PREVIEW_RESULT_PATH_ENV] = resultPath;
32
+ beginPreview('integration run');
33
+ publishPreviewResult({ ok: true, output: 'small' });
34
+ const record = JSON.parse(readFileSync(resultPath, 'utf8'));
35
+ assert.equal(record.path, 'integration run');
36
+ assert.equal(record.result.output, 'small');
37
+ });
@@ -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(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), JSON.stringify({ viewers: 1 }));
254
+ writeFileSync(attachPath, JSON.stringify({ viewers: 1 }));
249
255
  try {
250
256
  const seenAt = 15_000;
251
257
  await h.tick(seenAt);
package/dist/core/io.d.ts CHANGED
@@ -10,6 +10,15 @@ export interface CrtrPreviewRecord {
10
10
  jsonl?: Record<string, unknown>[];
11
11
  error?: ErrorPayload;
12
12
  }
13
+ /** A preview is bounded UI metadata, never a bulk-data channel. One
14
+ * `crtr integration run` once mirrored a 3 MB CRM dump into every copy of the
15
+ * transcript that contains its tool result — the session file, every replay
16
+ * frame, and the host product's stored conversation — because pi's 50 KB
17
+ * stdout truncation never sees this channel. The mirror write is the one
18
+ * choke point every record passes, so the bound lives here: an oversized
19
+ * record is dropped, and the viewer falls back to rendering the agent-facing
20
+ * stdout text, which pi bounds itself. */
21
+ export declare const PREVIEW_RESULT_MAX_BYTES: number;
13
22
  /** Begin collecting the current leaf's structured response, if its caller
14
23
  * supplied the private per-tool-call result path. */
15
24
  export declare function beginPreview(path: string): void;
package/dist/core/io.js CHANGED
@@ -14,6 +14,15 @@ import { ApiError } from '../api/index.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  let jsonOutput = false;
16
16
  let preview;
17
+ /** A preview is bounded UI metadata, never a bulk-data channel. One
18
+ * `crtr integration run` once mirrored a 3 MB CRM dump into every copy of the
19
+ * transcript that contains its tool result — the session file, every replay
20
+ * frame, and the host product's stored conversation — because pi's 50 KB
21
+ * stdout truncation never sees this channel. The mirror write is the one
22
+ * choke point every record passes, so the bound lives here: an oversized
23
+ * record is dropped, and the viewer falls back to rendering the agent-facing
24
+ * stdout text, which pi bounds itself. */
25
+ export const PREVIEW_RESULT_MAX_BYTES = 256 * 1024;
17
26
  /** Begin collecting the current leaf's structured response, if its caller
18
27
  * supplied the private per-tool-call result path. */
19
28
  export function beginPreview(path) {
@@ -24,9 +33,12 @@ function publishPreview(record) {
24
33
  const resultPath = process.env[PREVIEW_RESULT_PATH_ENV];
25
34
  if (resultPath === undefined || resultPath === '')
26
35
  return;
36
+ const line = `${JSON.stringify({ path: preview?.path ?? '', ...record })}\n`;
37
+ if (Buffer.byteLength(line, 'utf8') > PREVIEW_RESULT_MAX_BYTES)
38
+ return;
27
39
  const tempPath = `${resultPath}.${process.pid}.tmp`;
28
40
  try {
29
- writeFileSync(tempPath, `${JSON.stringify({ path: preview?.path ?? '', ...record })}\n`, 'utf8');
41
+ writeFileSync(tempPath, line, 'utf8');
30
42
  renameSync(tempPath, resultPath);
31
43
  }
32
44
  catch {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.223",
3
+ "version": "0.3.225",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.223",
3
+ "version": "0.3.225",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.223",
9
+ "version": "0.3.225",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {