@noodleseed/one 0.170.0 → 0.171.0

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 (105) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +3 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/catalog.d.ts.map +1 -1
  5. package/dist/commands/catalog.js +2 -1
  6. package/dist/commands/catalog.js.map +1 -1
  7. package/dist/commands/whatsapp-ops.d.ts +13 -0
  8. package/dist/commands/whatsapp-ops.d.ts.map +1 -0
  9. package/dist/commands/whatsapp-ops.js +244 -0
  10. package/dist/commands/whatsapp-ops.js.map +1 -0
  11. package/dist/commands/whatsapp-output.d.ts +3 -0
  12. package/dist/commands/whatsapp-output.d.ts.map +1 -0
  13. package/dist/commands/whatsapp-output.js +70 -0
  14. package/dist/commands/whatsapp-output.js.map +1 -0
  15. package/dist/dev.js +2 -0
  16. package/dist/dev.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/plugin-mode/plugin-operation-contract.d.ts +1 -1
  22. package/node_modules/@noodle-borg/agent-kit/dist/curated/command-groups.js +4 -0
  23. package/node_modules/@noodle-borg/agent-kit/dist/curated/error-fixes.js +1 -0
  24. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
  25. package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +3 -0
  26. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +6 -0
  27. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  28. package/node_modules/@noodle-borg/app-audit/dist/audit-embedded-assistant.js +2 -0
  29. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-configuration.js +2 -1
  30. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +9 -4
  31. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.d.ts +16 -0
  32. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.js +19 -0
  33. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +145 -0
  34. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +378 -0
  35. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +160 -0
  36. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-provider-blocks.d.ts +18 -0
  37. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-provider-blocks.js +69 -0
  38. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-spend.js +82 -0
  39. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +42 -0
  40. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.js +76 -0
  41. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +134 -0
  42. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +72 -0
  43. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-work.d.ts +20 -0
  44. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-work.js +221 -0
  45. package/node_modules/@noodle-borg/assistant-gateway/dist/messaging-tool-dispatch.d.ts +15 -0
  46. package/node_modules/@noodle-borg/assistant-gateway/dist/messaging-tool-dispatch.js +34 -0
  47. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +19 -1
  48. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +22 -6
  49. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +7 -0
  50. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +7 -0
  51. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-appearance-store.js +83 -0
  52. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-elevation.js +151 -0
  53. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-interaction-writes.js +45 -0
  54. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-interactions.js +340 -0
  55. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant.js +439 -0
  56. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-channel-store.js +102 -0
  57. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-continuity-store.js +159 -0
  58. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-elevation-store.js +110 -0
  59. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-embed-store.js +134 -0
  60. package/node_modules/@noodle-borg/assistant-gateway/dist/postgres.js +11 -0
  61. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +8 -0
  62. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +23 -0
  63. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +17 -2
  64. package/node_modules/@noodle-borg/authoring/dist/assistant.js +48 -19
  65. package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -1
  66. package/node_modules/@noodle-borg/authoring/dist/index.js +1 -1
  67. package/node_modules/@noodle-borg/authoring/dist/load-authored.js +1 -1
  68. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +121 -0
  69. package/node_modules/@noodle-borg/cli-catalog/dist/index.js +1 -0
  70. package/node_modules/@noodle-borg/compiler/dist/artifact/config.js +1 -1
  71. package/node_modules/@noodle-borg/compiler/dist/errors.d.ts +1 -1
  72. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +72 -12
  73. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +51 -27
  74. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +1 -0
  75. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +19 -0
  76. package/node_modules/@noodle-borg/module-audit/dist/index.js +37 -0
  77. package/node_modules/@noodle-borg/module-audit/dist/postgres-audit.js +126 -0
  78. package/node_modules/@noodle-borg/module-audit/dist/route.js +66 -0
  79. package/node_modules/@noodle-borg/runtime/dist/managed-origins.js +8 -4
  80. package/node_modules/@noodle-borg/runtime/dist/postgres-state-handles.js +311 -0
  81. package/node_modules/@noodle-borg/runtime/dist/postgres.js +4 -0
  82. package/node_modules/@noodle-borg/service/dist/assistant-composition.js +18 -0
  83. package/node_modules/@noodle-borg/service/dist/build-info.js +5 -2
  84. package/node_modules/@noodle-borg/service/dist/business-information/source-ingestion-coordinator.js +7 -0
  85. package/node_modules/@noodle-borg/service/dist/channels/360dialog.js +233 -0
  86. package/node_modules/@noodle-borg/service/dist/channels/cipher.js +37 -0
  87. package/node_modules/@noodle-borg/service/dist/channels/composition.js +37 -0
  88. package/node_modules/@noodle-borg/service/dist/channels/inference-guard.js +53 -0
  89. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +337 -0
  90. package/node_modules/@noodle-borg/service/dist/channels/worker-loop.js +50 -0
  91. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +62 -34
  92. package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +6 -2
  93. package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +14 -1
  94. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +238 -0
  95. package/node_modules/@noodle-borg/service/dist/service.js +30 -32
  96. package/node_modules/@noodle-borg/service/package.json +1 -1
  97. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
  98. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +9 -0
  99. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  100. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  101. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
  102. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +10 -2
  103. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +426 -0
  104. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +213 -0
  105. package/package.json +2 -2
@@ -0,0 +1,439 @@
1
+ import { ASSISTANT_HISTORY_MAX_MESSAGES, ASSISTANT_SESSION_IDLE_MS, } from './assistant-store.js';
2
+ import { PostgresAssistantInteractions } from './postgres-assistant-interactions.js';
3
+ /** Durable clients + shared, crash-disposable active sessions for multi-instance assistant gateways. */
4
+ export class PostgresAssistantStore {
5
+ #pool;
6
+ #interactions;
7
+ constructor(pool) {
8
+ this.#pool = pool;
9
+ this.#interactions = new PostgresAssistantInteractions(pool);
10
+ }
11
+ async ensureSchema() {
12
+ await this.#pool.query(`
13
+ CREATE TABLE IF NOT EXISTS assistant_clients (
14
+ id text PRIMARY KEY,
15
+ name text NOT NULL,
16
+ org_slug text NOT NULL,
17
+ app_slug text NOT NULL,
18
+ environment text NOT NULL,
19
+ deployment_id text NOT NULL,
20
+ allowed_origins jsonb NOT NULL,
21
+ secret_hash text NOT NULL,
22
+ created_at timestamptz NOT NULL,
23
+ revoked_at timestamptz
24
+ )
25
+ `);
26
+ await this.#pool.query(`
27
+ CREATE INDEX IF NOT EXISTS assistant_clients_tenant_idx
28
+ ON assistant_clients (org_slug, app_slug, environment)
29
+ `);
30
+ await this.#pool.query(`
31
+ CREATE UNLOGGED TABLE IF NOT EXISTS assistant_sessions (
32
+ id text PRIMARY KEY,
33
+ token_hash text UNIQUE NOT NULL,
34
+ client_id text NOT NULL,
35
+ org_slug text NOT NULL,
36
+ app_slug text NOT NULL,
37
+ environment text NOT NULL,
38
+ deployment_id text NOT NULL,
39
+ model_source text,
40
+ origin text NOT NULL,
41
+ caller jsonb NOT NULL,
42
+ customer_routing jsonb,
43
+ context jsonb,
44
+ preferences jsonb,
45
+ appearance jsonb,
46
+ history jsonb NOT NULL DEFAULT '[]'::jsonb,
47
+ model_tool_uses jsonb NOT NULL DEFAULT '[]'::jsonb,
48
+ created_at timestamptz NOT NULL,
49
+ expires_at timestamptz NOT NULL,
50
+ absolute_expires_at timestamptz NOT NULL
51
+ )
52
+ `);
53
+ await this.#interactions.ensureSchema();
54
+ await this.#pool.query(`
55
+ ALTER TABLE assistant_sessions
56
+ ADD COLUMN IF NOT EXISTS preferences jsonb
57
+ `);
58
+ await this.#pool.query(`
59
+ ALTER TABLE assistant_sessions
60
+ ADD COLUMN IF NOT EXISTS customer_routing jsonb
61
+ `);
62
+ await this.#pool.query(`
63
+ ALTER TABLE assistant_sessions
64
+ ADD COLUMN IF NOT EXISTS public_embed_id text
65
+ `);
66
+ await this.#pool.query(`
67
+ ALTER TABLE assistant_sessions
68
+ ADD COLUMN IF NOT EXISTS turn_count bigint NOT NULL DEFAULT 0
69
+ `);
70
+ await this.#pool.query(`
71
+ ALTER TABLE assistant_sessions
72
+ ADD COLUMN IF NOT EXISTS pending_resume jsonb
73
+ `);
74
+ await this.#pool.query(`
75
+ ALTER TABLE assistant_sessions
76
+ ADD COLUMN IF NOT EXISTS model_source text
77
+ `);
78
+ await this.#pool.query(`
79
+ ALTER TABLE assistant_sessions
80
+ ADD COLUMN IF NOT EXISTS model_tool_uses jsonb NOT NULL DEFAULT '[]'::jsonb
81
+ `);
82
+ await this.#pool.query(`
83
+ ALTER TABLE assistant_sessions
84
+ ADD COLUMN IF NOT EXISTS bound_surface text
85
+ `);
86
+ await this.#pool.query(`
87
+ ALTER TABLE assistant_sessions
88
+ ADD COLUMN IF NOT EXISTS initial_suggestions jsonb
89
+ `);
90
+ await this.#pool.query(`
91
+ ALTER TABLE assistant_sessions
92
+ ADD COLUMN IF NOT EXISTS latest_suggestions jsonb
93
+ `);
94
+ await this.#pool.query(`
95
+ ALTER TABLE assistant_sessions
96
+ ADD COLUMN IF NOT EXISTS latest_view jsonb
97
+ `);
98
+ await this.#pool.query(`
99
+ CREATE TABLE IF NOT EXISTS console_approval_nonces (
100
+ nonce text PRIMARY KEY,
101
+ subject text NOT NULL,
102
+ expires_at timestamptz NOT NULL,
103
+ consumed_at timestamptz NOT NULL
104
+ )
105
+ `);
106
+ }
107
+ async createClient(input) {
108
+ const { createHash, randomBytes, randomUUID } = await import('node:crypto');
109
+ const id = `embed_${randomUUID()}`;
110
+ const secret = `nsa_${randomBytes(32).toString('base64url')}`;
111
+ const secretHash = createHash('sha256').update(secret).digest('hex');
112
+ const client = {
113
+ id,
114
+ name: input.name,
115
+ tenant: { ...input.tenant },
116
+ deploymentId: input.deploymentId,
117
+ allowedOrigins: [...input.allowedOrigins],
118
+ secretHash,
119
+ createdAt: input.now.toISOString(),
120
+ };
121
+ await this.#pool.query(`INSERT INTO assistant_clients
122
+ (id, name, org_slug, app_slug, environment, deployment_id, allowed_origins,
123
+ secret_hash, created_at)
124
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9)`, [
125
+ id,
126
+ input.name,
127
+ input.tenant.org,
128
+ input.tenant.app,
129
+ input.tenant.env,
130
+ input.deploymentId,
131
+ JSON.stringify(input.allowedOrigins),
132
+ secretHash,
133
+ client.createdAt,
134
+ ]);
135
+ return { client, secret };
136
+ }
137
+ async listClients(tenant) {
138
+ const result = await this.#pool.query(`SELECT * FROM assistant_clients
139
+ WHERE org_slug=$1 AND app_slug=$2 AND environment=$3 ORDER BY created_at`, [tenant.org, tenant.app, tenant.env]);
140
+ return result.rows.map(clientFromRow);
141
+ }
142
+ async rotateClient(id, now) {
143
+ const { createHash, randomBytes } = await import('node:crypto');
144
+ const secret = `nsa_${randomBytes(32).toString('base64url')}`;
145
+ const secretHash = createHash('sha256').update(secret).digest('hex');
146
+ const result = await this.#pool.query(`UPDATE assistant_clients SET secret_hash=$2, created_at=$3
147
+ WHERE id=$1 AND revoked_at IS NULL RETURNING *`, [id, secretHash, now.toISOString()]);
148
+ const row = result.rows[0];
149
+ return row ? { client: clientFromRow(row), secret } : undefined;
150
+ }
151
+ async revokeClient(id, now) {
152
+ const result = await this.#pool.query(`UPDATE assistant_clients SET revoked_at=$2 WHERE id=$1 AND revoked_at IS NULL`, [id, now.toISOString()]);
153
+ return (result.rowCount ?? 0) > 0;
154
+ }
155
+ async authenticateClient(id, secret) {
156
+ const { createHash, timingSafeEqual } = await import('node:crypto');
157
+ const result = await this.#pool.query(`SELECT * FROM assistant_clients WHERE id=$1 AND revoked_at IS NULL`, [id]);
158
+ const row = result.rows[0];
159
+ if (!row)
160
+ return undefined;
161
+ const actual = Buffer.from(createHash('sha256').update(secret).digest('hex'), 'hex');
162
+ const expected = Buffer.from(row.secret_hash, 'hex');
163
+ return actual.length === expected.length && timingSafeEqual(actual, expected)
164
+ ? clientFromRow(row)
165
+ : undefined;
166
+ }
167
+ async createSession(input) {
168
+ const { createHash, randomBytes, randomUUID } = await import('node:crypto');
169
+ const id = `session_${randomUUID()}`;
170
+ const token = `nss_${randomBytes(32).toString('base64url')}`;
171
+ const tokenHash = createHash('sha256').update(token).digest('hex');
172
+ const session = {
173
+ ...input,
174
+ id,
175
+ tokenHash,
176
+ history: [],
177
+ modelToolUses: [],
178
+ turnCount: 0,
179
+ };
180
+ await this.#pool.query(`INSERT INTO assistant_sessions
181
+ (id, token_hash, client_id, org_slug, app_slug, environment, deployment_id, model_source, origin,
182
+ caller, customer_routing, context, preferences, appearance, history, model_tool_uses, created_at, expires_at,
183
+ absolute_expires_at, public_embed_id, turn_count, bound_surface)
184
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb,$12::jsonb,$13::jsonb,$14::jsonb,
185
+ '[]'::jsonb,'[]'::jsonb,$15,$16,$17,$18,0,$19)`, [
186
+ id,
187
+ tokenHash,
188
+ input.clientId,
189
+ input.tenant.org,
190
+ input.tenant.app,
191
+ input.tenant.env,
192
+ input.deploymentId,
193
+ input.modelSource ?? null,
194
+ input.origin,
195
+ JSON.stringify(input.caller),
196
+ input.customerRouting ? JSON.stringify(input.customerRouting) : null,
197
+ input.context ? JSON.stringify(input.context) : null,
198
+ input.preferences ? JSON.stringify(input.preferences) : null,
199
+ input.configuration ? JSON.stringify(input.configuration) : null,
200
+ input.createdAt,
201
+ input.expiresAt,
202
+ input.absoluteExpiresAt,
203
+ input.publicEmbedId ?? null,
204
+ input.boundSurface ?? null,
205
+ ]);
206
+ return { session, token };
207
+ }
208
+ /**
209
+ * One statement, so the check and the spend share a single row lock: concurrent turns arriving at the
210
+ * last slot cannot both be admitted. `RETURNING` gives the caller its own slot number, and the
211
+ * `COALESCE` read covers the two refusals — the session is spent, or it does not exist — without a
212
+ * second round trip that could observe a different state.
213
+ */
214
+ async consumeTurn(id, limit) {
215
+ const spent = await this.#pool.query(`UPDATE assistant_sessions SET turn_count = turn_count + 1
216
+ WHERE id = $1 AND turn_count < $2::bigint
217
+ RETURNING turn_count`, [id, limit]);
218
+ const admitted = spent.rows[0];
219
+ if (admitted)
220
+ return { allowed: true, turnCount: Number(admitted.turn_count) };
221
+ const current = await this.#pool.query(`SELECT turn_count FROM assistant_sessions WHERE id = $1`, [id]);
222
+ return { allowed: false, turnCount: Number(current.rows[0]?.turn_count ?? 0) };
223
+ }
224
+ async claimInitialSuggestions(id) {
225
+ const claimed = await this.#pool.query(`UPDATE assistant_sessions
226
+ SET initial_suggestions = '{"status":"generating"}'::jsonb
227
+ WHERE id = $1 AND initial_suggestions IS NULL
228
+ RETURNING id`, [id]);
229
+ if ((claimed.rowCount ?? 0) === 1)
230
+ return { disposition: 'generate' };
231
+ const current = await this.#pool.query('SELECT initial_suggestions FROM assistant_sessions WHERE id = $1', [id]);
232
+ const state = current.rows[0]?.initial_suggestions;
233
+ return state?.status === 'ready'
234
+ ? { disposition: 'ready', prompts: [...state.prompts] }
235
+ : { disposition: 'unavailable' };
236
+ }
237
+ async completeInitialSuggestions(id, prompts) {
238
+ const completed = await this.#pool.query(`UPDATE assistant_sessions
239
+ SET initial_suggestions = $2::jsonb
240
+ WHERE id = $1 AND initial_suggestions->>'status' = 'generating'
241
+ RETURNING id`, [id, JSON.stringify({ status: 'ready', prompts })]);
242
+ return (completed.rowCount ?? 0) === 1;
243
+ }
244
+ async failInitialSuggestions(id) {
245
+ const failed = await this.#pool.query(`UPDATE assistant_sessions
246
+ SET initial_suggestions = '{"status":"failed"}'::jsonb
247
+ WHERE id = $1 AND initial_suggestions->>'status' = 'generating'
248
+ RETURNING id`, [id]);
249
+ return (failed.rowCount ?? 0) === 1;
250
+ }
251
+ async replaceLatestSuggestions(id, suggestions) {
252
+ const replaced = await this.#pool.query(`UPDATE assistant_sessions SET latest_suggestions = $2::jsonb WHERE id = $1 RETURNING id`, [id, suggestions === undefined ? null : JSON.stringify(suggestions)]);
253
+ return (replaced.rowCount ?? 0) === 1;
254
+ }
255
+ async replaceLatestView(id, view) {
256
+ const replaced = await this.#pool.query(`UPDATE assistant_sessions SET latest_view = $2::jsonb WHERE id = $1 RETURNING id`, [id, view === undefined ? null : JSON.stringify(view)]);
257
+ return (replaced.rowCount ?? 0) === 1;
258
+ }
259
+ async claimModelToolUse(id, tool) {
260
+ const claimed = await this.#pool.query(`UPDATE assistant_sessions
261
+ SET model_tool_uses = model_tool_uses || to_jsonb($2::text)
262
+ WHERE id = $1 AND NOT model_tool_uses ? $2
263
+ RETURNING id`, [id, tool]);
264
+ return (claimed.rowCount ?? 0) === 1;
265
+ }
266
+ async releaseModelToolUse(id, tool) {
267
+ const released = await this.#pool.query(`UPDATE assistant_sessions
268
+ SET model_tool_uses = COALESCE(
269
+ (SELECT jsonb_agg(value) FROM jsonb_array_elements(model_tool_uses) AS entry(value)
270
+ WHERE value <> to_jsonb($2::text)),
271
+ '[]'::jsonb
272
+ )
273
+ WHERE id = $1 AND model_tool_uses ? $2
274
+ RETURNING id`, [id, tool]);
275
+ return (released.rowCount ?? 0) === 1;
276
+ }
277
+ /**
278
+ * One statement: two requests racing the same arm must not both see the pending resume. The
279
+ * armed CTE locks and carries the OLD value — a plain `RETURNING pending_resume` reflects the
280
+ * post-update NULL, not what was consumed.
281
+ */
282
+ async consumePendingResume(sessionId) {
283
+ const consumed = await this.#pool.query(`WITH armed AS (
284
+ SELECT id, pending_resume FROM assistant_sessions
285
+ WHERE id = $1 AND pending_resume IS NOT NULL
286
+ FOR UPDATE
287
+ )
288
+ UPDATE assistant_sessions AS sessions SET pending_resume = NULL
289
+ FROM armed WHERE sessions.id = armed.id
290
+ RETURNING armed.pending_resume`, [sessionId]);
291
+ return consumed.rows[0]?.pending_resume;
292
+ }
293
+ /**
294
+ * One statement, for the same reason `consumeTurn` is one: the anonymous token must not outlive the
295
+ * elevation by even a moment. `WHERE caller->>'identityKind' = 'anonymous'` is what refuses a second
296
+ * elevation — the guard is the update's own predicate, so there is no separate check to forget.
297
+ */
298
+ async elevateSession(input) {
299
+ const { createHash, randomBytes } = await import('node:crypto');
300
+ const token = `nss_${randomBytes(24).toString('base64url')}`;
301
+ // client_id, origin, routing, and the surface binding rebind in the same statement: the issuer
302
+ // basis (ADR 0152), the CORS pin, and the landing surface's projection must never lag the caller
303
+ // they now describe. COALESCE keeps "absent means unchanged" for routing and the binding —
304
+ // omission is not a decision.
305
+ const elevated = await this.#pool.query(`UPDATE assistant_sessions
306
+ SET token_hash = $2, caller = $3::jsonb, client_id = $4, origin = $5,
307
+ customer_routing = COALESCE($6::jsonb, customer_routing),
308
+ pending_resume = $7::jsonb, latest_suggestions = NULL,
309
+ bound_surface = COALESCE($8, bound_surface)
310
+ WHERE id = $1 AND caller->>'identityKind' = 'anonymous'
311
+ RETURNING *`, [
312
+ input.sessionId,
313
+ createHash('sha256').update(token).digest('hex'),
314
+ JSON.stringify(input.caller),
315
+ input.clientId,
316
+ input.origin,
317
+ input.customerRouting ? JSON.stringify(input.customerRouting) : null,
318
+ input.pendingResume ? JSON.stringify(input.pendingResume) : null,
319
+ input.boundSurface ?? null,
320
+ ]);
321
+ const row = elevated.rows[0];
322
+ if (row)
323
+ return { ok: true, session: sessionFromRow(row), token };
324
+ const existing = await this.#pool.query('SELECT id FROM assistant_sessions WHERE id = $1', [input.sessionId]);
325
+ return existing.rows[0]
326
+ ? { ok: false, reason: 'already_elevated' }
327
+ : { ok: false, reason: 'unknown_session' };
328
+ }
329
+ async getSession(token, now) {
330
+ const { createHash } = await import('node:crypto');
331
+ const tokenHash = createHash('sha256').update(token).digest('hex');
332
+ await this.#prune(now);
333
+ const result = await this.#pool.query(`UPDATE assistant_sessions
334
+ SET expires_at=LEAST(absolute_expires_at, $3)
335
+ WHERE token_hash=$1 AND expires_at>$2 AND absolute_expires_at>$2
336
+ RETURNING *`, [
337
+ tokenHash,
338
+ now.toISOString(),
339
+ new Date(now.getTime() + ASSISTANT_SESSION_IDLE_MS).toISOString(),
340
+ ]);
341
+ return result.rows[0] ? sessionFromRow(result.rows[0]) : undefined;
342
+ }
343
+ async appendHistory(id, messages) {
344
+ await this.#pool.query(`UPDATE assistant_sessions
345
+ SET history=(SELECT COALESCE(jsonb_agg(value ORDER BY n), '[]'::jsonb)
346
+ FROM (SELECT value, n FROM jsonb_array_elements(history || $2::jsonb)
347
+ WITH ORDINALITY AS e(value, n) ORDER BY n DESC LIMIT ${ASSISTANT_HISTORY_MAX_MESSAGES}) recent)
348
+ WHERE id=$1`, [id, JSON.stringify(messages)]);
349
+ }
350
+ async createInteraction(input) {
351
+ return input.kind === 'confirmation'
352
+ ? this.#interactions.create(input)
353
+ : this.#interactions.create(input);
354
+ }
355
+ async claimInteraction(input) {
356
+ return this.#interactions.claim(input);
357
+ }
358
+ async completeInteraction(input) {
359
+ return this.#interactions.complete(input);
360
+ }
361
+ async transitionInteraction(input) {
362
+ return this.#interactions.transition(input);
363
+ }
364
+ async getInteraction(input) {
365
+ return this.#interactions.get(input);
366
+ }
367
+ async findPendingInteraction(input) {
368
+ return this.#interactions.findPending(input);
369
+ }
370
+ async consumeInteraction(id, sessionId, deploymentId, now) {
371
+ return this.#interactions.consumeConfirmation({ id, sessionId, deploymentId, now });
372
+ }
373
+ async consumeConsoleApprovalNonce(nonce, subject, expiresAt, now) {
374
+ if (expiresAt.getTime() <= now.getTime())
375
+ return false;
376
+ const result = await this.#pool.query(`INSERT INTO console_approval_nonces (nonce, subject, expires_at, consumed_at)
377
+ VALUES ($1,$2,$3,$4) ON CONFLICT (nonce) DO NOTHING RETURNING nonce`, [nonce, subject, expiresAt.toISOString(), now.toISOString()]);
378
+ return result.rowCount === 1;
379
+ }
380
+ async #prune(now) {
381
+ await this.#interactions.prune(now);
382
+ await this.#pool.query(`DELETE FROM console_approval_nonces WHERE expires_at <= $1`, [
383
+ now.toISOString(),
384
+ ]);
385
+ await this.#pool.query(`DELETE FROM assistant_sessions WHERE expires_at <= $1 OR absolute_expires_at <= $1`, [now.toISOString()]);
386
+ }
387
+ }
388
+ function clientFromRow(row) {
389
+ return {
390
+ id: row.id,
391
+ name: row.name,
392
+ tenant: { org: row.org_slug, app: row.app_slug, env: row.environment },
393
+ deploymentId: row.deployment_id,
394
+ allowedOrigins: row.allowed_origins,
395
+ secretHash: row.secret_hash,
396
+ createdAt: row.created_at.toISOString(),
397
+ ...(row.revoked_at ? { revokedAt: row.revoked_at.toISOString() } : {}),
398
+ };
399
+ }
400
+ export function sessionFromRow(row) {
401
+ return {
402
+ id: row.id,
403
+ tokenHash: row.token_hash,
404
+ clientId: row.client_id,
405
+ tenant: { org: row.org_slug, app: row.app_slug, env: row.environment },
406
+ deploymentId: row.deployment_id,
407
+ ...(row.model_source ? { modelSource: row.model_source } : {}),
408
+ origin: row.origin,
409
+ caller: row.caller,
410
+ ...(row.customer_routing
411
+ ? { customerRouting: Object.freeze({ ...row.customer_routing }) }
412
+ : {}),
413
+ ...(row.context ? { context: row.context } : {}),
414
+ ...(row.preferences ? { preferences: row.preferences } : {}),
415
+ ...(row.appearance ? { configuration: row.appearance } : {}),
416
+ createdAt: row.created_at.toISOString(),
417
+ expiresAt: row.expires_at.toISOString(),
418
+ absoluteExpiresAt: row.absolute_expires_at.toISOString(),
419
+ history: row.history,
420
+ modelToolUses: row.model_tool_uses ?? [],
421
+ ...(row.public_embed_id ? { publicEmbedId: row.public_embed_id } : {}),
422
+ ...(row.pending_resume ? { pendingResume: row.pending_resume } : {}),
423
+ ...(row.bound_surface === 'public' || row.bound_surface === 'authenticated'
424
+ ? { boundSurface: row.bound_surface }
425
+ : {}),
426
+ turnCount: Number(row.turn_count ?? 0),
427
+ ...(row.initial_suggestions ? { initialSuggestions: row.initial_suggestions } : {}),
428
+ ...(row.latest_suggestions
429
+ ? {
430
+ latestSuggestions: {
431
+ phase: row.latest_suggestions.phase,
432
+ prompts: [...row.latest_suggestions.prompts],
433
+ },
434
+ }
435
+ : {}),
436
+ ...(row.latest_view ? { latestView: structuredClone(row.latest_view) } : {}),
437
+ };
438
+ }
439
+ //# sourceMappingURL=postgres-assistant.js.map
@@ -0,0 +1,102 @@
1
+ import { channelScanLimit, requireChannelLock, } from './channel-store.js';
2
+ /** Logged, encrypted journal with per-binding locks. No state relies on a surviving worker process. */
3
+ export class PostgresChannelStore {
4
+ pool;
5
+ cipher;
6
+ durable = true;
7
+ constructor(pool, cipher) {
8
+ this.pool = pool;
9
+ this.cipher = cipher;
10
+ }
11
+ async ensureSchema() {
12
+ await this.pool.query(`CREATE TABLE IF NOT EXISTS assistant_channel_records (
13
+ scope text NOT NULL, id text NOT NULL, kind text NOT NULL, state text,
14
+ updated_at bigint NOT NULL, expires_at bigint, sealed jsonb NOT NULL,
15
+ PRIMARY KEY(scope,id))`);
16
+ await this.pool.query(`CREATE INDEX IF NOT EXISTS assistant_channel_scan
17
+ ON assistant_channel_records(scope,kind,state,id)`);
18
+ await this.pool.query(`CREATE INDEX IF NOT EXISTS assistant_channel_expiry
19
+ ON assistant_channel_records(expires_at) WHERE expires_at IS NOT NULL`);
20
+ }
21
+ async transaction(locks, work) {
22
+ const client = await this.pool.connect();
23
+ try {
24
+ await client.query('BEGIN');
25
+ await client.query("SET LOCAL lock_timeout = '5s'");
26
+ await client.query("SET LOCAL statement_timeout = '10s'");
27
+ for (const scope of [...new Set(locks)].sort()) {
28
+ await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 713))', [scope]);
29
+ }
30
+ const result = await work(this.transactionView(client, locks));
31
+ await client.query('COMMIT');
32
+ return result;
33
+ }
34
+ catch (error) {
35
+ await client.query('ROLLBACK');
36
+ throw error;
37
+ }
38
+ finally {
39
+ client.release();
40
+ }
41
+ }
42
+ transactionView(client, locks) {
43
+ const read = async (scope, rows) => Promise.all(rows.map(async (row) => ({
44
+ id: row.id,
45
+ kind: row.kind,
46
+ ...(row.state === null ? {} : { state: row.state }),
47
+ updatedAt: Number(row.updated_at),
48
+ ...(row.expires_at === null ? {} : { expiresAt: Number(row.expires_at) }),
49
+ value: await this.cipher.open(scope, row.id, row.sealed),
50
+ })));
51
+ return {
52
+ get: async (scope, id) => {
53
+ requireChannelLock(locks, scope);
54
+ const result = await client.query('SELECT * FROM assistant_channel_records WHERE scope=$1 AND id=$2', [scope, id]);
55
+ return (await read(scope, result.rows))[0];
56
+ },
57
+ put: async (scope, row) => {
58
+ requireChannelLock(locks, scope);
59
+ const sealed = await this.cipher.seal(scope, row.id, row.value);
60
+ await client.query(`INSERT INTO assistant_channel_records(scope,id,kind,state,updated_at,expires_at,sealed)
61
+ VALUES($1,$2,$3,$4,$5,$6,$7::jsonb) ON CONFLICT(scope,id) DO UPDATE SET
62
+ kind=EXCLUDED.kind,state=EXCLUDED.state,updated_at=EXCLUDED.updated_at,
63
+ expires_at=EXCLUDED.expires_at,sealed=EXCLUDED.sealed`, [
64
+ scope,
65
+ row.id,
66
+ row.kind,
67
+ row.state ?? null,
68
+ row.updatedAt,
69
+ row.expiresAt ?? null,
70
+ JSON.stringify(sealed),
71
+ ]);
72
+ },
73
+ remove: async (scope, id) => {
74
+ requireChannelLock(locks, scope);
75
+ await client.query('DELETE FROM assistant_channel_records WHERE scope=$1 AND id=$2', [
76
+ scope,
77
+ id,
78
+ ]);
79
+ },
80
+ count: async (scope, kind, state, now) => {
81
+ requireChannelLock(locks, scope);
82
+ const result = await client.query('SELECT count(*) FROM assistant_channel_records WHERE scope=$1 AND kind=$2 AND state=$3 AND (expires_at IS NULL OR expires_at > $4)', [scope, kind, state, now]);
83
+ return Number(result.rows[0].count);
84
+ },
85
+ list: async (scope, scan) => {
86
+ requireChannelLock(locks, scope);
87
+ const result = await client.query(`SELECT * FROM assistant_channel_records
88
+ WHERE scope=$1 AND kind=$2 AND ($3::text IS NULL OR state=$3)
89
+ AND ($4::text IS NULL OR id COLLATE "C" > $4 COLLATE "C")
90
+ ORDER BY id COLLATE "C" LIMIT $5`, [scope, scan.kind, scan.state ?? null, scan.after ?? null, channelScanLimit(scan.limit)]);
91
+ return read(scope, result.rows);
92
+ },
93
+ };
94
+ }
95
+ async prune(now, limit) {
96
+ const result = await this.pool.query(`DELETE FROM assistant_channel_records WHERE (scope,id) IN (
97
+ SELECT scope,id FROM assistant_channel_records WHERE expires_at <= $1
98
+ ORDER BY expires_at LIMIT $2 FOR UPDATE SKIP LOCKED)`, [now, channelScanLimit(limit)]);
99
+ return result.rowCount ?? 0;
100
+ }
101
+ }
102
+ //# sourceMappingURL=postgres-channel-store.js.map