@north-light/api 0.3.560 → 0.3.562

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 (87) hide show
  1. package/dist/api/client.d.ts +27 -4
  2. package/dist/api/client.js +53 -0
  3. package/dist/api/contract.d.ts +1 -1
  4. package/dist/api/contract.js +1 -1
  5. package/dist/api/dto/bash-jobs.d.ts +5 -1
  6. package/dist/api/dto/broker-ops.d.ts +25 -1
  7. package/dist/api/dto/charges.d.ts +9 -2
  8. package/dist/api/dto/delivery.d.ts +26 -2
  9. package/dist/api/dto/execution-start.d.ts +57 -0
  10. package/dist/api/dto/execution-start.js +2 -0
  11. package/dist/api/dto/modelauth.d.ts +5 -0
  12. package/dist/api/dto/node-records.d.ts +16 -0
  13. package/dist/api/dto/objects.d.ts +1 -1
  14. package/dist/api/dto/reports.d.ts +0 -3
  15. package/dist/api/dto/run-events.d.ts +7 -1
  16. package/dist/api/dto/settings.d.ts +55 -0
  17. package/dist/api/dto/settings.js +4 -0
  18. package/dist/api/index.d.ts +2 -0
  19. package/dist/api/index.js +2 -0
  20. package/dist/api/routes.d.ts +11 -0
  21. package/dist/api/routes.js +12 -0
  22. package/dist/core/canvas/paths.d.ts +0 -1
  23. package/dist/core/canvas/paths.js +0 -3
  24. package/dist/core/canvas/person-state.d.ts +26 -0
  25. package/dist/core/canvas/person-state.js +41 -0
  26. package/dist/core/command-plugins/bundle.d.ts +1 -1
  27. package/dist/core/command-plugins/bundle.js +5 -5
  28. package/dist/core/config.d.ts +16 -15
  29. package/dist/core/config.js +99 -76
  30. package/dist/core/conversation-store/client.d.ts +2 -0
  31. package/dist/core/conversation-store/client.js +111 -0
  32. package/dist/core/conversation-store/index.d.ts +96 -0
  33. package/dist/core/conversation-store/index.js +62 -0
  34. package/dist/core/conversation-store/patch.d.ts +3 -0
  35. package/dist/core/conversation-store/patch.js +192 -0
  36. package/dist/core/conversation-store/repair.d.ts +45 -0
  37. package/dist/core/conversation-store/repair.js +84 -0
  38. package/dist/core/env-name.d.ts +1 -0
  39. package/dist/core/env-name.js +16 -0
  40. package/dist/core/events/emit.js +7 -5
  41. package/dist/core/git.d.ts +28 -0
  42. package/dist/core/git.js +103 -0
  43. package/dist/core/installed-plugins.js +12 -7
  44. package/dist/core/managed-providers.d.ts +72 -0
  45. package/dist/core/managed-providers.js +61 -0
  46. package/dist/core/plugin-env.js +1 -2
  47. package/dist/core/profiles/manifest.d.ts +14 -18
  48. package/dist/core/profiles/manifest.js +79 -98
  49. package/dist/core/runtime/broker/attachments-fetch.d.ts +5 -0
  50. package/dist/core/runtime/broker/attachments-fetch.js +20 -0
  51. package/dist/core/runtime/broker/daemon-ops.d.ts +10 -1
  52. package/dist/core/runtime/broker/daemon-ops.js +41 -3
  53. package/dist/core/runtime/broker/node-token.d.ts +3 -0
  54. package/dist/core/runtime/broker/node-token.js +16 -0
  55. package/dist/core/runtime/broker/signal-stream.js +2 -2
  56. package/dist/core/scoped-state/materialized-plugins.d.ts +18 -0
  57. package/dist/core/scoped-state/materialized-plugins.js +103 -0
  58. package/dist/core/scoped-state/person-store.d.ts +72 -0
  59. package/dist/core/scoped-state/person-store.js +112 -0
  60. package/dist/core/user-settings.js +1 -1
  61. package/dist/daemon/broker-signals/bus.d.ts +48 -11
  62. package/dist/daemon/broker-signals/bus.js +116 -46
  63. package/dist/shared/inbox-entry-body.js +2 -2
  64. package/dist/types.d.ts +12 -0
  65. package/package.json +1 -1
  66. package/dist/core/canvas/legacy-person.d.ts +0 -4
  67. package/dist/core/canvas/legacy-person.js +0 -4
  68. package/dist/core/locked-file.d.ts +0 -27
  69. package/dist/core/locked-file.js +0 -121
  70. package/dist/core/profiles/env-store.d.ts +0 -22
  71. package/dist/core/profiles/env-store.js +0 -166
  72. package/dist/core/scoped-state/db.d.ts +0 -17
  73. package/dist/core/scoped-state/db.js +0 -261
  74. package/dist/core/scoped-state/migrate.d.ts +0 -8
  75. package/dist/core/scoped-state/migrate.js +0 -187
  76. package/dist/core/scoped-state/profiles.d.ts +0 -27
  77. package/dist/core/scoped-state/profiles.js +0 -94
  78. package/dist/core/scoped-state/providers.d.ts +0 -24
  79. package/dist/core/scoped-state/providers.js +0 -19
  80. package/dist/core/scoped-state/schema.d.ts +0 -10
  81. package/dist/core/scoped-state/schema.js +0 -47
  82. package/dist/core/scoped-state/settings.d.ts +0 -28
  83. package/dist/core/scoped-state/settings.js +0 -104
  84. package/dist/core/sqlite-statements.d.ts +0 -4
  85. package/dist/core/sqlite-statements.js +0 -17
  86. package/dist/core/subscription-state.d.ts +0 -129
  87. package/dist/core/subscription-state.js +0 -299
@@ -0,0 +1,103 @@
1
+ // materialized-plugins.ts — the plugin bytes a cloud-role process holds for the people it serves (D84). A person's enabled plugins are
2
+ // rows (`scope_plugins`, `scope_marketplaces`); a fresh API process has no plugin directories, so before it reads plugin manifests it
3
+ // installs each enabled plugin from its marketplace into a process-local directory keyed by (person, plugins_revision). Leaf-safe:
4
+ // the installed-plugin reader imports this, so it imports no database. `loadPersonSettings` (person-rows.ts) fills it.
5
+ import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { isAbsolute, join, relative, resolve } from 'node:path';
8
+ import { CrtrError } from '../errors.js';
9
+ import { gitAsync } from '../git.js';
10
+ import { readMarketplaceManifest } from '../manifest.js';
11
+ const materialized = new Map();
12
+ const inFlight = new Map();
13
+ /** The directory holding this person's materialized plugins as `<dir>/<plugin name>`; undefined when none were materialized. */
14
+ export function materializedPluginsDir(person) {
15
+ const held = materialized.get(person);
16
+ if (held !== undefined && 'failure' in held)
17
+ throw held.failure;
18
+ return held?.dir;
19
+ }
20
+ function cacheRoot(person) { return join(tmpdir(), 'crtr-plugin-cache', person.replace(/[^A-Za-z0-9._-]/g, '_')); }
21
+ async function cloneInto(url, ref, dest, what) {
22
+ const res = await gitAsync(['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), url, dest]);
23
+ if (res.status !== 0)
24
+ throw new CrtrError('plugin_install_failed', `${what}: git clone ${url} failed: ${res.stderr.trim()}`);
25
+ }
26
+ async function installAll(staging, installs) {
27
+ const marketRoots = new Map();
28
+ mkdirSync(join(staging, 'plugins'), { recursive: true });
29
+ for (const plugin of installs) {
30
+ if (plugin.sourcePath !== null)
31
+ throw new CrtrError('plugin_source_unreachable', `${plugin.name}: its source is ${plugin.sourcePath} on the machine that registered it, which the control plane cannot read`);
32
+ const market = plugin.marketplace;
33
+ if (market === null)
34
+ throw new CrtrError('plugin_source_unreachable', `${plugin.name}: it names neither a marketplace nor a path`);
35
+ if (market.url === null || market.url === '')
36
+ throw new CrtrError('plugin_source_unreachable', `${plugin.name}: its marketplace ${market.name} has no url`);
37
+ const key = `${market.url}\0${market.ref ?? ''}`;
38
+ let root = marketRoots.get(key);
39
+ if (root === undefined) {
40
+ root = join(staging, 'marketplaces', String(marketRoots.size));
41
+ await cloneInto(market.url, market.ref, root, `${plugin.name}: marketplace ${market.name}`);
42
+ marketRoots.set(key, root);
43
+ }
44
+ const entry = readMarketplaceManifest(root)?.plugins.find((p) => p.name === plugin.name);
45
+ if (entry === undefined)
46
+ throw new CrtrError('plugin_install_failed', `${plugin.name}: marketplace ${market.name} (${market.url}) does not list it`);
47
+ const dest = join(staging, 'plugins', plugin.name);
48
+ if (isAbsolute(entry.source) || /^[a-z][a-z0-9+.-]*:|^git@/i.test(entry.source) || entry.source.endsWith('.git')) {
49
+ await cloneInto(entry.source, null, dest, plugin.name);
50
+ continue;
51
+ }
52
+ const from = resolve(root, entry.source);
53
+ const inside = relative(root, from);
54
+ if (inside.startsWith('..') || isAbsolute(inside))
55
+ throw new CrtrError('plugin_install_failed', `${plugin.name}: its source ${entry.source} leaves the marketplace`);
56
+ if (!existsSync(from))
57
+ throw new CrtrError('plugin_install_failed', `${plugin.name}: its source ${entry.source} is not in marketplace ${market.name}`);
58
+ cpSync(from, dest, { recursive: true });
59
+ }
60
+ rmSync(join(staging, 'marketplaces'), { recursive: true, force: true });
61
+ }
62
+ /** Install `installs` for `person` at `revision` into this process's plugin cache, once per (person, revision). When a plugin cannot
63
+ * be installed — a source on another machine, a marketplace that does not list it, a failed clone — the failure is held for that person
64
+ * and thrown by the next read of their plugins (`materializedPluginsDir`), so a request that never reads plugins, such as the one that
65
+ * fixes the settings, still works; the next load retries. */
66
+ export async function materializePlugins(person, revision, installs) {
67
+ const held = materialized.get(person);
68
+ if (held?.revision === revision && !('failure' in held))
69
+ return;
70
+ const final = join(cacheRoot(person), String(revision));
71
+ const key = `${person}\0${revision}`;
72
+ let pending = inFlight.get(key);
73
+ if (pending === undefined) {
74
+ pending = (async () => {
75
+ if (!existsSync(join(final, 'plugins'))) {
76
+ const staging = join(cacheRoot(person), `${revision}.${process.pid}.${Date.now()}`);
77
+ try {
78
+ await installAll(staging, installs);
79
+ try {
80
+ renameSync(staging, final);
81
+ }
82
+ catch (error) {
83
+ if (!existsSync(join(final, 'plugins')))
84
+ throw error;
85
+ }
86
+ }
87
+ finally {
88
+ rmSync(staging, { recursive: true, force: true });
89
+ }
90
+ }
91
+ return join(final, 'plugins');
92
+ })().finally(() => inFlight.delete(key));
93
+ inFlight.set(key, pending);
94
+ }
95
+ try {
96
+ materialized.set(person, { revision, dir: await pending });
97
+ }
98
+ catch (error) {
99
+ if (!(error instanceof CrtrError))
100
+ throw error;
101
+ materialized.set(person, { revision, failure: error });
102
+ }
103
+ }
@@ -0,0 +1,72 @@
1
+ import type { ProfileManifest, ScopeState } from '../../types.js';
2
+ export type StoredProfile = {
3
+ profileId: string;
4
+ manifest: ProfileManifest;
5
+ };
6
+ /** Everything one person's rows hold, in the shape the synchronous readers want. `settings` is keyed by owner (`user`,
7
+ * `profile:<id>`) and carries the rebuilt `plugins` and `marketplaces` blocks; `state` carries the bookkeeping. */
8
+ export interface PersonSnapshot {
9
+ profiles: StoredProfile[];
10
+ defaults: Record<string, string>;
11
+ settings: Record<string, Record<string, unknown>>;
12
+ state: Record<string, ScopeState>;
13
+ }
14
+ export type SettingsOp = {
15
+ op: 'settings.replace';
16
+ owner: string;
17
+ raw: Record<string, unknown>;
18
+ ifRevision: string;
19
+ } | {
20
+ op: 'state.write';
21
+ owner: string;
22
+ state: ScopeState;
23
+ } | {
24
+ op: 'profile.insert';
25
+ profile: StoredProfile;
26
+ } | {
27
+ op: 'profile.update';
28
+ profile: StoredProfile;
29
+ } | {
30
+ op: 'profile.delete';
31
+ profileId: string;
32
+ } | {
33
+ op: 'defaults.replace';
34
+ defaults: Record<string, string>;
35
+ };
36
+ export declare class SettingsRevisionConflictError extends Error {
37
+ constructor();
38
+ }
39
+ export declare class PersonSettingsNotLoadedError extends Error {
40
+ constructor(person: string);
41
+ }
42
+ declare const BLOCKS: readonly ["plugins", "marketplaces"];
43
+ export declare function revisionOf(raw: Record<string, unknown> | undefined): string;
44
+ export declare function installPersonSnapshot(person: string, snapshot: PersonSnapshot): void;
45
+ export declare function heldPersons(): string[];
46
+ export declare function dropPersonSnapshots(): void;
47
+ /** Before the install's person is resolved (a Mac's first boot reads its config to resolve it), reads answer from this snapshot of
48
+ * the old local store; `loadPersonSettings` replaces it once the person is known. */
49
+ export declare function installPreludeSnapshot(snapshot: PersonSnapshot | undefined): void;
50
+ /** The current person's snapshot. A miss is an error: a person whose rows exist never reads as defaults. A cloud role outside a
51
+ * request has no person and no install settings: it reads defaults. */
52
+ export declare function currentSnapshot(): PersonSnapshot;
53
+ export declare function personHasSnapshot(): boolean;
54
+ export type SettingsTransport = (person: string, ops: SettingsOp[]) => Promise<PersonSnapshot>;
55
+ export declare function setSettingsTransport(next: SettingsTransport): void;
56
+ /** Compute ops from the cached snapshot and send them; on a revision conflict reload (the transport returned or the rows hold a newer
57
+ * snapshot) and compute again, up to three times. A failed write throws and leaves the cache as it was. */
58
+ export declare function writeSettings(compute: (snapshot: PersonSnapshot) => SettingsOp[]): Promise<PersonSnapshot>;
59
+ export declare function setPersonSnapshot(person: string, snapshot: PersonSnapshot): void;
60
+ export declare function setSettingsReloader(next: (person: string) => Promise<PersonSnapshot>): void;
61
+ export declare function ownerKeyOf(profileId?: string): string;
62
+ export declare function readCachedSettings(owner: string): {
63
+ settings: Record<string, unknown> | null;
64
+ revision: string;
65
+ };
66
+ export declare function readCachedState(owner: string): ScopeState;
67
+ export { BLOCKS as SETTINGS_ROW_BLOCKS };
68
+ export declare function listCachedProfiles(): StoredProfile[];
69
+ export declare function getCachedProfile(profileId: string): StoredProfile | null;
70
+ export declare function cachedProfilePaused(profileId: string): boolean;
71
+ export declare function cachedPausedProfileIds(): Set<string>;
72
+ export declare function cachedDefaults(): Record<string, string>;
@@ -0,0 +1,112 @@
1
+ // person-store.ts — profiles, scope settings, scope bookkeeping and the plugin/marketplace install list as Postgres rows
2
+ // keyed by person (002-fleet.sql). Reads stay synchronous over a per-process cache of one snapshot per person; a write is
3
+ // asynchronous: rows first, then the cache is reloaded from the rows. A failed write throws and leaves the cache.
4
+ // Two transports apply the same declarative ops: `applyOpsToRows` (a process that holds the database) and `PUT /v1/settings`
5
+ // (a CLI or viewer process, installed by `loadPersonSettingsViaApi`). Project-scope settings are files and never come here.
6
+ import { createHash } from 'node:crypto';
7
+ import { PersonOutsideRequestError, PersonUnresolvedError, personId } from '../canvas/person-state.js';
8
+ export class SettingsRevisionConflictError extends Error {
9
+ constructor() { super('settings changed since the supplied revision'); this.name = 'SettingsRevisionConflictError'; }
10
+ }
11
+ export class PersonSettingsNotLoadedError extends Error {
12
+ constructor(person) { super(`settings for ${person} are not loaded in this process`); this.name = 'PersonSettingsNotLoadedError'; }
13
+ }
14
+ const BLOCKS = ['plugins', 'marketplaces'];
15
+ const caches = new Map();
16
+ export function revisionOf(raw) {
17
+ return raw === undefined ? 'absent' : createHash('sha256').update(JSON.stringify(raw)).digest('hex');
18
+ }
19
+ // ------------------------------------------------------------------ cache
20
+ export function installPersonSnapshot(person, snapshot) { caches.set(person, snapshot); }
21
+ export function heldPersons() { return [...caches.keys()]; }
22
+ export function dropPersonSnapshots() { caches.clear(); }
23
+ const emptySnapshot = () => ({ profiles: [], defaults: {}, settings: {}, state: {} });
24
+ let prelude;
25
+ /** Before the install's person is resolved (a Mac's first boot reads its config to resolve it), reads answer from this snapshot of
26
+ * the old local store; `loadPersonSettings` replaces it once the person is known. */
27
+ export function installPreludeSnapshot(snapshot) { prelude = snapshot; }
28
+ /** The current person's snapshot. A miss is an error: a person whose rows exist never reads as defaults. A cloud role outside a
29
+ * request has no person and no install settings: it reads defaults. */
30
+ export function currentSnapshot() {
31
+ let person;
32
+ try {
33
+ person = personId();
34
+ }
35
+ catch (error) {
36
+ if (error instanceof PersonOutsideRequestError)
37
+ return emptySnapshot();
38
+ // Resolving the install's person reads the user config (`runtime.issuer`) before any person exists: a process with no
39
+ // prelude and no person holds no settings, so it reads defaults, as a cloud role outside a request does.
40
+ if (error instanceof PersonUnresolvedError)
41
+ return prelude ?? emptySnapshot();
42
+ throw error;
43
+ }
44
+ const held = caches.get(person);
45
+ if (held !== undefined)
46
+ return held;
47
+ if (prelude !== undefined)
48
+ return prelude;
49
+ throw new PersonSettingsNotLoadedError(person);
50
+ }
51
+ export function personHasSnapshot() {
52
+ try {
53
+ return caches.has(personId());
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ /** The default transport and reloader are the database's (`person-rows.ts` registers them in a process that holds one); a CLI or viewer
60
+ * process installs the `/v1/settings` pair. Neither being installed is a wiring error. */
61
+ let transport = async () => { throw new Error('no settings transport installed in this process'); };
62
+ export function setSettingsTransport(next) { transport = next; }
63
+ /** Compute ops from the cached snapshot and send them; on a revision conflict reload (the transport returned or the rows hold a newer
64
+ * snapshot) and compute again, up to three times. A failed write throws and leaves the cache as it was. */
65
+ export async function writeSettings(compute) {
66
+ const person = personId();
67
+ for (let attempt = 0;; attempt++) {
68
+ const ops = compute(currentSnapshot());
69
+ if (ops.length === 0)
70
+ return currentSnapshot();
71
+ try {
72
+ const next = await transport(person, ops);
73
+ caches.set(person, next);
74
+ return next;
75
+ }
76
+ catch (error) {
77
+ if (!(error instanceof SettingsRevisionConflictError) || attempt >= 2)
78
+ throw error;
79
+ await reloadViaTransport(person);
80
+ }
81
+ }
82
+ }
83
+ export function setPersonSnapshot(person, snapshot) { caches.set(person, snapshot); prelude = undefined; }
84
+ let reloader;
85
+ export function setSettingsReloader(next) { reloader = next; }
86
+ async function reloadViaTransport(person) {
87
+ caches.set(person, await (reloader ?? (async () => { throw new Error('no settings reloader installed in this process'); }))(person));
88
+ }
89
+ // ------------------------------------------------------------------ synchronous readers over the cache
90
+ export function ownerKeyOf(profileId) { return profileId === undefined ? 'user' : `profile:${profileId}`; }
91
+ export function readCachedSettings(owner) {
92
+ const raw = currentSnapshot().settings[owner];
93
+ return { settings: raw === undefined ? null : structuredClone(raw), revision: revisionOf(raw) };
94
+ }
95
+ export function readCachedState(owner) {
96
+ const st = currentSnapshot().state[owner];
97
+ return st === undefined ? { marketplaces: {}, plugins: {}, activeCanvas: null } : structuredClone(st);
98
+ }
99
+ export { BLOCKS as SETTINGS_ROW_BLOCKS };
100
+ // ------------------------------------------------------------------ synchronous profile readers over the cache
101
+ export function listCachedProfiles() { return structuredClone(currentSnapshot().profiles); }
102
+ export function getCachedProfile(profileId) {
103
+ const found = currentSnapshot().profiles.find((p) => p.profileId === profileId);
104
+ return found === undefined ? null : structuredClone(found);
105
+ }
106
+ export function cachedProfilePaused(profileId) {
107
+ return currentSnapshot().profiles.find((p) => p.profileId === profileId)?.manifest.paused_at != null;
108
+ }
109
+ export function cachedPausedProfileIds() {
110
+ return new Set(currentSnapshot().profiles.filter((p) => p.manifest.paused_at !== null).map((p) => p.profileId));
111
+ }
112
+ export function cachedDefaults() { return { ...currentSnapshot().defaults }; }
@@ -469,7 +469,7 @@ export const CONFIG_SETTINGS_COVERAGE = {
469
469
  remoteCanvas: { surface: 'specialized', owner: 'canvas config commands' },
470
470
  api: { surface: 'specialized', owner: 'sys connect' },
471
471
  paths: { surface: 'specialized', owner: 'runtime layout' },
472
- ledger: { surface: 'specialized', owner: 'money ledger (take_pct)' },
472
+ ledger: { surface: 'specialized', owner: 'money ledger (take_pct, sandbox rates)' },
473
473
  store: { surface: 'specialized', owner: 'daemon conversation store' },
474
474
  runtime: { surface: 'specialized', owner: 'app-facing listener' },
475
475
  spawnEnv: { surface: 'specialized', owner: 'spawn environment allow-list' },
@@ -1,35 +1,72 @@
1
1
  import type { BrokerExtensionNodeDTO } from '../../api/dto/broker-ops.js';
2
2
  /** Node-owned mutable fields in extensionNode's projection. Keep this list and
3
3
  * the typed cache view together; object/worktree-owned fields are never cached. */
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"];
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", "situational_card"];
5
5
  type ProjectedNodeKey<C extends string> = C extends 'review_binding_json' ? 'review_binding' : C extends 'persona_ack_json' ? 'persona_ack' : C;
6
- export type BrokerSignalCachedNode = Pick<BrokerExtensionNodeDTO, Extract<ProjectedNodeKey<(typeof BROKER_SIGNAL_NODE_COLUMNS)[number]>, keyof BrokerExtensionNodeDTO>>;
6
+ export type BrokerSignalCachedNode = Pick<BrokerExtensionNodeDTO, Extract<ProjectedNodeKey<Exclude<(typeof BROKER_SIGNAL_NODE_COLUMNS)[number], 'situational_card'>>, keyof BrokerExtensionNodeDTO>>;
7
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. */
8
+ * appear in `crtr_sig_node_upd` (002-fleet.sql recreates it with `situational_card`); keep the two lists together. */
9
9
  export declare function cachedBrokerNodeFields(node: BrokerExtensionNodeDTO): BrokerSignalCachedNode;
10
- export type BrokerSignalKind = 'mail' | 'node';
11
- export type BrokerSignalSubscriber = (nodeId: string, kind: BrokerSignalKind) => void;
12
- /** Hints, not delivery state: a rolled-back write may mark a node. The consumer
13
- * always re-reads durable state. Pending marks are bounded by distinct node ids. */
10
+ /** What a `crtr_signal` notification names. `mail` and `node` carry a node id; the others name the row that changed
11
+ * (D39): a grant id, run or node id, structured request, queue, limit, or environment object. `fault_retry` carries a node id: the
12
+ * fault-retry backstop publishes it in the commit that claims the nudge, and the API process holding that node's stream turns it
13
+ * into a `fault-retry` line. The writers fire them (the
14
+ * schema's triggers, or the unit that owns the writer); the bus only delivers. */
15
+ export declare const BROKER_SIGNAL_KINDS: readonly ["mail", "node", "grant", "run_event", "report", "structured_request", "queue", "spend_limits", "environment_image", "fault_retry", "settings"];
16
+ export type BrokerSignalKind = (typeof BROKER_SIGNAL_KINDS)[number];
17
+ export type BrokerSignalSubscriber = (id: string, kind: BrokerSignalKind) => void;
18
+ export interface BrokerSignalSubscription {
19
+ /** The kinds this subscriber hears. Default `mail` and `node`, the two a node's broker stream reads. */
20
+ kinds?: readonly BrokerSignalKind[];
21
+ /** Called each time the listen connection starts or restarts (LISTEN is active). Notifications sent while it was down
22
+ * are lost, so a subscriber re-reads its durable state here; that is its catch-up. A subscriber added while the bus
23
+ * is already listening is called once, soon after subscribing. */
24
+ onListenStart?: () => void;
25
+ }
26
+ export interface BrokerSignalBusOptions {
27
+ /** How often the listen connection is checked with `SELECT 1`. Default 30 s. */
28
+ checkIntervalMs?: number;
29
+ /** How long a check may take before the connection counts as lost. Default 10 s. */
30
+ checkTimeoutMs?: number;
31
+ /** Delay before a lost listen connection reconnects. Default 1 s. */
32
+ reconnectMs?: number;
33
+ }
34
+ /** Hints, not delivery state: a rolled-back write may mark an id. The consumer always re-reads durable state. Pending
35
+ * marks are bounded by distinct (kind, id) pairs and delivered once per flush. */
14
36
  export declare class BrokerSignalBus {
15
37
  private readonly dirty;
16
38
  private readonly subscribers;
17
39
  private scheduled;
18
- subscribe(listener: BrokerSignalSubscriber): Promise<() => void>;
40
+ private readonly checkIntervalMs;
41
+ private readonly checkTimeoutMs;
42
+ private readonly reconnectMs;
43
+ constructor(options?: BrokerSignalBusOptions);
44
+ subscribe(listener: BrokerSignalSubscriber, subscription?: BrokerSignalSubscription): Promise<() => void>;
19
45
  markMail(nodeId: string): void;
20
46
  markNode(nodeId: string): void;
47
+ /** Hint any kind, as a notification of that kind would. */
48
+ markSignal(kind: BrokerSignalKind, id: string): void;
21
49
  private mark;
22
50
  private flush;
51
+ private callListenStart;
23
52
  /** 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. */
53
+ * triggers publish `<kind>:<id>` at commit). Resolves once LISTEN is active, so a write committed after it returns is
54
+ * heard. A lost connection (an error, an end, or a failed `SELECT 1` check) reconnects with a fixed delay and calls every
55
+ * subscriber's `onListenStart` once LISTEN is active again. Signals sent while it was down are lost, which is safe because
56
+ * they are hints and every consumer re-reads durable state, at `onListenStart` and otherwise. */
27
57
  listen(url: string): Promise<void>;
28
58
  private listener;
29
59
  private listening;
30
60
  private url;
31
61
  private reconnect;
62
+ private check;
32
63
  private connectListener;
64
+ /** `SELECT 1` on the listen connection every interval. A dead TCP connection (a dropped route, a failover) raises no
65
+ * error by itself, so a failed or unanswered check is what tells the bus the connection is lost. */
66
+ private startChecks;
67
+ private stopChecks;
68
+ private dropListener;
69
+ private scheduleReconnect;
33
70
  /** Stop listening and drop pending marks. The returned promise resolves once the connection has closed. */
34
71
  close(): Promise<void>;
35
72
  }
@@ -4,55 +4,86 @@ import pg from 'pg';
4
4
  export const BROKER_SIGNAL_NODE_COLUMNS = [
5
5
  'description', 'title', 'icon', 'mode', 'lifecycle', 'status', 'cwd',
6
6
  'parent', 'fork_from', 'profile_id', 'review_binding_json',
7
- 'persona_ack_json', 'intent',
7
+ 'persona_ack_json', 'intent', 'situational_card',
8
8
  ];
9
9
  const nodeField = (column) => column === 'review_binding_json' ? 'review_binding' : column === 'persona_ack_json' ? 'persona_ack' : column;
10
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
+ * appear in `crtr_sig_node_upd` (002-fleet.sql recreates it with `situational_card`); keep the two lists together. */
12
12
  export function cachedBrokerNodeFields(node) {
13
13
  const picked = {};
14
14
  for (const column of BROKER_SIGNAL_NODE_COLUMNS) {
15
+ if (column === 'situational_card')
16
+ continue;
15
17
  const field = nodeField(column);
16
18
  picked[field] = node[field];
17
19
  }
18
20
  return picked;
19
21
  }
20
- /** Hints, not delivery state: a rolled-back write may mark a node. The consumer
21
- * always re-reads durable state. Pending marks are bounded by distinct node ids. */
22
+ /** What a `crtr_signal` notification names. `mail` and `node` carry a node id; the others name the row that changed
23
+ * (D39): a grant id, run or node id, structured request, queue, limit, or environment object. `fault_retry` carries a node id: the
24
+ * fault-retry backstop publishes it in the commit that claims the nudge, and the API process holding that node's stream turns it
25
+ * into a `fault-retry` line. The writers fire them (the
26
+ * schema's triggers, or the unit that owns the writer); the bus only delivers. */
27
+ export const BROKER_SIGNAL_KINDS = ['mail', 'node', 'grant', 'run_event', 'report', 'structured_request', 'queue', 'spend_limits', 'environment_image', 'fault_retry', 'settings'];
28
+ const KNOWN_KINDS = new Set(BROKER_SIGNAL_KINDS);
29
+ /** Hints, not delivery state: a rolled-back write may mark an id. The consumer always re-reads durable state. Pending
30
+ * marks are bounded by distinct (kind, id) pairs and delivered once per flush. */
22
31
  export class BrokerSignalBus {
23
32
  dirty = new Map();
24
33
  subscribers = new Set();
25
34
  scheduled;
26
- async subscribe(listener) {
27
- this.subscribers.add(listener);
28
- return () => { this.subscribers.delete(listener); };
35
+ checkIntervalMs;
36
+ checkTimeoutMs;
37
+ reconnectMs;
38
+ constructor(options = {}) {
39
+ this.checkIntervalMs = options.checkIntervalMs ?? 30_000;
40
+ this.checkTimeoutMs = options.checkTimeoutMs ?? 10_000;
41
+ this.reconnectMs = options.reconnectMs ?? 1000;
29
42
  }
30
- markMail(nodeId) { this.mark(nodeId, 1); }
31
- markNode(nodeId) { this.mark(nodeId, 2); }
32
- mark(nodeId, flag) {
33
- this.dirty.set(nodeId, (this.dirty.get(nodeId) ?? 0) | flag);
43
+ async subscribe(listener, subscription = {}) {
44
+ const subscriber = { listener, kinds: new Set(subscription.kinds ?? ['mail', 'node']), onListenStart: subscription.onListenStart };
45
+ this.subscribers.add(subscriber);
46
+ if (this.listener !== undefined && subscriber.onListenStart !== undefined)
47
+ queueMicrotask(() => { if (this.subscribers.has(subscriber))
48
+ this.callListenStart(subscriber); });
49
+ return () => { this.subscribers.delete(subscriber); };
50
+ }
51
+ markMail(nodeId) { this.mark('mail', nodeId); }
52
+ markNode(nodeId) { this.mark('node', nodeId); }
53
+ /** Hint any kind, as a notification of that kind would. */
54
+ markSignal(kind, id) { this.mark(kind, id); }
55
+ mark(kind, id) {
56
+ const key = `${kind}:${id}`;
57
+ if (!this.dirty.has(key))
58
+ this.dirty.set(key, { id, kind });
34
59
  this.scheduled ??= setImmediate(() => this.flush());
35
60
  }
36
61
  flush() {
37
62
  this.scheduled = undefined;
38
- const batch = [...this.dirty];
63
+ const batch = [...this.dirty.values()];
39
64
  this.dirty.clear();
40
- for (const [id, flags] of batch) {
41
- for (const listener of this.subscribers) {
65
+ for (const { id, kind } of batch) {
66
+ for (const subscriber of this.subscribers) {
67
+ if (!subscriber.kinds.has(kind))
68
+ continue;
42
69
  try {
43
- if (flags & 1)
44
- listener(id, 'mail');
45
- if (flags & 2)
46
- listener(id, 'node');
70
+ subscriber.listener(id, kind);
47
71
  }
48
72
  catch { /* a subscriber never interrupts another subscriber */ }
49
73
  }
50
74
  }
51
75
  }
76
+ callListenStart(subscriber) {
77
+ try {
78
+ subscriber.onListenStart?.();
79
+ }
80
+ catch { /* a subscriber never interrupts another subscriber */ }
81
+ }
52
82
  /** 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. */
83
+ * triggers publish `<kind>:<id>` at commit). Resolves once LISTEN is active, so a write committed after it returns is
84
+ * heard. A lost connection (an error, an end, or a failed `SELECT 1` check) reconnects with a fixed delay and calls every
85
+ * subscriber's `onListenStart` once LISTEN is active again. Signals sent while it was down are lost, which is safe because
86
+ * they are hints and every consumer re-reads durable state, at `onListenStart` and otherwise. */
56
87
  async listen(url) {
57
88
  if (this.listener !== undefined || this.listening !== undefined)
58
89
  return;
@@ -64,8 +95,9 @@ export class BrokerSignalBus {
64
95
  listening;
65
96
  url;
66
97
  reconnect;
98
+ check;
67
99
  async connectListener() {
68
- const client = new pg.Client({ connectionString: this.url, application_name: `crtr-signal-${process.pid}` });
100
+ const client = new pg.Client({ connectionString: this.url, application_name: `crtr-signal-${process.pid}`, keepAlive: true });
69
101
  client.on('notification', message => {
70
102
  // A hint must never cause an exception for an unexpected payload.
71
103
  const payload = message.payload ?? '';
@@ -73,39 +105,74 @@ export class BrokerSignalBus {
73
105
  if (colon < 0)
74
106
  return;
75
107
  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);
108
+ if (KNOWN_KINDS.has(kind))
109
+ this.mark(kind, payload.slice(colon + 1));
81
110
  });
82
111
  const lost = () => {
83
112
  if (this.listener !== client)
84
113
  return;
85
- this.listener = undefined;
114
+ this.dropListener(client);
115
+ this.scheduleReconnect();
116
+ };
117
+ client.on('error', lost);
118
+ client.on('end', lost);
119
+ try {
120
+ await client.connect();
121
+ await client.query('LISTEN crtr_signal');
122
+ }
123
+ catch (error) {
86
124
  client.removeAllListeners();
87
125
  client.on('error', () => { });
88
126
  client.end().catch(() => { });
89
- if (this.url === undefined)
127
+ throw error;
128
+ }
129
+ if (this.url === undefined) { // closed while connecting
130
+ client.removeAllListeners();
131
+ client.on('error', () => { });
132
+ client.end().catch(() => { });
133
+ return;
134
+ }
135
+ this.listener = client;
136
+ this.startChecks(client, lost);
137
+ for (const subscriber of [...this.subscribers])
138
+ this.callListenStart(subscriber);
139
+ }
140
+ /** `SELECT 1` on the listen connection every interval. A dead TCP connection (a dropped route, a failover) raises no
141
+ * error by itself, so a failed or unanswered check is what tells the bus the connection is lost. */
142
+ startChecks(client, lost) {
143
+ this.stopChecks();
144
+ this.check = setInterval(() => {
145
+ if (this.listener !== client)
90
146
  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)
147
+ let timer;
148
+ const unanswered = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('listen connection check timed out')), this.checkTimeoutMs); });
149
+ Promise.race([client.query('SELECT 1'), unanswered])
150
+ .catch(() => lost())
151
+ .finally(() => clearTimeout(timer));
152
+ }, this.checkIntervalMs);
153
+ this.check.unref();
154
+ }
155
+ stopChecks() {
156
+ if (this.check !== undefined)
157
+ clearInterval(this.check);
158
+ this.check = undefined;
159
+ }
160
+ dropListener(client) {
161
+ this.listener = undefined;
162
+ this.stopChecks();
163
+ client.removeAllListeners();
164
+ client.on('error', () => { });
165
+ client.end().catch(() => { });
166
+ }
167
+ scheduleReconnect() {
168
+ if (this.url === undefined || this.reconnect !== undefined)
169
+ return;
170
+ this.reconnect = setTimeout(() => {
171
+ this.reconnect = undefined;
172
+ if (this.url === undefined)
98
173
  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;
174
+ this.listening = this.connectListener().catch(() => this.scheduleReconnect()).finally(() => { this.listening = undefined; });
175
+ }, this.reconnectMs);
109
176
  }
110
177
  /** Stop listening and drop pending marks. The returned promise resolves once the connection has closed. */
111
178
  close() {
@@ -113,6 +180,7 @@ export class BrokerSignalBus {
113
180
  if (this.reconnect !== undefined)
114
181
  clearTimeout(this.reconnect);
115
182
  this.reconnect = undefined;
183
+ this.stopChecks();
116
184
  const listener = this.listener;
117
185
  this.listener = undefined;
118
186
  if (this.scheduled !== undefined)
@@ -123,6 +191,8 @@ export class BrokerSignalBus {
123
191
  if (listener === undefined)
124
192
  return Promise.resolve();
125
193
  listener.removeAllListeners('notification');
194
+ listener.removeAllListeners('error');
195
+ listener.on('error', () => { });
126
196
  return listener.end().catch(() => undefined);
127
197
  }
128
198
  }
@@ -83,7 +83,7 @@ function renderPush(entry, push) {
83
83
  case 'messaged': return `${actor} messaged ${obj}.`;
84
84
  case 'sent': return `${actor} sent a message as ${obj}.`;
85
85
  case 'configured': return `${actor} changed the settings of ${obj}${typeof a['changed'] === 'string' ? `: ${a['changed']}` : ''}.`;
86
- case 'exited': return `${obj[0].toUpperCase()}${obj.slice(1)} exited with code ${String(a['exit-code'])}. Log: ${String(a['log-path'])}`;
86
+ case 'exited': return `${obj[0].toUpperCase()}${obj.slice(1)} exited with code ${String(a['exit-code'])}. Output: ${String(a['output-key'])}`;
87
87
  case 'silent': {
88
88
  const files = typeof a['files'] === 'string' ? a['files'] : '';
89
89
  return `${obj[0].toUpperCase()}${obj.slice(1)} has been silent for ${String(a['silent-for'])}: its processes used almost no CPU and none of the files they hold open for writing changed. It may be hung. Watched: ${files === '' ? 'the job log only' : files}. Inspect those files; stop it with \`crtr node bash kill ${push.object.id}\`; or, if it is meant to sit idle, stop these messages with \`crtr node bash quiet ${push.object.id}\`.`;
@@ -94,7 +94,7 @@ function renderPush(entry, push) {
94
94
  const claim = a['reason'] === 'deadline'
95
95
  ? ` It was canceled at its deadline; claim it with \`crtr node bash extend ${push.object.id} --for <duration>\` next time.`
96
96
  : '';
97
- return `${obj[0].toUpperCase()}${obj.slice(1)} was killed${by}${why}.${claim} Log: ${String(a['log-path'])}`;
97
+ return `${obj[0].toUpperCase()}${obj.slice(1)} was killed${by}${why}.${claim} Output: ${String(a['output-key'])}`;
98
98
  }
99
99
  default: return entry.label;
100
100
  }