@north-light/api 0.3.532 → 0.3.534

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.
@@ -28,7 +28,7 @@ import type { ChatInventoryDTO, ProspectiveChatInventoryDTO, ProspectiveChatInve
28
28
  import type { CredentialRemovalResultDTO, CredentialResultDTO, InstallCredentialRequest, CompleteModelAuthFlowRequest, ModelAuthFlowCompletedDTO, ModelAuthFlowDTO, ModelAuthFlowStartedDTO, ModelAuthListDTO, ModelAuthReadinessDTO, ModelAuthReadinessQuery, StartModelAuthFlowRequest } from './dto/modelauth.js';
29
29
  import type { CancelReviewRequest, CreateReviewRequest, ListReviewsQuery, ReviewCancelResultDTO, ReviewDocumentBaseDTO, ReviewDTO, ReviewListDTO, ReviewSubmitResultDTO } from './dto/reviews.js';
30
30
  import type { CreateReviewCommentRequest, EditReviewCommentRequest, ListReviewCommentsQuery, ReadReviewCommentEventsQuery, ReviewCommentActionRequest, ReviewCommentDetailDTO, ReviewCommentEventsDTO, ReviewCommentForkDTO, ReviewCommentListDTO, ReviewCommentMutationDTO, ReviewCommentRangeBatchRequest, ReviewCommentRangeBatchResultDTO } from './dto/review-comments.js';
31
- import type { CancelInboxTicketRequest, CanceledTicketResultDTO, InboxListDTO, InboxPageDTO, InboxPageHistoryDTO, InboxPageResponseDTO, InboxTicketIdDTO, PageFeedbackResolutionDTO, PageResponsesDTO, PageTicketResultDTO, RespondInboxPageRequest } from './dto/inbox.js';
31
+ import type { CancelInboxTicketRequest, CanceledTicketResultDTO, InboxListDTO, InboxPageDTO, InboxPageHistoryDTO, PageTicketSummaryDTO, InboxPageResponseDTO, InboxTicketIdDTO, PageFeedbackResolutionDTO, PageResponsesDTO, PageTicketResultDTO, RespondInboxPageRequest } from './dto/inbox.js';
32
32
  import type { CreateHumanRequestDTO, CreateHumanRequestRequest, HumanRequestDTO, HumanRequestIdDTO, ReplaceHumanRequestRequest, RespondHumanRequestRequest, SettleHumanRequestRequest, PageComponentListDTO } from './dto/human-requests.js';
33
33
  import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryStatsQuery, HistoryStatsResultDTO, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, GraphDTO, PruneRequest, PruneResultDTO, LiveRootsDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
34
34
  import type { AbandonWorktreeRequest, AbandonWorktreeResultDTO, CloseWorktreeResultDTO, QuarantinedWorktreeDTO, SpaceListDTO } from './dto/worktree.js';
@@ -377,8 +377,11 @@ export declare class CrtrClient {
377
377
  /** Read one page ticket by its opaque ticket id (pending, resolved, or canceled). */
378
378
  getHumanInboxPage(ticketId: InboxTicketIdDTO): Promise<InboxPageDTO>;
379
379
  /** All page tickets, oldest first (pending, resolved, or canceled) —
380
- * home-wide, or narrowed to one raising node when `nodeId` is given. */
381
- getInboxHistory(nodeId?: string): Promise<InboxPageHistoryDTO>;
380
+ * home-wide, or narrowed to one raising node when `nodeId` is given, and to
381
+ * one ticket state when `opts.state` is given. */
382
+ getInboxHistory(nodeId?: string, opts?: {
383
+ state?: PageTicketSummaryDTO['state'];
384
+ }): Promise<InboxPageHistoryDTO>;
382
385
  /** Submit responses for a page ticket. Single-assignment server-side: a competing
383
386
  * resolution races to `ticket_already_resolved`. */
384
387
  respondHumanInboxPage(ticketId: InboxTicketIdDTO, request: RespondInboxPageRequest): Promise<PageTicketResultDTO>;
@@ -707,15 +707,13 @@ export class CrtrClient {
707
707
  return this.request('GET', routes.humanInboxTicket(this.ticketId(ticketId)));
708
708
  }
709
709
  /** All page tickets, oldest first (pending, resolved, or canceled) —
710
- * home-wide, or narrowed to one raising node when `nodeId` is given. */
711
- getInboxHistory(nodeId) {
712
- if (nodeId === undefined) {
713
- return this.request('GET', routes.humanInbox() + '/history');
714
- }
715
- if (typeof nodeId !== 'string' || nodeId === '') {
710
+ * home-wide, or narrowed to one raising node when `nodeId` is given, and to
711
+ * one ticket state when `opts.state` is given. */
712
+ getInboxHistory(nodeId, opts = {}) {
713
+ if (nodeId !== undefined && (typeof nodeId !== 'string' || nodeId === '')) {
716
714
  throw new TypeError(`invalid node id: ${JSON.stringify(nodeId)}`);
717
715
  }
718
- return this.request('GET', withQuery(routes.humanInbox() + '/history', { node_id: nodeId }));
716
+ return this.request('GET', withQuery(routes.humanInbox() + '/history', { node_id: nodeId, state: opts.state }));
719
717
  }
720
718
  /** Submit responses for a page ticket. Single-assignment server-side: a competing
721
719
  * resolution races to `ticket_already_resolved`. */
@@ -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.25";
2
+ export declare const CONTRACT_LEVEL = "1.26";
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.25';
15
+ export const CONTRACT_LEVEL = '1.26';
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`. */
@@ -257,6 +257,8 @@ export interface RosterNodeDTO {
257
257
  enterable: boolean;
258
258
  parent: NodeIdDTO | null;
259
259
  created: IsoTime;
260
+ /** The node's one-line description, the label for an unnamed node. Absent from daemons older than this field. */
261
+ description?: string | null;
260
262
  }
261
263
  /** One `subscribes_to` edge in the roster's topology half. Every edge is kept
262
264
  * (passive + multiple per node) — never collapsed to one edge per node. */
@@ -29,7 +29,7 @@ export interface CronDTO {
29
29
  command: string;
30
30
  /** The grant the cron runs under (checked at every fire, call and birth); null only for the terminal on a runtime with no issuer. */
31
31
  grant_id: string | null;
32
- /** The scopes the cron is bounded to; null = the grant's whole current set (a person-armed terminal cron). */
32
+ /** The scopes the cron is bounded to; null = the grant's whole current set at each fire (a person-armed terminal cron, or one armed by a caller with no scope limit). */
33
33
  scopes: string[] | null;
34
34
  /** The run of the node that armed it, when a node did. */
35
35
  origin_run_id: string | null;
@@ -83,6 +83,8 @@ export interface CronDTO {
83
83
  * boundary between profiles, not a security boundary. */
84
84
  export interface ListCronsQuery {
85
85
  profile?: string;
86
+ /** Only the crons this node armed (`CronDTO.created_by`). */
87
+ created_by?: string;
86
88
  }
87
89
  /** Caller identity for the per-cron routes (show/pause/resume/run/cancel).
88
90
  * Same rule as `ListCronsQuery`: a cron outside the caller's scope is 404,
@@ -1,12 +1,11 @@
1
- import type { DatabaseSync } from 'node:sqlite';
2
1
  import type { BrokerExtensionNodeDTO } from '../../api/dto/broker-ops.js';
3
2
  /** Node-owned mutable fields in extensionNode's projection. Keep this list and
4
3
  * the typed cache view together; object/worktree-owned fields are never cached. */
5
4
  export declare const BROKER_SIGNAL_NODE_COLUMNS: readonly ["description", "title", "icon", "mode", "lifecycle", "status", "cwd", "parent", "fork_from", "profile_id", "review_binding_json", "persona_ack_json", "intent"];
6
5
  type ProjectedNodeKey<C extends string> = C extends 'review_binding_json' ? 'review_binding' : C extends 'persona_ack_json' ? 'persona_ack' : C;
7
6
  export type BrokerSignalCachedNode = Pick<BrokerExtensionNodeDTO, Extract<ProjectedNodeKey<(typeof BROKER_SIGNAL_NODE_COLUMNS)[number]>, keyof BrokerExtensionNodeDTO>>;
8
- /** Only node-owned columns are suitable for P4's cache. The source list also
9
- * drives the SQLite trigger, so a newly added node field has one edit point. */
7
+ /** Only node-owned columns are suitable for P4's cache. The same columns
8
+ * appear in `crtr_sig_node_upd` in schema-pg.sql; keep the two lists together. */
10
9
  export declare function cachedBrokerNodeFields(node: BrokerExtensionNodeDTO): BrokerSignalCachedNode;
11
10
  export type BrokerSignalKind = 'mail' | 'node';
12
11
  export type BrokerSignalSubscriber = (nodeId: string, kind: BrokerSignalKind) => void;
@@ -21,9 +20,18 @@ export declare class BrokerSignalBus {
21
20
  markNode(nodeId: string): void;
22
21
  private mark;
23
22
  private flush;
24
- /** Reinstalled for every new daemon canvas handle (TEMP triggers vanish on close). */
25
- install(db: DatabaseSync): void;
26
- close(): void;
23
+ /** Start listening on `crtr_signal` over a second, listen-only connection to `url` (the schema's `pg_notify`
24
+ * triggers publish `mail:<node id>` and `node:<node id>` at commit). Resolves once LISTEN is active, so a write
25
+ * committed after it returns is heard. A lost connection reconnects with backoff; signals sent while it was down
26
+ * are lost, which is safe because they are hints and every consumer re-reads durable state. */
27
+ listen(url: string): Promise<void>;
28
+ private listener;
29
+ private listening;
30
+ private url;
31
+ private reconnect;
32
+ private connectListener;
33
+ /** Stop listening and drop pending marks. The returned promise resolves once the connection has closed. */
34
+ close(): Promise<void>;
27
35
  }
28
36
  /** One daemon-process bus; the API fixture binds this same instance. */
29
37
  export declare const brokerSignalBus: BrokerSignalBus;
@@ -1,3 +1,4 @@
1
+ import pg from 'pg';
1
2
  /** Node-owned mutable fields in extensionNode's projection. Keep this list and
2
3
  * the typed cache view together; object/worktree-owned fields are never cached. */
3
4
  export const BROKER_SIGNAL_NODE_COLUMNS = [
@@ -6,8 +7,8 @@ export const BROKER_SIGNAL_NODE_COLUMNS = [
6
7
  'persona_ack_json', 'intent',
7
8
  ];
8
9
  const nodeField = (column) => column === 'review_binding_json' ? 'review_binding' : column === 'persona_ack_json' ? 'persona_ack' : column;
9
- /** Only node-owned columns are suitable for P4's cache. The source list also
10
- * drives the SQLite trigger, so a newly added node field has one edit point. */
10
+ /** Only node-owned columns are suitable for P4's cache. The same columns
11
+ * appear in `crtr_sig_node_upd` in schema-pg.sql; keep the two lists together. */
11
12
  export function cachedBrokerNodeFields(node) {
12
13
  const picked = {};
13
14
  for (const column of BROKER_SIGNAL_NODE_COLUMNS) {
@@ -48,39 +49,81 @@ export class BrokerSignalBus {
48
49
  }
49
50
  }
50
51
  }
51
- /** Reinstalled for every new daemon canvas handle (TEMP triggers vanish on close). */
52
- install(db) {
53
- db.function('crtr_signal', { deterministic: false }, (kind, id) => {
54
- // SQLite invokes this synchronously inside the writer's statement. A hint
55
- // must never cause a transaction to fail, even for an unexpected row.
56
- try {
57
- if (typeof id === 'string') {
58
- if (kind === 'mail')
59
- this.markMail(id);
60
- if (kind === 'node')
61
- this.markNode(id);
62
- }
63
- }
64
- catch { /* best-effort hint only */ }
65
- return null;
52
+ /** Start listening on `crtr_signal` over a second, listen-only connection to `url` (the schema's `pg_notify`
53
+ * triggers publish `mail:<node id>` and `node:<node id>` at commit). Resolves once LISTEN is active, so a write
54
+ * committed after it returns is heard. A lost connection reconnects with backoff; signals sent while it was down
55
+ * are lost, which is safe because they are hints and every consumer re-reads durable state. */
56
+ async listen(url) {
57
+ if (this.listener !== undefined || this.listening !== undefined)
58
+ return;
59
+ this.url = url;
60
+ this.listening = this.connectListener().finally(() => { this.listening = undefined; });
61
+ await this.listening;
62
+ }
63
+ listener;
64
+ listening;
65
+ url;
66
+ reconnect;
67
+ async connectListener() {
68
+ const client = new pg.Client({ connectionString: this.url, application_name: `crtr-signal-${process.pid}` });
69
+ client.on('notification', message => {
70
+ // A hint must never cause an exception for an unexpected payload.
71
+ const payload = message.payload ?? '';
72
+ const colon = payload.indexOf(':');
73
+ if (colon < 0)
74
+ return;
75
+ const kind = payload.slice(0, colon);
76
+ const id = payload.slice(colon + 1);
77
+ if (kind === 'mail')
78
+ this.markMail(id);
79
+ else if (kind === 'node')
80
+ this.markNode(id);
66
81
  });
67
- db.exec(`
68
- CREATE TEMP TRIGGER crtr_sig_deliv_ins AFTER INSERT ON main.deliveries
69
- WHEN NEW.wake = 1 BEGIN SELECT crtr_signal('mail', NEW.destination_id); END;
70
- CREATE TEMP TRIGGER crtr_sig_deliv_upd AFTER UPDATE OF state, canceled_at, claim_owner ON main.deliveries
71
- WHEN NEW.wake = 1 BEGIN SELECT crtr_signal('mail', NEW.destination_id); END;
72
- CREATE TEMP TRIGGER crtr_sig_deliv_del AFTER DELETE ON main.deliveries
73
- WHEN OLD.wake = 1 BEGIN SELECT crtr_signal('mail', OLD.destination_id); END;
74
- CREATE TEMP TRIGGER crtr_sig_node_upd AFTER UPDATE OF ${BROKER_SIGNAL_NODE_COLUMNS.join(', ')} ON main.nodes
75
- BEGIN SELECT crtr_signal('node', NEW.node_id); END;
76
- `);
82
+ const lost = () => {
83
+ if (this.listener !== client)
84
+ return;
85
+ this.listener = undefined;
86
+ client.removeAllListeners();
87
+ client.on('error', () => { });
88
+ client.end().catch(() => { });
89
+ if (this.url === undefined)
90
+ return;
91
+ this.reconnect = setTimeout(() => {
92
+ this.reconnect = undefined;
93
+ this.connectListener().catch(() => lostRetry());
94
+ }, 1000);
95
+ };
96
+ const lostRetry = () => {
97
+ if (this.url === undefined || this.reconnect !== undefined)
98
+ return;
99
+ this.reconnect = setTimeout(() => {
100
+ this.reconnect = undefined;
101
+ this.connectListener().catch(() => lostRetry());
102
+ }, 1000);
103
+ };
104
+ client.on('error', lost);
105
+ client.on('end', lost);
106
+ await client.connect();
107
+ await client.query('LISTEN crtr_signal');
108
+ this.listener = client;
77
109
  }
110
+ /** Stop listening and drop pending marks. The returned promise resolves once the connection has closed. */
78
111
  close() {
112
+ this.url = undefined;
113
+ if (this.reconnect !== undefined)
114
+ clearTimeout(this.reconnect);
115
+ this.reconnect = undefined;
116
+ const listener = this.listener;
117
+ this.listener = undefined;
79
118
  if (this.scheduled !== undefined)
80
119
  clearImmediate(this.scheduled);
81
120
  this.scheduled = undefined;
82
121
  this.dirty.clear();
83
122
  this.subscribers.clear();
123
+ if (listener === undefined)
124
+ return Promise.resolve();
125
+ listener.removeAllListeners('notification');
126
+ return listener.end().catch(() => undefined);
84
127
  }
85
128
  }
86
129
  /** One daemon-process bus; the API fixture binds this same instance. */
@@ -40,6 +40,16 @@ export declare function sweepStrayDaemons(keepPid: number | null): number[];
40
40
  /** Replace PID 1 with the selected daemon generation. Docker keeps a container
41
41
  * alive only while PID 1 lives, so a handover there must preserve its pid. */
42
42
  export declare function execDaemon(): Promise<never>;
43
+ export interface DaemonExportSqliteResult {
44
+ path: string;
45
+ rows: number;
46
+ ms: number;
47
+ userVersion: number;
48
+ }
49
+ /** Run the selected generation's `crtrd-cli export-sqlite` and wait for it. The export loads canvas database code, which
50
+ * a CLI process may not, so it runs in the daemon entry the way the daemon itself is launched. Throws with the child's
51
+ * stderr when it fails. */
52
+ export declare function runDaemonExportSqlite(): Promise<DaemonExportSqliteResult>;
43
53
  export interface SpawnDaemonOptions {
44
54
  /** The phase that proves a daemon launched by this call is available. */
45
55
  targetPhase?: 'ready' | 'prepared';
@@ -195,6 +195,25 @@ export async function execDaemon() {
195
195
  const launchArgs = await resolveDaemonLaunch();
196
196
  return execve(executable, [executable, ...launchArgs, '--canvas-home', crtrHome()], sanitizedDaemonEnv());
197
197
  }
198
+ /** Run the selected generation's `crtrd-cli export-sqlite` and wait for it. The export loads canvas database code, which
199
+ * a CLI process may not, so it runs in the daemon entry the way the daemon itself is launched. Throws with the child's
200
+ * stderr when it fails. */
201
+ export async function runDaemonExportSqlite() {
202
+ const launchArgs = await resolveDaemonLaunch();
203
+ const child = spawn(hostExecPath(), [...launchArgs, 'export-sqlite'], { stdio: ['ignore', 'pipe', 'pipe'], env: sanitizedDaemonEnv() });
204
+ let stdout = '';
205
+ let stderr = '';
206
+ child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; });
207
+ child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; });
208
+ const code = await new Promise((resolve, reject) => {
209
+ child.once('error', reject);
210
+ child.once('close', (exitCode) => resolve(exitCode));
211
+ });
212
+ if (code !== 0)
213
+ throw new Error(stderr.trim() || `the export process exited with code ${code}`);
214
+ const line = stdout.trim().split('\n').at(-1) ?? '';
215
+ return JSON.parse(line);
216
+ }
198
217
  /** How long a handover successor waits for a running join: a registration call (30 s) plus the join's own stop and start. */
199
218
  const JOIN_WAIT_WINDOW_MS = 3 * 60_000;
200
219
  // Shutdown stays short even though startup may include corpus migration.
@@ -220,12 +239,14 @@ export function probeErrorMessage(error) {
220
239
  /** True only when one mandatory Unix API socket probe answers the existing ready health contract. */
221
240
  export async function probeDaemonServing(timeoutMs = DAEMON_HEALTH_PROBE_TIMEOUT_MS) {
222
241
  const health = await probeDaemonHealth(timeoutMs);
223
- if (health.startup_blocked !== undefined) {
242
+ // Only `blocked` needs a person; `initializing` and `recovering` (a first-boot SQLite import, broker recovery) finish
243
+ // on their own, so a caller waiting out a startup window keeps waiting through them.
244
+ if (health.startup_phase === 'blocked') {
224
245
  const pid = readPidfile() ?? process.pid;
225
- throw new DaemonStartupBlockedError(pid, health.startup_blocked);
246
+ throw new DaemonStartupBlockedError(pid, health.startup_blocked ?? 'daemon startup is blocked');
226
247
  }
227
248
  if (!health.ok)
228
- throw new Error('crtrd health check reported not ready');
249
+ throw new Error(health.startup_blocked ?? 'crtrd health check reported not ready');
229
250
  }
230
251
  /** True only when one mandatory Unix API socket probe answers `/healthz`.
231
252
  * A pidfile identifies an owner; it is never readiness. */
@@ -110,6 +110,9 @@ export declare function envMaxLiveBrokers(): number | undefined;
110
110
  /** Live-broker warning threshold override (`CRTR_WARN_LIVE_BROKERS`); wins
111
111
  * over `readConfig('user').brokerThresholds.warning` when set. */
112
112
  export declare function envWarnLiveBrokers(): number | undefined;
113
+ /** Test-only override (`CRTR_TEST_NODE_SOCKET_WINDOW_MS`) of how long a command inside a node waits for the broker's
114
+ * relay socket to come back; gated by `envNoDaemonAutostart()` at the call site, so a real node always waits out a restart. */
115
+ export declare function envTestNodeSocketWindowMs(): number | undefined;
113
116
  /** Test-only unattended-park interval override (`CRTR_TEST_UNATTENDED_PARK_MS`),
114
117
  * gated by `envNoDaemonAutostart()` at the call site; no production default. */
115
118
  export declare function envTestUnattendedParkMs(): number | undefined;
@@ -283,6 +283,11 @@ function parsePositiveMsNoDefault(raw) {
283
283
  const n = Number(raw);
284
284
  return Number.isFinite(n) && n > 0 ? n : undefined;
285
285
  }
286
+ /** Test-only override (`CRTR_TEST_NODE_SOCKET_WINDOW_MS`) of how long a command inside a node waits for the broker's
287
+ * relay socket to come back; gated by `envNoDaemonAutostart()` at the call site, so a real node always waits out a restart. */
288
+ export function envTestNodeSocketWindowMs() {
289
+ return parsePositiveMsNoDefault(process.env['CRTR_TEST_NODE_SOCKET_WINDOW_MS']);
290
+ }
286
291
  /** Test-only unattended-park interval override (`CRTR_TEST_UNATTENDED_PARK_MS`),
287
292
  * gated by `envNoDaemonAutostart()` at the call site; no production default. */
288
293
  export function envTestUnattendedParkMs() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.532",
3
+ "version": "0.3.534",
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",