@sigrun/ext-gateway 0.0.0-stage → 0.1.2

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 (82) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -2
  4. package/dist/access/codes.d.ts +18 -0
  5. package/dist/access/codes.js +34 -0
  6. package/dist/access/link-codes.d.ts +33 -0
  7. package/dist/access/link-codes.js +63 -0
  8. package/dist/access/loop-guard.d.ts +17 -0
  9. package/dist/access/loop-guard.js +42 -0
  10. package/dist/access/pairing.d.ts +74 -0
  11. package/dist/access/pairing.js +131 -0
  12. package/dist/commands/chat.d.ts +66 -0
  13. package/dist/commands/chat.js +371 -0
  14. package/dist/commands/link.d.ts +63 -0
  15. package/dist/commands/link.js +77 -0
  16. package/dist/commands/pair.d.ts +72 -0
  17. package/dist/commands/pair.js +110 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +219 -0
  20. package/dist/engine.d.ts +108 -0
  21. package/dist/engine.js +645 -0
  22. package/dist/inbound/albums.d.ts +39 -0
  23. package/dist/inbound/albums.js +62 -0
  24. package/dist/inbound/envelope.d.ts +19 -0
  25. package/dist/inbound/envelope.js +39 -0
  26. package/dist/inbound/groups.d.ts +29 -0
  27. package/dist/inbound/groups.js +55 -0
  28. package/dist/inbound/media.d.ts +20 -0
  29. package/dist/inbound/media.js +88 -0
  30. package/dist/inbound/pipeline.d.ts +144 -0
  31. package/dist/inbound/pipeline.js +600 -0
  32. package/dist/inbound/tokens.d.ts +38 -0
  33. package/dist/inbound/tokens.js +42 -0
  34. package/dist/index.d.ts +86 -0
  35. package/dist/index.js +136 -0
  36. package/dist/interactions/answer.d.ts +42 -0
  37. package/dist/interactions/answer.js +112 -0
  38. package/dist/interactions/chat.d.ts +28 -0
  39. package/dist/interactions/chat.js +199 -0
  40. package/dist/interactions/prompts.d.ts +68 -0
  41. package/dist/interactions/prompts.js +246 -0
  42. package/dist/interactions/receipts.d.ts +7 -0
  43. package/dist/interactions/receipts.js +69 -0
  44. package/dist/interactions/render.d.ts +39 -0
  45. package/dist/interactions/render.js +122 -0
  46. package/dist/jobs.d.ts +34 -0
  47. package/dist/jobs.js +72 -0
  48. package/dist/log.d.ts +21 -0
  49. package/dist/log.js +42 -0
  50. package/dist/notify.d.ts +21 -0
  51. package/dist/notify.js +64 -0
  52. package/dist/outbound/delivery.d.ts +131 -0
  53. package/dist/outbound/delivery.js +404 -0
  54. package/dist/outbound/markdown.d.ts +36 -0
  55. package/dist/outbound/markdown.js +286 -0
  56. package/dist/outbound/render.d.ts +24 -0
  57. package/dist/outbound/render.js +65 -0
  58. package/dist/outbound/settled.d.ts +81 -0
  59. package/dist/outbound/settled.js +271 -0
  60. package/dist/outbound/stream.d.ts +38 -0
  61. package/dist/outbound/stream.js +205 -0
  62. package/dist/section.d.ts +16 -0
  63. package/dist/section.js +55 -0
  64. package/dist/session-meta.d.ts +18 -0
  65. package/dist/session-meta.js +39 -0
  66. package/dist/state/bindings.d.ts +55 -0
  67. package/dist/state/bindings.js +128 -0
  68. package/dist/state/identities.d.ts +65 -0
  69. package/dist/state/identities.js +135 -0
  70. package/dist/state/json-file.d.ts +64 -0
  71. package/dist/state/json-file.js +156 -0
  72. package/dist/state/ledger.d.ts +176 -0
  73. package/dist/state/ledger.js +256 -0
  74. package/dist/state/seen.d.ts +41 -0
  75. package/dist/state/seen.js +77 -0
  76. package/dist/status-route.d.ts +32 -0
  77. package/dist/status-route.js +26 -0
  78. package/dist/supervisor.d.ts +153 -0
  79. package/dist/supervisor.js +273 -0
  80. package/dist/tools.d.ts +27 -0
  81. package/dist/tools.js +207 -0
  82. package/package.json +54 -5
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The binding store (spec §8.3): one row per chat, key → the
3
+ * conversation the chat is bound to. Persisted at
4
+ * `<stateDir>/gateway/bindings.json` through {@link JsonFile} (0600,
5
+ * atomic, serialized updates).
6
+ *
7
+ * `account` is always `default` in v1 (the multi-account slot the key
8
+ * reserves so keys never need migrating); a thread rides its own key
9
+ * segment. `previous` records every conversation the chat was bound to
10
+ * before the current one (`/new` and rebinds append) — the outbound
11
+ * side never reads it today, but `/status` (Task 7) shows it and the
12
+ * array is the honest history a rebind loses otherwise.
13
+ */
14
+ import type { ChatRef } from "@sigrun/sigrun/extension";
15
+ /** One live binding: the chat → conversation edge. */
16
+ export interface Binding {
17
+ readonly conversationId: number;
18
+ readonly chat: ChatRef;
19
+ /** Clock ms when the CURRENT conversation was bound. */
20
+ readonly boundAt: number;
21
+ /** Earlier conversation ids this chat was bound to, oldest first. */
22
+ readonly previous: readonly number[];
23
+ /** Set when delivery classified the chat gone (`chat_gone`). */
24
+ readonly dead?: boolean;
25
+ }
26
+ /** The binding key (§8.3): `<channel>:default:<chatType>:<chatId>[:threadId]`. */
27
+ export declare function bindingKey(channel: string, chat: ChatRef): string;
28
+ /**
29
+ * The chat → conversation bindings, one instance per engine. `bind` is
30
+ * the only mutator that grows `previous`; every method persists through
31
+ * the serialized JsonFile queue, so concurrent receives on the same
32
+ * chat never lose a rebind.
33
+ */
34
+ export declare class BindingStore {
35
+ private readonly file;
36
+ /** The write-side clock (tests inject a fake; default real). */
37
+ private readonly now;
38
+ constructor(path: string, opts?: {
39
+ readonly now?: () => number;
40
+ });
41
+ /** The live binding for `key`, or undefined when unbound. */
42
+ get(key: string): Binding | undefined;
43
+ /** One file read's binding rows, keyed for target authorization. */
44
+ entries(): Readonly<Record<string, Binding>>;
45
+ /** Every live key bound to `conversationId` (the delivery fan-out read). */
46
+ keysOf(conversationId: number): readonly string[];
47
+ /** Bind `key` to `conversationId`, recording any prior binding in `previous`. */
48
+ bind(key: string, conversationId: number, chat: ChatRef): Promise<void>;
49
+ /** Remove the binding entirely (the access-revoked path). */
50
+ unbind(key: string): Promise<void>;
51
+ /** Mark the binding dead (`chat_gone` delivery): kept, never delivered to. */
52
+ markDead(key: string): Promise<void>;
53
+ /** Clear the dead flag (a new inbound event proves the chat alive). */
54
+ clearDead(key: string): Promise<void>;
55
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The binding store (spec §8.3): one row per chat, key → the
3
+ * conversation the chat is bound to. Persisted at
4
+ * `<stateDir>/gateway/bindings.json` through {@link JsonFile} (0600,
5
+ * atomic, serialized updates).
6
+ *
7
+ * `account` is always `default` in v1 (the multi-account slot the key
8
+ * reserves so keys never need migrating); a thread rides its own key
9
+ * segment. `previous` records every conversation the chat was bound to
10
+ * before the current one (`/new` and rebinds append) — the outbound
11
+ * side never reads it today, but `/status` (Task 7) shows it and the
12
+ * array is the honest history a rebind loses otherwise.
13
+ */
14
+ import { JsonFile } from "./json-file.js";
15
+ /** The binding key (§8.3): `<channel>:default:<chatType>:<chatId>[:threadId]`. */
16
+ export function bindingKey(channel, chat) {
17
+ const base = `${channel}:default:${chat.chatType}:${chat.chatId}`;
18
+ return chat.threadId === undefined
19
+ ? base
20
+ : `${base}:${chat.threadId}`;
21
+ }
22
+ /**
23
+ * The chat → conversation bindings, one instance per engine. `bind` is
24
+ * the only mutator that grows `previous`; every method persists through
25
+ * the serialized JsonFile queue, so concurrent receives on the same
26
+ * chat never lose a rebind.
27
+ */
28
+ export class BindingStore {
29
+ file;
30
+ /** The write-side clock (tests inject a fake; default real). */
31
+ now;
32
+ constructor(path, opts = {}) {
33
+ this.file = new JsonFile(path, () => ({
34
+ bindings: {},
35
+ }));
36
+ this.now = opts.now ?? Date.now;
37
+ }
38
+ /** The live binding for `key`, or undefined when unbound. */
39
+ get(key) {
40
+ // Read the file synchronously per call. ONE instance is threaded
41
+ // engine-wide now, so a construction-time mirror COULD suffice — but
42
+ // the reads are cheap local JSON and the per-call read keeps the
43
+ // store honest against EXTERNAL writers (a crash-recovery e2e or an
44
+ // operator repairing bindings.json between calls) and against any
45
+ // future second instance. The mirror was REMOVED precisely because
46
+ // it went stale across instances; readSync is the fix that stays.
47
+ return toBinding(this.file.readSync().bindings[key]);
48
+ }
49
+ /** One file read's binding rows, keyed for target authorization. */
50
+ entries() {
51
+ return Object.fromEntries(Object.entries(this.file.readSync().bindings).map(([key, row]) => [
52
+ key,
53
+ toBinding(row),
54
+ ]));
55
+ }
56
+ /** Every live key bound to `conversationId` (the delivery fan-out read). */
57
+ keysOf(conversationId) {
58
+ const keys = [];
59
+ for (const [key, row] of Object.entries(this.file.readSync().bindings)) {
60
+ if (row.conversationId === conversationId)
61
+ keys.push(key);
62
+ }
63
+ return keys;
64
+ }
65
+ /** Bind `key` to `conversationId`, recording any prior binding in `previous`. */
66
+ async bind(key, conversationId, chat) {
67
+ await this.file.update((draft) => {
68
+ const prior = draft.bindings[key];
69
+ if (prior === undefined) {
70
+ draft.bindings[key] = {
71
+ conversationId,
72
+ chat,
73
+ boundAt: this.now(),
74
+ previous: [],
75
+ };
76
+ }
77
+ else {
78
+ draft.bindings[key] = {
79
+ conversationId,
80
+ chat,
81
+ boundAt: this.now(),
82
+ previous: [
83
+ ...prior.previous.filter((id) => id !== conversationId),
84
+ prior.conversationId,
85
+ ],
86
+ ...(prior.dead === undefined ? {} : { dead: prior.dead }),
87
+ };
88
+ }
89
+ });
90
+ }
91
+ /** Remove the binding entirely (the access-revoked path). */
92
+ async unbind(key) {
93
+ await this.file.update((draft) => {
94
+ delete draft.bindings[key];
95
+ });
96
+ }
97
+ /** Mark the binding dead (`chat_gone` delivery): kept, never delivered to. */
98
+ async markDead(key) {
99
+ await this.file.update((draft) => {
100
+ const row = draft.bindings[key];
101
+ if (row === undefined)
102
+ return;
103
+ draft.bindings[key] = { ...row, dead: true };
104
+ });
105
+ }
106
+ /** Clear the dead flag (a new inbound event proves the chat alive). */
107
+ async clearDead(key) {
108
+ await this.file.update((draft) => {
109
+ const row = draft.bindings[key];
110
+ if (row === undefined || row.dead === undefined)
111
+ return;
112
+ const { dead: _dead, ...rest } = row;
113
+ draft.bindings[key] = rest;
114
+ });
115
+ }
116
+ }
117
+ /** Narrow a stored row to the public binding shape. */
118
+ function toBinding(row) {
119
+ if (row === undefined)
120
+ return undefined;
121
+ return {
122
+ conversationId: row.conversationId,
123
+ chat: row.chat,
124
+ boundAt: row.boundAt,
125
+ previous: [...row.previous],
126
+ ...(row.dead === undefined ? {} : { dead: row.dead }),
127
+ };
128
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Sender identity resolution (spec §8.2): core owns the link table;
3
+ * this store owns only per-user home chats in `identities.json`.
4
+ *
5
+ * Resolution order for `userFor`: the channel's ALLOWLIST first, then
6
+ * `host.accounts.lookup("chat:<channel>", senderId)`. An allowlist hit
7
+ * deliberately performs no core lookup: it is the operator's pinned
8
+ * mapping, and any disagreeing core link remains inert.
9
+ */
10
+ import type { ChatRef, SigrunHost } from "@sigrun/sigrun/extension";
11
+ import type { ChannelConfig } from "../config.js";
12
+ import type { EngineLog } from "../log.js";
13
+ /** How a chat link came to be. */
14
+ export type LinkVia = "pairing" | "self";
15
+ /** Core's one provider-key convention for platform senders. */
16
+ export declare function chatProviderOf(channel: string): string;
17
+ /**
18
+ * Core admits only this channel-id shape inside a chat provider. This
19
+ * matches the loader's grammar exactly: every binding-key and target
20
+ * parse splits on ":", so a wider alphabet would mis-attribute chats.
21
+ */
22
+ export declare const CHANNEL_ID_PATTERN: RegExp;
23
+ /** The result of a `userFor` resolution. */
24
+ export interface ResolvedUser {
25
+ readonly user: string;
26
+ /** Whether the allowlist (not a link) produced the user. */
27
+ readonly via: "allow" | "link";
28
+ }
29
+ /** The result of a core-backed link attempt. */
30
+ export type LinkResult = {
31
+ readonly linked: true;
32
+ } | {
33
+ readonly linked: false;
34
+ readonly reason: "conflict";
35
+ };
36
+ /** Core-backed sender resolution and engine-owned home-chat persistence. */
37
+ export declare class IdentityStore {
38
+ private readonly host;
39
+ private readonly file;
40
+ private homes;
41
+ constructor(host: SigrunHost, path: string, opts?: {
42
+ readonly log?: EngineLog;
43
+ });
44
+ /** Resolve the allowlist first; only an unlisted sender reads core. */
45
+ userFor(config: ChannelConfig, channel: string, senderId: string): Promise<ResolvedUser | undefined>;
46
+ /** Link through core; only core's conflict refusal maps to a result. */
47
+ link(channel: string, senderId: string, user: string, via: LinkVia, dmChat: ChatRef): Promise<LinkResult>;
48
+ /** Core removes links before userDeleted; the engine clears homes only. */
49
+ forgetHomes(user: string): Promise<void>;
50
+ /** The user's home chats, one per channel. */
51
+ homesOf(user: string): readonly {
52
+ channel: string;
53
+ chat: ChatRef;
54
+ }[];
55
+ /** Install an absent home without an await before the mirror update,
56
+ * preserving admission order. The durable write is queued behind the
57
+ * synchronous in-memory update; the caller owns its completion/error. */
58
+ ensureHome(user: string, channel: string, chat: ChatRef): Promise<void>;
59
+ /** Set (move) the user's home chat on `channel`. */
60
+ setHome(user: string, channel: string, chat: ChatRef): Promise<void>;
61
+ /** Refresh the home mirror from the written state (post-mutation). */
62
+ private reload;
63
+ /** Preserve an absent home without touching its durable file. */
64
+ private rememberHome;
65
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Sender identity resolution (spec §8.2): core owns the link table;
3
+ * this store owns only per-user home chats in `identities.json`.
4
+ *
5
+ * Resolution order for `userFor`: the channel's ALLOWLIST first, then
6
+ * `host.accounts.lookup("chat:<channel>", senderId)`. An allowlist hit
7
+ * deliberately performs no core lookup: it is the operator's pinned
8
+ * mapping, and any disagreeing core link remains inert.
9
+ */
10
+ import { JsonFile } from "./json-file.js";
11
+ /** Core's one provider-key convention for platform senders. */
12
+ export function chatProviderOf(channel) {
13
+ return `chat:${channel}`;
14
+ }
15
+ /**
16
+ * Core admits only this channel-id shape inside a chat provider. This
17
+ * matches the loader's grammar exactly: every binding-key and target
18
+ * parse splits on ":", so a wider alphabet would mis-attribute chats.
19
+ */
20
+ export const CHANNEL_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
21
+ /** Core-backed sender resolution and engine-owned home-chat persistence. */
22
+ export class IdentityStore {
23
+ host;
24
+ file;
25
+ homes = {};
26
+ constructor(host, path, opts = {}) {
27
+ this.host = host;
28
+ this.file = new JsonFile(path, () => ({ homes: {} }));
29
+ // Home reads are synchronous by contract; load the mirror before
30
+ // admitting work, as the other engine-owned JsonFile stores do.
31
+ const state = this.file.readSync();
32
+ this.homes = state.homes;
33
+ const legacyCount = Object.keys(state.links ?? {}).length;
34
+ if (legacyCount > 0) {
35
+ opts.log?.warn(`identities.json contains ${legacyCount} legacy links; those senders must pair or self-link again`);
36
+ }
37
+ }
38
+ /** Resolve the allowlist first; only an unlisted sender reads core. */
39
+ async userFor(config, channel, senderId) {
40
+ const allowed = config.allow.find((entry) => entry.sender === senderId)?.user;
41
+ if (allowed !== undefined)
42
+ return { user: allowed, via: "allow" };
43
+ const linked = await this.host.accounts.lookup(chatProviderOf(channel), senderId);
44
+ return linked === undefined ? undefined : { user: linked.user, via: "link" };
45
+ }
46
+ /** Link through core; only core's conflict refusal maps to a result. */
47
+ async link(channel, senderId, user, via, dmChat) {
48
+ try {
49
+ await this.host.accounts.link(chatProviderOf(channel), senderId, user, via);
50
+ }
51
+ catch (error) {
52
+ if (error instanceof Error &&
53
+ error.message.startsWith("account_link_conflict:")) {
54
+ return { linked: false, reason: "conflict" };
55
+ }
56
+ throw error;
57
+ }
58
+ try {
59
+ await this.file.update((draft) => {
60
+ delete draft.links;
61
+ // First link sets the home chat (a later /home moves it).
62
+ if (draft.homes[user]?.[channel] === undefined) {
63
+ const perChannel = draft.homes[user] ?? {};
64
+ perChannel[channel] = { chat: dmChat };
65
+ draft.homes[user] = perChannel;
66
+ }
67
+ }).then((state) => this.reload(state));
68
+ }
69
+ catch (error) {
70
+ // Core has already durably linked the sender. Preserve this
71
+ // absent home in memory before rethrowing so homesOf and a
72
+ // same-user retry do not pretend the first link never happened;
73
+ // the next successful write persists it.
74
+ this.rememberHome(user, channel, dmChat);
75
+ throw error;
76
+ }
77
+ return { linked: true };
78
+ }
79
+ /** Core removes links before userDeleted; the engine clears homes only. */
80
+ async forgetHomes(user) {
81
+ await this.file.update((draft) => {
82
+ delete draft.links;
83
+ delete draft.homes[user];
84
+ }).then((state) => this.reload(state));
85
+ }
86
+ /** The user's home chats, one per channel. */
87
+ homesOf(user) {
88
+ const perChannel = this.homes[user];
89
+ if (perChannel === undefined)
90
+ return [];
91
+ return Object.entries(perChannel).map(([channel, row]) => ({
92
+ channel,
93
+ chat: row.chat,
94
+ }));
95
+ }
96
+ /** Install an absent home without an await before the mirror update,
97
+ * preserving admission order. The durable write is queued behind the
98
+ * synchronous in-memory update; the caller owns its completion/error. */
99
+ ensureHome(user, channel, chat) {
100
+ if (this.homes[user]?.[channel] !== undefined)
101
+ return Promise.resolve();
102
+ const perChannel = this.homes[user] ?? {};
103
+ perChannel[channel] = { chat };
104
+ this.homes[user] = perChannel;
105
+ return this.file.update((draft) => {
106
+ delete draft.links;
107
+ if (draft.homes[user]?.[channel] !== undefined)
108
+ return;
109
+ const stored = draft.homes[user] ?? {};
110
+ stored[channel] = { chat };
111
+ draft.homes[user] = stored;
112
+ }).then(() => undefined);
113
+ }
114
+ /** Set (move) the user's home chat on `channel`. */
115
+ async setHome(user, channel, chat) {
116
+ await this.file.update((draft) => {
117
+ delete draft.links;
118
+ const perChannel = draft.homes[user] ?? {};
119
+ perChannel[channel] = { chat };
120
+ draft.homes[user] = perChannel;
121
+ }).then((state) => this.reload(state));
122
+ }
123
+ /** Refresh the home mirror from the written state (post-mutation). */
124
+ reload(state) {
125
+ this.homes = state.homes;
126
+ }
127
+ /** Preserve an absent home without touching its durable file. */
128
+ rememberHome(user, channel, chat) {
129
+ if (this.homes[user]?.[channel] !== undefined)
130
+ return;
131
+ const perChannel = this.homes[user] ?? {};
132
+ perChannel[channel] = { chat };
133
+ this.homes[user] = perChannel;
134
+ }
135
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * JsonFile<T> — the engine's state-file primitive (spec §8.0: state
3
+ * lives in `<stateDir>/gateway/`, core never reads or writes it).
4
+ *
5
+ * - serialized updates: every `update` runs read → mutate → write inside
6
+ * ONE queue position, so concurrent updates interleave never (a
7
+ * lost-update on a state file would silently drop links/bindings);
8
+ * - atomic write: staged temp + rename in the same directory (a crash or
9
+ * concurrent reader sees the old complete file or the new complete
10
+ * file, never a half-written one);
11
+ * - mode 0600: state carries identity links, so group/other read is off;
12
+ * - malformed file THROWS naming the path — a corrupt state file is
13
+ * never silently reset by the next write (the sigrun stores contract).
14
+ */
15
+ /** A serialized JSON state file at `path`. */
16
+ export declare class JsonFile<T> {
17
+ private readonly path;
18
+ private readonly initial;
19
+ private queue;
20
+ constructor(path: string, initial: () => T);
21
+ /** Read the file (absent → the initial value; malformed → throws naming the path). */
22
+ read(): Promise<T>;
23
+ /**
24
+ * The synchronous read: the same semantics as {@link read} (absent →
25
+ * the initial value; malformed → throws naming the path) without the
26
+ * queue. Stores whose READ API is synchronous (the binding/identity
27
+ * reads the plan pins) load at construction this way — the queue is
28
+ * empty then, so nothing can interleave; a queued write's own state
29
+ * tracking refreshes the store's mirror afterward.
30
+ */
31
+ readSync(): T;
32
+ /**
33
+ * Read-modify-write under the serialization queue: the mutator runs
34
+ * on the CURRENT state, the result is written atomically, and the new
35
+ * state returns. A throwing mutator leaves the file untouched.
36
+ */
37
+ update(mutate: (draft: T) => void | Promise<void>): Promise<T>;
38
+ /**
39
+ * The SYNCHRONOUS read-modify-write, mirroring {@link readSync}'s
40
+ * contract for stores whose mutation API is synchronous by interface
41
+ * (the pairing queue: `request` returns its verdict immediately, so
42
+ * the durable write must land before the verdict — a crash between
43
+ * them would silently lose a pending request). The queue is bypassed
44
+ * (the callers own the ordering: sync mutations cannot interleave).
45
+ */
46
+ updateSync(mutate: (draft: T) => void): T;
47
+ /** Run `work` after every earlier queued operation (the serialization). */
48
+ private enqueue;
49
+ private readNow;
50
+ /**
51
+ * Atomic replace: stage to a fresh temp file in the target's directory
52
+ * (rename is same-filesystem atomic), stamp 0600 on the temp BEFORE
53
+ * the rename (chmod beats `writeFile`'s umask-masked `mode`), then
54
+ * rename over the target. The parent dir is created when absent —
55
+ * the engine's start creates the state dir, but a JsonFile handed a
56
+ * deeper path stays usable on a fresh deployment.
57
+ */
58
+ private writeNow;
59
+ /**
60
+ * The sync twin of {@link writeNow}: the SAME atomic staging (temp +
61
+ * rename, 0600 before the swap) through the blocking fs calls.
62
+ */
63
+ private writeNowSync;
64
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * JsonFile<T> — the engine's state-file primitive (spec §8.0: state
3
+ * lives in `<stateDir>/gateway/`, core never reads or writes it).
4
+ *
5
+ * - serialized updates: every `update` runs read → mutate → write inside
6
+ * ONE queue position, so concurrent updates interleave never (a
7
+ * lost-update on a state file would silently drop links/bindings);
8
+ * - atomic write: staged temp + rename in the same directory (a crash or
9
+ * concurrent reader sees the old complete file or the new complete
10
+ * file, never a half-written one);
11
+ * - mode 0600: state carries identity links, so group/other read is off;
12
+ * - malformed file THROWS naming the path — a corrupt state file is
13
+ * never silently reset by the next write (the sigrun stores contract).
14
+ */
15
+ import { randomUUID } from "node:crypto";
16
+ import { chmod, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
17
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
18
+ import { dirname } from "node:path";
19
+ /** A serialized JSON state file at `path`. */
20
+ export class JsonFile {
21
+ path;
22
+ initial;
23
+ queue = Promise.resolve();
24
+ constructor(path, initial) {
25
+ this.path = path;
26
+ this.initial = initial;
27
+ }
28
+ /** Read the file (absent → the initial value; malformed → throws naming the path). */
29
+ read() {
30
+ return this.enqueue(async () => this.readNow());
31
+ }
32
+ /**
33
+ * The synchronous read: the same semantics as {@link read} (absent →
34
+ * the initial value; malformed → throws naming the path) without the
35
+ * queue. Stores whose READ API is synchronous (the binding/identity
36
+ * reads the plan pins) load at construction this way — the queue is
37
+ * empty then, so nothing can interleave; a queued write's own state
38
+ * tracking refreshes the store's mirror afterward.
39
+ */
40
+ readSync() {
41
+ let text;
42
+ try {
43
+ text = readFileSync(this.path, "utf8");
44
+ }
45
+ catch (error) {
46
+ if (error.code === "ENOENT") {
47
+ return this.initial();
48
+ }
49
+ throw error;
50
+ }
51
+ try {
52
+ return JSON.parse(text);
53
+ }
54
+ catch (error) {
55
+ throw new Error(`gateway: malformed JSON in ${this.path}: ${String(error)}`, { cause: error });
56
+ }
57
+ }
58
+ /**
59
+ * Read-modify-write under the serialization queue: the mutator runs
60
+ * on the CURRENT state, the result is written atomically, and the new
61
+ * state returns. A throwing mutator leaves the file untouched.
62
+ */
63
+ update(mutate) {
64
+ return this.enqueue(async () => {
65
+ const draft = await this.readNow();
66
+ await mutate(draft);
67
+ await this.writeNow(draft);
68
+ return draft;
69
+ });
70
+ }
71
+ /**
72
+ * The SYNCHRONOUS read-modify-write, mirroring {@link readSync}'s
73
+ * contract for stores whose mutation API is synchronous by interface
74
+ * (the pairing queue: `request` returns its verdict immediately, so
75
+ * the durable write must land before the verdict — a crash between
76
+ * them would silently lose a pending request). The queue is bypassed
77
+ * (the callers own the ordering: sync mutations cannot interleave).
78
+ */
79
+ updateSync(mutate) {
80
+ const draft = this.readSync();
81
+ mutate(draft);
82
+ this.writeNowSync(draft);
83
+ return draft;
84
+ }
85
+ /** Run `work` after every earlier queued operation (the serialization). */
86
+ enqueue(work) {
87
+ const next = this.queue.then(work, work);
88
+ // The queue only tracks completion order; a rejection must not
89
+ // poison later operations (they re-read the file for truth).
90
+ this.queue = next.catch(() => { });
91
+ return next;
92
+ }
93
+ async readNow() {
94
+ let text;
95
+ try {
96
+ text = await readFile(this.path, "utf8");
97
+ }
98
+ catch (error) {
99
+ if (error.code === "ENOENT") {
100
+ return this.initial();
101
+ }
102
+ throw error;
103
+ }
104
+ try {
105
+ return JSON.parse(text);
106
+ }
107
+ catch (error) {
108
+ throw new Error(`gateway: malformed JSON in ${this.path}: ${String(error)}`, { cause: error });
109
+ }
110
+ }
111
+ /**
112
+ * Atomic replace: stage to a fresh temp file in the target's directory
113
+ * (rename is same-filesystem atomic), stamp 0600 on the temp BEFORE
114
+ * the rename (chmod beats `writeFile`'s umask-masked `mode`), then
115
+ * rename over the target. The parent dir is created when absent —
116
+ * the engine's start creates the state dir, but a JsonFile handed a
117
+ * deeper path stays usable on a fresh deployment.
118
+ */
119
+ async writeNow(value) {
120
+ await mkdir(dirname(this.path), { recursive: true });
121
+ const temp = `${this.path}.${randomUUID()}.tmp`;
122
+ try {
123
+ await writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, {
124
+ mode: 0o600,
125
+ });
126
+ await chmod(temp, 0o600);
127
+ await rename(temp, this.path);
128
+ }
129
+ catch (error) {
130
+ await rm(temp, { force: true }).catch(() => {
131
+ // Best-effort cleanup of the staged temp; the failure being
132
+ // handled is the original one.
133
+ });
134
+ throw error;
135
+ }
136
+ }
137
+ /**
138
+ * The sync twin of {@link writeNow}: the SAME atomic staging (temp +
139
+ * rename, 0600 before the swap) through the blocking fs calls.
140
+ */
141
+ writeNowSync(value) {
142
+ mkdirSync(dirname(this.path), { recursive: true });
143
+ const temp = `${this.path}.${randomUUID()}.tmp`;
144
+ try {
145
+ writeFileSync(temp, `${JSON.stringify(value, null, "\t")}\n`, {
146
+ mode: 0o600,
147
+ });
148
+ chmodSync(temp, 0o600);
149
+ renameSync(temp, this.path);
150
+ }
151
+ catch (error) {
152
+ rmSync(temp, { force: true });
153
+ throw error;
154
+ }
155
+ }
156
+ }