@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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/catalog.d.ts.map +1 -1
- package/dist/commands/catalog.js +2 -1
- package/dist/commands/catalog.js.map +1 -1
- package/dist/commands/whatsapp-ops.d.ts +13 -0
- package/dist/commands/whatsapp-ops.d.ts.map +1 -0
- package/dist/commands/whatsapp-ops.js +244 -0
- package/dist/commands/whatsapp-ops.js.map +1 -0
- package/dist/commands/whatsapp-output.d.ts +3 -0
- package/dist/commands/whatsapp-output.d.ts.map +1 -0
- package/dist/commands/whatsapp-output.js +70 -0
- package/dist/commands/whatsapp-output.js.map +1 -0
- package/dist/dev.js +2 -0
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin-mode/plugin-operation-contract.d.ts +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/curated/command-groups.js +4 -0
- package/node_modules/@noodle-borg/agent-kit/dist/curated/error-fixes.js +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +3 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +6 -0
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/app-audit/dist/audit-embedded-assistant.js +2 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-configuration.js +2 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +9 -4
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.d.ts +16 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.js +19 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +145 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +378 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +160 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-provider-blocks.d.ts +18 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-provider-blocks.js +69 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-spend.js +82 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +42 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.js +76 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +134 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +72 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-work.d.ts +20 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-work.js +221 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/messaging-tool-dispatch.d.ts +15 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/messaging-tool-dispatch.js +34 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +19 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +22 -6
- package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +7 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +7 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-appearance-store.js +83 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-elevation.js +151 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-interaction-writes.js +45 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant-interactions.js +340 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-assistant.js +439 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-channel-store.js +102 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-continuity-store.js +159 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-elevation-store.js +110 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres-embed-store.js +134 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/postgres.js +11 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +8 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +23 -0
- package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +17 -2
- package/node_modules/@noodle-borg/authoring/dist/assistant.js +48 -19
- package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/authoring/dist/index.js +1 -1
- package/node_modules/@noodle-borg/authoring/dist/load-authored.js +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +121 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/index.js +1 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/config.js +1 -1
- package/node_modules/@noodle-borg/compiler/dist/errors.d.ts +1 -1
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +72 -12
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +51 -27
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +1 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +19 -0
- package/node_modules/@noodle-borg/module-audit/dist/index.js +37 -0
- package/node_modules/@noodle-borg/module-audit/dist/postgres-audit.js +126 -0
- package/node_modules/@noodle-borg/module-audit/dist/route.js +66 -0
- package/node_modules/@noodle-borg/runtime/dist/managed-origins.js +8 -4
- package/node_modules/@noodle-borg/runtime/dist/postgres-state-handles.js +311 -0
- package/node_modules/@noodle-borg/runtime/dist/postgres.js +4 -0
- package/node_modules/@noodle-borg/service/dist/assistant-composition.js +18 -0
- package/node_modules/@noodle-borg/service/dist/build-info.js +5 -2
- package/node_modules/@noodle-borg/service/dist/business-information/source-ingestion-coordinator.js +7 -0
- package/node_modules/@noodle-borg/service/dist/channels/360dialog.js +233 -0
- package/node_modules/@noodle-borg/service/dist/channels/cipher.js +37 -0
- package/node_modules/@noodle-borg/service/dist/channels/composition.js +37 -0
- package/node_modules/@noodle-borg/service/dist/channels/inference-guard.js +53 -0
- package/node_modules/@noodle-borg/service/dist/channels/runtime.js +337 -0
- package/node_modules/@noodle-borg/service/dist/channels/worker-loop.js +50 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +62 -34
- package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +6 -2
- package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +14 -1
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +238 -0
- package/node_modules/@noodle-borg/service/dist/service.js +30 -32
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +9 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +10 -2
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +426 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +213 -0
- 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
|