@north-light/api 0.3.447 → 0.3.449

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.
Files changed (35) hide show
  1. package/dist/api/contract.d.ts +1 -1
  2. package/dist/api/contract.js +1 -1
  3. package/dist/api/dto/nodes.d.ts +5 -0
  4. package/dist/api/dto/objects.d.ts +33 -2
  5. package/dist/api/error-codes.d.ts +15 -0
  6. package/dist/api/error-codes.js +6 -0
  7. package/dist/core/scoped-state/settings.js +26 -8
  8. package/dist/shared/generated-context.d.ts +8 -0
  9. package/dist/shared/generated-context.js +6 -1
  10. package/dist/shared/inbox-entry-body.js +1 -1
  11. package/package.json +1 -1
  12. package/dist/api/__tests__/serial/client.test.d.ts +0 -1
  13. package/dist/api/__tests__/serial/client.test.js +0 -141
  14. package/dist/api/dto/memory.d.ts +0 -171
  15. package/dist/api/dto/memory.js +0 -7
  16. package/dist/core/canvas/types.d.ts +0 -446
  17. package/dist/core/canvas/types.js +0 -6
  18. package/dist/core/fault-classifier.d.ts +0 -34
  19. package/dist/core/fault-classifier.js +0 -96
  20. package/dist/core/provider-error-recognition.d.ts +0 -27
  21. package/dist/core/provider-error-recognition.js +0 -74
  22. package/dist/core/provider-failure.d.ts +0 -27
  23. package/dist/core/provider-failure.js +0 -109
  24. package/dist/core/runtime/broker-protocol.d.ts +0 -721
  25. package/dist/core/runtime/broker-protocol.js +0 -152
  26. package/dist/core/runtime/managed-provider-cooling.d.ts +0 -24
  27. package/dist/core/runtime/managed-provider-cooling.js +0 -62
  28. package/dist/core/runtime/stamp/protocol.d.ts +0 -90
  29. package/dist/core/runtime/stamp/protocol.js +0 -113
  30. package/dist/core/scopes.d.ts +0 -17
  31. package/dist/core/scopes.js +0 -42
  32. package/dist/core/substrate/subject-fields.d.ts +0 -48
  33. package/dist/core/substrate/subject-fields.js +0 -39
  34. package/dist/shared/tool-groups.d.ts +0 -26
  35. package/dist/shared/tool-groups.js +0 -64
@@ -1,5 +1,5 @@
1
1
  /** The contract level this build serves and requires. Written by `scripts/contract.mjs release`; never edit by hand. */
2
- export declare const CONTRACT_LEVEL = "1.1";
2
+ export declare const CONTRACT_LEVEL = "1.2";
3
3
  /** Request header naming the one contract level the caller needs, e.g. `1.3`. Optional: absent means no check. */
4
4
  export declare const REQUIRED_CONTRACT_HEADER = "Crouter-Required-Contract";
5
5
  /** Response header listing the contract levels the server serves, comma-separated, one per major, e.g. `1.3`. */
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // PURITY (spec §3.1): Node built-ins + `src/api/*` only.
14
14
  /** The contract level this build serves and requires. Written by `scripts/contract.mjs release`; never edit by hand. */
15
- export const CONTRACT_LEVEL = '1.1';
15
+ export const CONTRACT_LEVEL = '1.2';
16
16
  /** Request header naming the one contract level the caller needs, e.g. `1.3`. Optional: absent means no check. */
17
17
  export const REQUIRED_CONTRACT_HEADER = 'Crouter-Required-Contract';
18
18
  /** Response header listing the contract levels the server serves, comma-separated, one per major, e.g. `1.3`. */
@@ -210,6 +210,11 @@ export interface NodeFaultDTO {
210
210
  /** The full node view — summary ∪ identity extras ∪ edges ∪ paths. Returned by
211
211
  * `GET /v1/nodes/{id}` and by the create/lifecycle actions that yield a node. */
212
212
  export interface NodeDetailDTO extends Omit<NodeSummaryDTO, 'outcome'> {
213
+ /** Set when another of your machines homes this node and answered: `{id, name}` of that machine. */
214
+ machine?: {
215
+ id: string;
216
+ name: string;
217
+ };
213
218
  outcome: NodeOutcomeDTO | null;
214
219
  /** Node that created this node, or null when an external process did. */
215
220
  creator: NodeIdDTO | null;
@@ -74,6 +74,8 @@ export interface OwnerReadData {
74
74
  * structured fields (for the built-in kinds, the `*ReadData` shapes above). */
75
75
  export interface ObjectLiveReadDTO {
76
76
  state: 'object';
77
+ /** Set when another of your machines homes the object and answered: `{id, name}` of that machine. */
78
+ machine?: MachineRefDTO;
77
79
  type: string;
78
80
  object: ObjectDTO;
79
81
  text: string;
@@ -83,15 +85,28 @@ export interface ObjectLiveReadDTO {
83
85
  export interface ObjectDeletedDTO {
84
86
  state: 'deleted';
85
87
  deleted: ObjectDTO;
88
+ machine?: MachineRefDTO;
89
+ }
90
+ /** A machine of your account, as an answer names it. */
91
+ export interface MachineRefDTO {
92
+ id: string;
93
+ name: string;
94
+ }
95
+ /** No machine answered for `ref`, and the ones that could not answer are listed: the object may be on one of them. Never a "missing". */
96
+ export interface ObjectUnknownDTO {
97
+ state: 'unknown';
98
+ ref: string;
99
+ unreachable: string[];
86
100
  }
87
101
  /** The name's first segment is no handle on this runtime; the object may live on another runtime. */
88
102
  export interface ObjectRemoteDTO {
89
103
  state: 'remote';
90
104
  remote: string;
91
105
  }
92
- export type ObjectReadDTO = ObjectLiveReadDTO | ObjectDeletedDTO | ObjectRemoteDTO;
106
+ export type ObjectReadDTO = ObjectLiveReadDTO | ObjectDeletedDTO | ObjectRemoteDTO | ObjectUnknownDTO;
93
107
  export interface ObjectReadQuery {
94
- fields?: boolean;
108
+ fields?: boolean; /** Read on this machine (`runtime:<id>`), as a merged listing or inbox entry names it. */
109
+ machine?: string;
95
110
  }
96
111
  export interface ObjectListRequest {
97
112
  /** A kind name. */
@@ -105,15 +120,29 @@ export interface ObjectListRequest {
105
120
  deleted?: boolean;
106
121
  limit?: number;
107
122
  cursor?: string;
123
+ /** List only this machine (`runtime:<id>`), paging through its cursor. `self` lists only this machine without labels. */
124
+ machine?: string;
108
125
  }
109
126
  export interface ObjectListItemDTO extends ObjectDTO {
127
+ /** The machine of your account that homes the row; set on a merged or `--machine` listing. */
128
+ machine?: MachineRefDTO;
110
129
  summary?: string | null;
111
130
  /** A document's rule count. */
112
131
  rules?: number;
113
132
  }
133
+ export type MachineListState = 'ok' | 'unreachable' | 'too_old';
134
+ /** How one machine answered a merged listing; `more` says its first page was not all of it (page it with `--machine`). */
135
+ export interface MachineListEntryDTO {
136
+ machine_id: string;
137
+ name: string;
138
+ state: MachineListState;
139
+ more: boolean;
140
+ }
141
+ /** `machines` is present when this runtime belongs to an account; `next_cursor` is always this machine's (or, with `machine`, that machine's). */
114
142
  export interface ObjectListDTO {
115
143
  items: ObjectListItemDTO[];
116
144
  next_cursor: string | null;
145
+ machines?: MachineListEntryDTO[];
117
146
  }
118
147
  export interface ObjectSearchRequest {
119
148
  text: string;
@@ -151,10 +180,12 @@ export interface UnwatchQuery {
151
180
  export interface WatchResultDTO {
152
181
  object: ObjectDTO;
153
182
  patterns: string[];
183
+ machine?: MachineRefDTO;
154
184
  }
155
185
  export interface UnwatchResultDTO {
156
186
  object: ObjectDTO;
157
187
  removed: number;
188
+ machine?: MachineRefDTO;
158
189
  }
159
190
  export type EdgeTypeName = 'acted-on' | 'links-to' | 'watches';
160
191
  export interface EdgesQuery {
@@ -89,6 +89,11 @@ export declare const errorCodes: {
89
89
  readonly status: 403;
90
90
  readonly retryable: false;
91
91
  };
92
+ readonly app_not_granted: {
93
+ readonly type: "permission_error";
94
+ readonly status: 403;
95
+ readonly retryable: false;
96
+ };
92
97
  readonly isolated_run: {
93
98
  readonly type: "permission_error";
94
99
  readonly status: 403;
@@ -114,6 +119,16 @@ export declare const errorCodes: {
114
119
  readonly status: 416;
115
120
  readonly retryable: false;
116
121
  };
122
+ readonly not_here: {
123
+ readonly type: "invalid_request_error";
124
+ readonly status: 404;
125
+ readonly retryable: false;
126
+ };
127
+ readonly peer_version_too_old: {
128
+ readonly type: "invalid_request_error";
129
+ readonly status: 426;
130
+ readonly retryable: false;
131
+ };
117
132
  readonly conflict: {
118
133
  readonly type: "invalid_request_error";
119
134
  readonly status: 409;
@@ -25,11 +25,17 @@ export const errorCodes = {
25
25
  question_pages_off: { type: 'permission_error', status: 403, retryable: false },
26
26
  // An unsandboxed machine (a computer joined to a Northlight account) runs only the person's own terminal agents; app grantees run on the hosted machine.
27
27
  unsandboxed_machine: { type: 'permission_error', status: 403, retryable: false },
28
+ // A forwarded app actor (an app node on another of the person's machines) whose app holds no active mirrored grant on this machine; refused before any row dispatch.
29
+ app_not_granted: { type: 'permission_error', status: 403, retryable: false },
28
30
  isolated_run: { type: 'permission_error', status: 403, retryable: false },
29
31
  provider_version_retired: { type: 'permission_error', status: 403, retryable: false },
30
32
  invalid_scope: { type: 'invalid_request_error', status: 400, retryable: false },
31
33
  invalid_request: { type: 'invalid_request_error', status: 400, retryable: false },
32
34
  range_not_satisfiable: { type: 'invalid_request_error', status: 416, retryable: false },
35
+ // A forwarded call landed on a machine that holds only a stub of the object; the caller asks the other machines again. Details `{ id, home_machine }`.
36
+ not_here: { type: 'invalid_request_error', status: 404, retryable: false },
37
+ // A peer machine runs a crtr release below the peer-protocol floor; details `{ peer_version, floor, receiver_version }`. Update it with `crtr sys update`.
38
+ peer_version_too_old: { type: 'invalid_request_error', status: 426, retryable: false },
33
39
  conflict: { type: 'invalid_request_error', status: 409, retryable: false },
34
40
  not_found: { type: 'invalid_request_error', status: 404, retryable: false },
35
41
  model_unavailable: { type: 'invalid_request_error', status: 404, retryable: false },
@@ -1,13 +1,31 @@
1
1
  import { prepared as cachedStatement } from '../sqlite-statements.js';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { openScopedStore, withScopedWrite } from './db.js';
4
- function ensureOwner(db, owner) {
4
+ /**
5
+ * The owner row ties a store to the thing it belongs to. For user and profile
6
+ * owners the recorded root is the guard: those rows share one store, and a row
7
+ * whose root differs belongs to something else. A project store is different —
8
+ * it lives inside the root that opens it (`<root>/operations.db`), so the
9
+ * location is the identity and the recorded root is only a note of where the
10
+ * directory last was. When the directory is moved or renamed the note goes
11
+ * stale; the owner is still the same project, so a write rebinds the note and a
12
+ * read accepts it. Kind and profile still have to match, so a user or profile
13
+ * store is never accepted as a project owner.
14
+ */
15
+ function ensureOwner(db, owner, access) {
5
16
  const row = cachedStatement(db, 'SELECT kind, root, profile_id FROM scope_owners WHERE owner_key = ?').get(owner.key);
6
17
  if (!row)
7
18
  throw new Error(`scoped SQL owner is missing: ${owner.key}`);
8
- if (row.kind !== owner.kind || row.root !== owner.root || (owner.profileId ?? null) !== row.profile_id) {
19
+ if (row.kind !== owner.kind || (owner.profileId ?? null) !== row.profile_id) {
9
20
  throw new Error(`scoped SQL owner does not match its root: ${owner.key}`);
10
21
  }
22
+ if (row.root === owner.root)
23
+ return;
24
+ if (owner.kind !== 'project' || owner.store.kind !== 'project' || owner.store.root !== owner.root) {
25
+ throw new Error(`scoped SQL owner does not match its root: ${owner.key}`);
26
+ }
27
+ if (access === 'write')
28
+ cachedStatement(db, 'UPDATE scope_owners SET root = ? WHERE owner_key = ?').run(owner.root, owner.key);
11
29
  }
12
30
  function revisionFor(row) {
13
31
  return row === undefined ? 'absent' : createHash('sha256').update(row.raw_json).digest('hex');
@@ -23,7 +41,7 @@ export class SettingsRevisionConflictError extends Error {
23
41
  }
24
42
  export function readRawSettingsWithRevision(owner) {
25
43
  const db = openScopedStore(owner.store);
26
- ensureOwner(db, owner);
44
+ ensureOwner(db, owner, 'read');
27
45
  const row = readRawSettingsRow(db, owner);
28
46
  return { revision: revisionFor(row), settings: row === undefined ? null : JSON.parse(row.raw_json) };
29
47
  }
@@ -32,7 +50,7 @@ export function readRawSettings(owner) {
32
50
  }
33
51
  export function replaceRawSettings(owner, next, ifRevision, validate) {
34
52
  return withScopedWrite(owner.store, (db) => {
35
- ensureOwner(db, owner);
53
+ ensureOwner(db, owner, 'write');
36
54
  const current = readRawSettingsRow(db, owner);
37
55
  if (ifRevision !== revisionFor(current))
38
56
  throw new SettingsRevisionConflictError();
@@ -46,13 +64,13 @@ export function replaceRawSettings(owner, next, ifRevision, validate) {
46
64
  }
47
65
  export function writeRawSettings(owner, raw) {
48
66
  withScopedWrite(owner.store, (db) => {
49
- ensureOwner(db, owner);
67
+ ensureOwner(db, owner, 'write');
50
68
  cachedStatement(db, `INSERT INTO scope_settings(owner_key, raw_json) VALUES (?, ?) ON CONFLICT(owner_key) DO UPDATE SET raw_json=excluded.raw_json`).run(owner.key, JSON.stringify(raw));
51
69
  });
52
70
  }
53
71
  export function mutateRawSettings(owner, mutate) {
54
72
  return withScopedWrite(owner.store, (db) => {
55
- ensureOwner(db, owner);
73
+ ensureOwner(db, owner, 'write');
56
74
  const row = cachedStatement(db, 'SELECT raw_json FROM scope_settings WHERE owner_key = ?').get(owner.key);
57
75
  const result = mutate(row ? JSON.parse(row.raw_json) : {});
58
76
  cachedStatement(db, `INSERT INTO scope_settings(owner_key, raw_json) VALUES (?, ?) ON CONFLICT(owner_key) DO UPDATE SET raw_json=excluded.raw_json`).run(owner.key, JSON.stringify(result.next));
@@ -61,7 +79,7 @@ export function mutateRawSettings(owner, mutate) {
61
79
  }
62
80
  export function readBookkeeping(owner) {
63
81
  const db = openScopedStore(owner.store);
64
- ensureOwner(db, owner);
82
+ ensureOwner(db, owner, 'read');
65
83
  const bookkeeping = cachedStatement(db, 'SELECT last_self_check, active_canvas FROM scope_bookkeeping WHERE owner_key = ?').get(owner.key);
66
84
  const marketplaces = Object.fromEntries(cachedStatement(db, 'SELECT name, last_updated FROM scope_marketplaces WHERE owner_key = ?').all(owner.key).map((row) => [row.name, row.last_updated === null ? {} : { last_updated: row.last_updated }]));
67
85
  const plugins = Object.fromEntries(cachedStatement(db, 'SELECT name, last_updated, etag, checked_at FROM scope_plugins WHERE owner_key = ?').all(owner.key).map((row) => [row.name, { ...(row.last_updated === null ? {} : { last_updated: row.last_updated }), ...(row.etag === null ? {} : { etag: row.etag }), ...(row.checked_at === null ? {} : { checked_at: row.checked_at }) }]));
@@ -69,7 +87,7 @@ export function readBookkeeping(owner) {
69
87
  }
70
88
  export function writeBookkeeping(owner, state) {
71
89
  withScopedWrite(owner.store, (db) => {
72
- ensureOwner(db, owner);
90
+ ensureOwner(db, owner, 'write');
73
91
  cachedStatement(db, `INSERT INTO scope_bookkeeping(owner_key, last_self_check, active_canvas) VALUES (?, ?, ?) ON CONFLICT(owner_key) DO UPDATE SET last_self_check=excluded.last_self_check, active_canvas=excluded.active_canvas`).run(owner.key, state.last_self_check ?? null, state.activeCanvas ?? null);
74
92
  cachedStatement(db, 'DELETE FROM scope_marketplaces WHERE owner_key = ?').run(owner.key);
75
93
  cachedStatement(db, 'DELETE FROM scope_plugins WHERE owner_key = ?').run(owner.key);
@@ -53,6 +53,8 @@ export interface InboxCardEntry {
53
53
  /** An already-resolved sender section supplied to the pure digest formatter. */
54
54
  export interface InboxCardSection {
55
55
  id: string;
56
+ /** The machine of the account the sender lives on, when it is not this one. */
57
+ machine?: string;
56
58
  entries: readonly InboxCardEntry[];
57
59
  }
58
60
  export interface GeneratedCard {
@@ -80,4 +82,10 @@ export declare function isHumanWordsEntry(entry: CardEntry): boolean;
80
82
  /** Explain how an inbox entry's ref is read without guessing its filesystem location. */
81
83
  export declare function formatInboxRefInstruction(ref: string): string;
82
84
  /** Format a complete inbox card from report bodies resolved by the caller. */
85
+ /** The machine a section's sender lives on, when its entries say it is another one (`data.from_machine`, set by the home side of a forwarded send). */
86
+ export declare function machineOfEntries(items: readonly {
87
+ data?: Record<string, unknown>;
88
+ }[]): {
89
+ machine?: string;
90
+ };
83
91
  export declare function formatInboxCard(sections: readonly InboxCardSection[]): string;
@@ -221,6 +221,11 @@ export function formatInboxRefInstruction(ref) {
221
221
  : `Read this report with \`crtr canvas read ${ref}\`.`;
222
222
  }
223
223
  /** Format a complete inbox card from report bodies resolved by the caller. */
224
+ /** The machine a section's sender lives on, when its entries say it is another one (`data.from_machine`, set by the home side of a forwarded send). */
225
+ export function machineOfEntries(items) {
226
+ const machine = items.map((i) => i.data?.['from_machine']).find((m) => typeof m === 'string');
227
+ return machine === undefined ? {} : { machine };
228
+ }
224
229
  export function formatInboxCard(sections) {
225
230
  const body = sections.map((section) => {
226
231
  const entries = section.entries.map((entry) => {
@@ -229,7 +234,7 @@ export function formatInboxCard(sections) {
229
234
  : undefined;
230
235
  return `<entry${formatAttributes({ kind: entry.kind, ref: entry.ref, disposition })}>${escapeXmlText(entry.body)}</entry>`;
231
236
  }).join('\n');
232
- return `<from${formatAttributes({ id: section.id })}>${entries}</from>`;
237
+ return `<from${formatAttributes({ id: section.id, machine: section.machine })}>${entries}</from>`;
233
238
  }).join('\n');
234
239
  return formatCard('inbox', {}, body);
235
240
  }
@@ -42,7 +42,7 @@ function objectText(o) {
42
42
  if (o.type === 'job')
43
43
  return `job ${o.id} (\`${o.name}\`)`;
44
44
  if (o.type === 'node')
45
- return `node ${o.name} (${o.id})`;
45
+ return `node ${o.name} (${o.id})${o.machine ? ` on ${o.machine}` : ''}`;
46
46
  return `${o.type} ${o.name}`;
47
47
  }
48
48
  function actorText(entry, push) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.447",
3
+ "version": "0.3.449",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",
@@ -1 +0,0 @@
1
- export {};
@@ -1,141 +0,0 @@
1
- // client.test.ts — regression for issue #516: a cold-start `/healthz` timeout
2
- // used to throw a bare `daemon_unavailable` message that discarded the actual
3
- // startup failure (the tail of crtrd.err). `coldStartTimeoutMessage` is the
4
- // pure composition `handleColdSocket` uses on timeout; this proves an injected
5
- // diagnostic is appended (not dropped) and that a missing/empty diagnostic
6
- // still falls back to the original bare message unchanged. `safeColdStartDiagnostic`
7
- // is the guard `handleColdSocket` applies before that composition; this proves a
8
- // THROWING hook is treated as absent (per the documented `CrtrClientOptions`
9
- // contract) instead of propagating and replacing the typed `daemon_unavailable`
10
- // error.
11
- import { test } from 'node:test';
12
- import assert from 'node:assert/strict';
13
- import { createServer } from 'node:http';
14
- import { mkdtempSync, rmSync } from 'node:fs';
15
- import { tmpdir } from 'node:os';
16
- import { join } from 'node:path';
17
- import { coldStartTimeoutMessage, CrtrClient, safeColdStartDiagnostic } from '../../client.js';
18
- import { ApiError } from '../../errors.js';
19
- test('coldStartTimeoutMessage appends a present diagnostic to the base message', () => {
20
- const msg = coldStartTimeoutMessage('crtrd.err (tail):\nError: bind EADDRINUSE');
21
- assert.match(msg, /crtrd did not start/);
22
- assert.match(msg, /crtrd\.err \(tail\):\nError: bind EADDRINUSE/);
23
- });
24
- test('coldStartTimeoutMessage falls back to the bare message when no diagnostic is available', () => {
25
- assert.equal(coldStartTimeoutMessage(undefined), 'crtrd did not start; run `crtr sys daemon start` and check crtrd.err.');
26
- assert.equal(coldStartTimeoutMessage(''), 'crtrd did not start; run `crtr sys daemon start` and check crtrd.err.');
27
- });
28
- function startDelayedHealthzServer(socketPath, delayMs) {
29
- const server = createServer((_req, res) => {
30
- res.writeHead(200, { 'content-type': 'application/json' });
31
- res.end(JSON.stringify({ ok: true }));
32
- });
33
- const timer = setTimeout(() => server.listen(socketPath), delayMs);
34
- return { server, cancel: () => clearTimeout(timer) };
35
- }
36
- // REGRESSION: `crtr sys daemon restart` acks, then tears the old server down and
37
- // hands over to a successor — a request crossing that window dies with a hang-up
38
- // (ECONNRESET), not a refused connect. That used to surface as
39
- // `daemon_unavailable: socket hang up` telling the caller to run
40
- // `crtr sys daemon start` against a daemon that was already coming back up.
41
- function startHandoverServer(socketPath) {
42
- let dropped = false;
43
- const server = createServer((req, res) => {
44
- if (!dropped) {
45
- dropped = true; // model the old server dying mid-request
46
- req.socket.destroy();
47
- return;
48
- }
49
- res.writeHead(200, { 'content-type': 'application/json' });
50
- res.end(JSON.stringify({ ok: true, method: req.method }));
51
- });
52
- return {
53
- server,
54
- ready: new Promise((resolvePromise) => server.listen(socketPath, resolvePromise)),
55
- };
56
- }
57
- test('a hang-up mid-request rides out the daemon handover and replays the idempotent call', async () => {
58
- const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
59
- const socketPath = join(dir, 'crtrd.sock');
60
- const { server, ready } = startHandoverServer(socketPath);
61
- try {
62
- await ready;
63
- const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
64
- assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true, method: 'GET' });
65
- }
66
- finally {
67
- server.close();
68
- rmSync(dir, { recursive: true, force: true });
69
- }
70
- });
71
- test('a hang-up mid-mutation reports daemon_restarting, not daemon_unavailable', async () => {
72
- const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
73
- const socketPath = join(dir, 'crtrd.sock');
74
- const { server, ready } = startHandoverServer(socketPath);
75
- try {
76
- await ready;
77
- const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
78
- await assert.rejects(() => client.request('POST', '/v1/nodes', {}),
79
- // Replaying is unsafe (the daemon may have applied it), so this one is the
80
- // caller's call — but it must not be reported as a daemon that is down.
81
- (err) => err instanceof ApiError && err.code === 'daemon_restarting');
82
- }
83
- finally {
84
- server.close();
85
- rmSync(dir, { recursive: true, force: true });
86
- }
87
- });
88
- test('cliClient-style cold start fails loud when the injected poll window is shorter than the (valid) startup delay', async () => {
89
- const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
90
- const socketPath = join(dir, 'crtrd.sock');
91
- const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
92
- try {
93
- const client = new CrtrClient({
94
- socketPath,
95
- autostart: true,
96
- onColdSocket: async () => {
97
- /* fire-and-forget: the delayed listen() above models ensureDaemon() */
98
- },
99
- coldStartPollWindowMs: 100, // shorter than the 300ms the daemon actually needs
100
- });
101
- await assert.rejects(() => client.healthz(), (err) => err instanceof ApiError && err.code === 'daemon_unavailable');
102
- }
103
- finally {
104
- cancel(); // the client already timed out at 100ms; never let listen() fire at 300ms
105
- server.close();
106
- rmSync(dir, { recursive: true, force: true });
107
- }
108
- });
109
- test('cliClient-style cold start succeeds when the injected poll window covers the (valid) startup delay', async () => {
110
- const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
111
- const socketPath = join(dir, 'crtrd.sock');
112
- const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
113
- try {
114
- const client = new CrtrClient({
115
- socketPath,
116
- autostart: true,
117
- onColdSocket: async () => {
118
- /* fire-and-forget: the delayed listen() above models ensureDaemon() */
119
- },
120
- coldStartPollWindowMs: 2_000, // long enough to see the 300ms-delayed daemon come up
121
- });
122
- const health = await client.healthz();
123
- assert.deepEqual(health, { ok: true });
124
- }
125
- finally {
126
- cancel(); // already fired by the time we get here; harmless no-op
127
- server.close();
128
- rmSync(dir, { recursive: true, force: true });
129
- }
130
- });
131
- test('safeColdStartDiagnostic returns undefined for an absent hook', () => {
132
- assert.equal(safeColdStartDiagnostic(undefined), undefined);
133
- });
134
- test('safeColdStartDiagnostic returns the hook result when it succeeds', () => {
135
- assert.equal(safeColdStartDiagnostic(() => 'crtrd.err (tail):\nboom'), 'crtrd.err (tail):\nboom');
136
- });
137
- test('safeColdStartDiagnostic treats a THROWING hook as absent, not a propagated error', () => {
138
- assert.equal(safeColdStartDiagnostic(() => {
139
- throw new Error('custom hook blew up');
140
- }), undefined);
141
- });
@@ -1,171 +0,0 @@
1
- export declare const MEMORY_SCOPES: readonly ["node", "project", "profile", "user", "builtin"];
2
- export type MemoryScopeName = typeof MEMORY_SCOPES[number];
3
- /** A canonical document address: slash-joined safe segments, carried as one percent-encoded path segment. */
4
- export declare function isMemoryDocName(name: string): boolean;
5
- export interface MemoryOriginConversationDTO {
6
- session_file: string;
7
- }
8
- export interface MemoryScope {
9
- node?: string;
10
- cwd?: string;
11
- profile?: string;
12
- store?: MemoryScopeName;
13
- }
14
- export interface MemoryDocSummaryDTO {
15
- name: string;
16
- short_form: string;
17
- kind: string;
18
- privacy: 'public' | 'friends' | 'private';
19
- scope: MemoryScopeName;
20
- extensions: Record<string, Record<string, string | boolean | number>>;
21
- path: string;
22
- store_root: string;
23
- physical_relative_path: string;
24
- candidates?: MemoryCandidateDTO[];
25
- }
26
- export interface MemoryCandidateDTO {
27
- winner: boolean;
28
- scope: MemoryScopeName;
29
- store_root: string;
30
- physical_relative_path: string;
31
- path: string;
32
- }
33
- export interface MemoryDocDTO extends MemoryDocSummaryDTO {
34
- body: string;
35
- frontmatter: Record<string, unknown> | null;
36
- representation: 'leaf-file' | 'directory-index';
37
- links: string[];
38
- findings?: MemoryLintFindingDTO[];
39
- }
40
- export interface MemoryLintFindingDTO {
41
- severity: string;
42
- rule: string;
43
- message: string;
44
- }
45
- export interface MemoryListQuery extends MemoryScope {
46
- kind?: string;
47
- limit?: number;
48
- after?: string;
49
- }
50
- export interface MemoryDocCreateRequest extends MemoryScope {
51
- name: string;
52
- kind: string;
53
- when_and_why_to_read: string;
54
- body: string;
55
- frontmatter?: Record<string, unknown>;
56
- extensions?: Record<string, Record<string, string | boolean | number>>;
57
- }
58
- export interface MemoryDocUpdateRequest extends MemoryScope {
59
- rationale: string;
60
- body?: string;
61
- frontmatter?: Record<string, unknown>;
62
- extensions?: Record<string, Record<string, string | boolean | number>>;
63
- unset?: string[];
64
- }
65
- export interface MemoryDocDeletedDTO {
66
- name: string;
67
- scope: MemoryScopeName;
68
- path: string;
69
- log_path: string;
70
- deleted: true;
71
- }
72
- export interface MemoryDocMoveRequest extends MemoryScope {
73
- to: string;
74
- }
75
- export interface MemoryDocMovedDTO {
76
- name: string;
77
- previous_name: string;
78
- scope: MemoryScopeName;
79
- path: string;
80
- log_path: string;
81
- refs_rewritten: Array<{
82
- name: string;
83
- scope: MemoryScopeName;
84
- path: string;
85
- refs: number;
86
- }>;
87
- follow_up: string;
88
- }
89
- export interface MemorySearchRequest extends MemoryScope {
90
- query: string;
91
- kind?: string;
92
- grep?: boolean;
93
- body?: boolean;
94
- limit?: number;
95
- after?: string;
96
- min_score?: number;
97
- }
98
- export type MemorySearchHitDTO = MemoryRankedSearchHitDTO | MemoryGrepSearchHitDTO;
99
- export interface MemoryRankedSearchHitDTO {
100
- name: string;
101
- kind: string;
102
- privacy: 'public' | 'friends' | 'private';
103
- scope: MemoryScopeName;
104
- score: number;
105
- short_form: string;
106
- path: string;
107
- store_root: string;
108
- physical_relative_path: string;
109
- candidates?: MemoryCandidateDTO[];
110
- }
111
- export interface MemoryGrepSearchHitDTO {
112
- name: string;
113
- privacy: 'public' | 'friends' | 'private';
114
- scope: MemoryScopeName;
115
- line: number;
116
- text: string;
117
- path: string;
118
- store_root: string;
119
- physical_relative_path: string;
120
- candidates?: MemoryCandidateDTO[];
121
- }
122
- export interface MemoryHistoryQuery extends MemoryScope {
123
- limit?: number;
124
- revision?: number;
125
- diff?: boolean;
126
- }
127
- export interface MemoryHistoryRevisionDTO {
128
- revision: number;
129
- op: 'create' | 'edit' | 'delete' | 'move';
130
- at: string;
131
- node?: string;
132
- rationale?: string;
133
- verbatim?: true;
134
- body_changed: boolean;
135
- frontmatter_changed: string[];
136
- diff?: string;
137
- }
138
- export interface MemoryHistoryRecordDTO {
139
- revision: number;
140
- op: 'create' | 'edit' | 'delete' | 'move';
141
- at: string;
142
- node?: string;
143
- cwd: string;
144
- from?: string;
145
- rationale?: string;
146
- verbatim?: boolean;
147
- before: string;
148
- after: string;
149
- }
150
- export interface MemoryHistoryDTO {
151
- name: string;
152
- scope: MemoryScopeName;
153
- log_path: string;
154
- total: number;
155
- deleted?: unknown;
156
- revisions?: MemoryHistoryRevisionDTO[];
157
- record?: MemoryHistoryRecordDTO;
158
- }
159
- export interface MemoryDocRefDTO {
160
- name: string;
161
- scope: string;
162
- path: string;
163
- store_root: string;
164
- physical_relative_path: string;
165
- plugin?: string;
166
- }
167
- export interface MemoryListDTO<T> {
168
- object: 'list';
169
- data: T[];
170
- has_more: boolean;
171
- }
@@ -1,7 +0,0 @@
1
- // Memory-document DTOs. Every memory request carries an explicit target; crtrd
2
- // never resolves documents from its own cwd or environment.
3
- export const MEMORY_SCOPES = ['node', 'project', 'profile', 'user', 'builtin'];
4
- /** A canonical document address: slash-joined safe segments, carried as one percent-encoded path segment. */
5
- export function isMemoryDocName(name) {
6
- return /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(name);
7
- }