@parlor/chat 0.0.0-stage → 0.2.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -2
  3. package/dist/events.d.ts +4 -0
  4. package/dist/events.d.ts.map +1 -0
  5. package/dist/events.js +12 -0
  6. package/dist/events.js.map +1 -0
  7. package/dist/id-schemas.d.ts +5 -0
  8. package/dist/id-schemas.d.ts.map +1 -0
  9. package/dist/id-schemas.js +11 -0
  10. package/dist/id-schemas.js.map +1 -0
  11. package/dist/index.d.ts +9 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +7 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/items.d.ts +295 -0
  16. package/dist/items.d.ts.map +1 -0
  17. package/dist/items.js +113 -0
  18. package/dist/items.js.map +1 -0
  19. package/dist/limits.d.ts +4 -0
  20. package/dist/limits.d.ts.map +1 -0
  21. package/dist/limits.js +4 -0
  22. package/dist/limits.js.map +1 -0
  23. package/dist/model.d.ts +143 -0
  24. package/dist/model.d.ts.map +1 -0
  25. package/dist/model.js +2 -0
  26. package/dist/model.js.map +1 -0
  27. package/dist/open-turns.d.ts +19 -0
  28. package/dist/open-turns.d.ts.map +1 -0
  29. package/dist/open-turns.js +31 -0
  30. package/dist/open-turns.js.map +1 -0
  31. package/dist/protocol.d.ts +69 -0
  32. package/dist/protocol.d.ts.map +1 -0
  33. package/dist/protocol.js +80 -0
  34. package/dist/protocol.js.map +1 -0
  35. package/dist/server/actions.d.ts +561 -0
  36. package/dist/server/actions.d.ts.map +1 -0
  37. package/dist/server/actions.js +422 -0
  38. package/dist/server/actions.js.map +1 -0
  39. package/dist/server/context.d.ts +24 -0
  40. package/dist/server/context.d.ts.map +1 -0
  41. package/dist/server/context.js +77 -0
  42. package/dist/server/context.js.map +1 -0
  43. package/dist/server/index.d.ts +50 -0
  44. package/dist/server/index.d.ts.map +1 -0
  45. package/dist/server/index.js +145 -0
  46. package/dist/server/index.js.map +1 -0
  47. package/dist/server/live.d.ts +24 -0
  48. package/dist/server/live.d.ts.map +1 -0
  49. package/dist/server/live.js +130 -0
  50. package/dist/server/live.js.map +1 -0
  51. package/dist/server/migrations.d.ts +3 -0
  52. package/dist/server/migrations.d.ts.map +1 -0
  53. package/dist/server/migrations.js +96 -0
  54. package/dist/server/migrations.js.map +1 -0
  55. package/dist/server/party.d.ts +24 -0
  56. package/dist/server/party.d.ts.map +1 -0
  57. package/dist/server/party.js +38 -0
  58. package/dist/server/party.js.map +1 -0
  59. package/dist/server/routes.d.ts +17 -0
  60. package/dist/server/routes.d.ts.map +1 -0
  61. package/dist/server/routes.js +105 -0
  62. package/dist/server/routes.js.map +1 -0
  63. package/dist/server/rows.d.ts +49 -0
  64. package/dist/server/rows.d.ts.map +1 -0
  65. package/dist/server/rows.js +136 -0
  66. package/dist/server/rows.js.map +1 -0
  67. package/dist/server/store.d.ts +130 -0
  68. package/dist/server/store.d.ts.map +1 -0
  69. package/dist/server/store.js +539 -0
  70. package/dist/server/store.js.map +1 -0
  71. package/dist/tags.d.ts +7 -0
  72. package/dist/tags.d.ts.map +1 -0
  73. package/dist/tags.js +7 -0
  74. package/dist/tags.js.map +1 -0
  75. package/dist/testing/index.d.ts +46 -0
  76. package/dist/testing/index.d.ts.map +1 -0
  77. package/dist/testing/index.js +96 -0
  78. package/dist/testing/index.js.map +1 -0
  79. package/dist/view.d.ts +87 -0
  80. package/dist/view.d.ts.map +1 -0
  81. package/dist/view.js +236 -0
  82. package/dist/view.js.map +1 -0
  83. package/package.json +47 -3
@@ -0,0 +1,539 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { ActionError, asId, } from "@parlor/core";
3
+ import { sql } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { idSchema } from "../id-schemas.js";
6
+ import { chatItemBodies } from "../items.js";
7
+ import { agentInfoSchema, itemRow, threadRow, toLoggedItem, toThreadSummary, TURN_FAILURES, turnStateSchema, } from "./rows.js";
8
+ function rowsOf(result) {
9
+ if (Array.isArray(result))
10
+ return result;
11
+ const rows = result?.rows;
12
+ return rows ?? [];
13
+ }
14
+ async function query(db, statement, schema) {
15
+ const result = await db.execute(statement);
16
+ return rowsOf(result).map((row) => schema.parse(row));
17
+ }
18
+ const count = z.coerce.number();
19
+ const ms = (column) => sql.raw(`(extract(epoch from ${column}) * 1000)::bigint`);
20
+ const ITEM_COLUMNS = sql `id, thread_id, turn_id, seq, kind, body, ${ms("created_at")} as at`;
21
+ const ITEM_COLUMNS_OF_I = sql `i.id, i.thread_id, i.turn_id, i.seq, i.kind, i.body, ${ms("i.created_at")} as at`;
22
+ const THREAD_COLUMNS = sql `id, title, host_id, agent_id, last_seq, ${ms("updated_at")} as updated_at`;
23
+ export const THREAD_LIST_LIMIT = 200;
24
+ const TITLE_CHARS = 80;
25
+ const TERMINAL_STATES = new Set(["done", "failed", "cancelled"]);
26
+ const threadMissing = () => new ActionError("not_found", "Thread not found");
27
+ const turnMissing = () => new ActionError("not_found", "Turn not found");
28
+ const inList = (values) => sql.join(values.map((value) => sql `${value}`), sql `, `);
29
+ const authorOf = (kind) => chatItemBodies[kind].author;
30
+ function titleOf(text) {
31
+ const firstLine = text.trim().split("\n")[0] ?? "";
32
+ return [...firstLine].slice(0, TITLE_CHARS).join("");
33
+ }
34
+ /** The only place the log is locked: the thread row, then the turn row. */
35
+ async function lockLog(tx, owner, threadId, turnId) {
36
+ const [row] = await query(tx, sql `select last_seq from parlor_chat_threads
37
+ where id = ${threadId} and scope = ${owner.scope} and owner_id = ${owner.userId}
38
+ for update`, z.object({ last_seq: count }));
39
+ if (!row)
40
+ throw threadMissing();
41
+ if (turnId === undefined) {
42
+ return { lastSeq: row.last_seq, turnState: undefined, leased: false };
43
+ }
44
+ const [turn] = await query(tx, sql `select state, coalesce(state = 'running' and lease_until >= now(), false) as leased
45
+ from parlor_chat_turns where id = ${turnId} and thread_id = ${threadId} for update`, z.object({ state: z.string(), leased: z.boolean() }));
46
+ return {
47
+ lastSeq: row.last_seq,
48
+ turnState: turn?.state,
49
+ leased: turn?.leased ?? false,
50
+ };
51
+ }
52
+ async function insertItems(tx, threadId, lastSeq, rows) {
53
+ if (rows.length === 0)
54
+ return lastSeq;
55
+ const values = rows.map((row, index) => sql `(${row.id}, ${threadId}, ${row.turnId}, ${lastSeq + index + 1}, ${authorOf(row.kind)}, ${row.kind}, ${JSON.stringify(row.body)}::jsonb)`);
56
+ await tx.execute(sql `insert into parlor_chat_items (id, thread_id, turn_id, seq, author, kind, body)
57
+ values ${sql.join(values, sql `, `)}`);
58
+ const next = lastSeq + rows.length;
59
+ await tx.execute(sql `update parlor_chat_threads set last_seq = ${next}, updated_at = now() where id = ${threadId}`);
60
+ return next;
61
+ }
62
+ async function itemThreads(tx, ids) {
63
+ if (ids.length === 0)
64
+ return new Map();
65
+ const rows = await query(tx, sql `select id, thread_id from parlor_chat_items where id in (${inList(ids)})`, z.object({ id: z.string(), thread_id: z.string() }));
66
+ return new Map(rows.map((row) => [row.id, row.thread_id]));
67
+ }
68
+ async function itemsById(db, threadId, ids) {
69
+ if (ids.length === 0)
70
+ return [];
71
+ const rows = await query(db, sql `select ${ITEM_COLUMNS} from parlor_chat_items
72
+ where thread_id = ${threadId} and id in (${inList(ids)}) order by seq`, itemRow);
73
+ return rows.map(toLoggedItem);
74
+ }
75
+ const turnRow = z.object({
76
+ id: idSchema("turn"),
77
+ thread_id: idSchema("thread"),
78
+ host_id: idSchema("host"),
79
+ agent_id: z.string(),
80
+ state: turnStateSchema,
81
+ lapsed: z.boolean(),
82
+ failure: z.enum(TURN_FAILURES).nullable(),
83
+ });
84
+ async function createTurn(tx, owner, threadId, turn, promptItemId) {
85
+ const [existing] = await query(tx, sql `select thread_id, owner_id, scope from parlor_chat_turns where id = ${turn.turnId}`, z.object({ thread_id: z.string(), owner_id: z.string(), scope: z.string() }));
86
+ if (existing) {
87
+ const sameThread = existing.thread_id === threadId &&
88
+ existing.owner_id === owner.userId &&
89
+ existing.scope === owner.scope;
90
+ if (!sameThread)
91
+ throw turnMissing();
92
+ return;
93
+ }
94
+ const [queued] = await query(tx, sql `select count(*) as waiting from parlor_chat_turns
95
+ where thread_id = ${threadId} and state = 'queued'`, z.object({ waiting: count }));
96
+ if ((queued?.waiting ?? 0) >= turn.maxQueued) {
97
+ throw new ActionError("invalid_input", "Too many prompts are waiting for the agent; wait for it to catch up.");
98
+ }
99
+ await tx.execute(sql `insert into parlor_chat_turns
100
+ (id, thread_id, scope, owner_id, host_id, agent_id, client_id, device_id, prompt_item_id, state)
101
+ values (${turn.turnId}, ${threadId}, ${owner.scope}, ${owner.userId}, ${turn.agent.hostId},
102
+ ${turn.agent.agentId}, ${turn.clientId}, ${turn.deviceId ?? null}, ${promptItemId}, 'queued')`);
103
+ }
104
+ async function admitsUserItem(tx, threadId, turnId, draft) {
105
+ if (draft.kind === "prompt")
106
+ return true;
107
+ if (draft.kind === "answer") {
108
+ const askId = draft.body.askId;
109
+ const [ask] = await query(tx, sql `select turn_id, body->>'approval' as approval from parlor_chat_items
110
+ where thread_id = ${threadId} and id = ${askId} and kind = 'ask'`, z.object({ turn_id: z.string(), approval: z.string().nullable() }));
111
+ if (!ask || ask.turn_id !== turnId)
112
+ throw new ActionError("not_found", "Question not found");
113
+ if (ask.approval === "computer") {
114
+ throw new ActionError("forbidden", "This question is answered on the computer.");
115
+ }
116
+ const { signed } = draft.body;
117
+ if (ask.approval === "presence" && signed?.signature.presence !== "user") {
118
+ throw new ActionError("forbidden", "This question needs a presence check.");
119
+ }
120
+ const [state] = await query(tx, sql `select state from parlor_chat_turns where id = ${turnId} for update`, z.object({ state: z.string() }));
121
+ const [standing] = await query(tx, sql `select a.id from parlor_chat_items a
122
+ where a.thread_id = ${threadId} and a.kind = 'answer' and a.body->>'askId' = ${askId}
123
+ and not exists (
124
+ select 1 from parlor_chat_items e
125
+ where e.thread_id = ${threadId} and e.kind = 'event'
126
+ and e.body->>'event' = 'answer_rejected' and e.body->>'itemId' = a.id)
127
+ limit 1`, z.object({ id: z.string() }));
128
+ if (standing) {
129
+ throw new ActionError("invalid_input", "This question was already answered.");
130
+ }
131
+ if (state?.state !== "running") {
132
+ throw new ActionError("invalid_input", "The agent is no longer waiting for this answer.");
133
+ }
134
+ return true;
135
+ }
136
+ const [turn] = await query(tx, sql `select state from parlor_chat_turns where id = ${turnId} and thread_id = ${threadId}`, z.object({ state: z.string() }));
137
+ if (!turn)
138
+ throw turnMissing();
139
+ if (TERMINAL_STATES.has(turn.state))
140
+ return false;
141
+ if (turn.state === "queued") {
142
+ await tx.execute(sql `update parlor_chat_turns
143
+ set state = 'cancelled', outcome = '{"state":"cancelled"}'::jsonb, finished_at = now()
144
+ where id = ${turnId} and state = 'queued'`);
145
+ }
146
+ return true;
147
+ }
148
+ const lostItemId = () => asId("item", `lost.${randomUUID()}`);
149
+ const ownedBy = (owner) => owner ? sql `and owner_id = ${owner.userId} and scope = ${owner.scope}` : sql ``;
150
+ async function lapsedFor(db, owner) {
151
+ const [found] = await query(db, sql `select (
152
+ exists (select 1 from parlor_chat_turns
153
+ where state = 'running' and lease_until < now() ${ownedBy(owner)})
154
+ or exists (select 1 from parlor_chat_hosts
155
+ where announced_online and online_until <= now() ${ownedBy(owner)})
156
+ ) as lapsed`, z.object({ lapsed: z.boolean() }));
157
+ return found?.lapsed ?? false;
158
+ }
159
+ async function expireLapsed(tx, owner) {
160
+ const changes = new Map();
161
+ const changed = (scope, userId) => {
162
+ const key = `${scope}\u0000${userId}`;
163
+ let change = changes.get(key);
164
+ if (!change)
165
+ changes.set(key, (change = { scope, userId, threads: [], hosts: [] }));
166
+ return change;
167
+ };
168
+ const lapsed = await query(tx, sql `select distinct scope, owner_id, thread_id from parlor_chat_turns
169
+ where state = 'running' and lease_until < now() ${ownedBy(owner)}
170
+ order by thread_id`, z.object({
171
+ scope: z.string(),
172
+ owner_id: idSchema("user"),
173
+ thread_id: idSchema("thread"),
174
+ }));
175
+ for (const { scope, owner_id: userId, thread_id: threadId } of lapsed) {
176
+ const { lastSeq } = await lockLog(tx, { scope, userId }, threadId);
177
+ const expired = await query(tx, sql `update parlor_chat_turns
178
+ set state = 'failed', outcome = '{"state":"failed","reason":"host_lost"}'::jsonb,
179
+ finished_at = now()
180
+ where thread_id = ${threadId} and state = 'running' and lease_until < now()
181
+ returning id`, z.object({ id: idSchema("turn") }));
182
+ if (expired.length === 0)
183
+ continue;
184
+ await insertItems(tx, threadId, lastSeq, expired.map(({ id }) => ({
185
+ id: lostItemId(),
186
+ turnId: id,
187
+ kind: "event",
188
+ body: { event: "host_lost" },
189
+ })));
190
+ changed(scope, userId).threads.push(threadId);
191
+ }
192
+ const offline = await query(tx, sql `update parlor_chat_hosts set announced_online = false
193
+ where announced_online and online_until <= now() ${ownedBy(owner)}
194
+ returning id, scope, owner_id`, z.object({ id: idSchema("host"), scope: z.string(), owner_id: idSchema("user") }));
195
+ for (const { id, scope, owner_id: userId } of offline) {
196
+ changed(scope, userId).hosts.push(id);
197
+ }
198
+ return [...changes.values()];
199
+ }
200
+ async function lockRunningTurn(tx, owner, host, turnId) {
201
+ const [found] = await query(tx, sql `select thread_id, client_id, agent_id from parlor_chat_turns
202
+ where id = ${turnId} and host_id = ${host.hostId}
203
+ and owner_id = ${owner.userId} and scope = ${owner.scope}`, z.object({
204
+ thread_id: idSchema("thread"),
205
+ client_id: z.string(),
206
+ agent_id: z.string(),
207
+ }));
208
+ if (!found)
209
+ throw turnMissing();
210
+ const { lastSeq, leased } = await lockLog(tx, owner, found.thread_id, turnId);
211
+ if (!leased)
212
+ throw new ActionError("forbidden", "The turn is not running.");
213
+ return {
214
+ threadId: found.thread_id,
215
+ lastSeq,
216
+ clientId: found.client_id,
217
+ agentId: found.agent_id,
218
+ };
219
+ }
220
+ const leaseSql = (leaseMs) => sql `now() + ${leaseMs}::float8 * interval '1 millisecond'`;
221
+ async function requireThread(db, owner, threadId) {
222
+ const [thread] = await query(db, sql `select id from parlor_chat_threads
223
+ where id = ${threadId} and owner_id = ${owner.userId} and scope = ${owner.scope}`, z.object({ id: z.string() }));
224
+ if (!thread)
225
+ throw threadMissing();
226
+ }
227
+ /** The turns that `items`, a run of consecutive seqs, belong to. */
228
+ async function turnsOf(db, threadId, items) {
229
+ const first = items[0];
230
+ const last = items.at(-1);
231
+ if (!first || !last)
232
+ return [];
233
+ const turns = await query(db, sql `select id, thread_id, host_id, agent_id, state,
234
+ coalesce(state = 'running' and lease_until < now(), false) as lapsed,
235
+ case when state = 'failed' then outcome->>'reason' end as failure
236
+ from parlor_chat_turns
237
+ where thread_id = ${threadId}
238
+ and id in (select turn_id from parlor_chat_items
239
+ where thread_id = ${threadId} and seq between ${first.seq} and ${last.seq})
240
+ order by created_at, id`, turnRow);
241
+ return turns.map((turn) => ({
242
+ turnId: turn.id,
243
+ state: turn.lapsed ? "failed" : turn.state,
244
+ agent: { hostId: turn.host_id, agentId: turn.agent_id },
245
+ failure: turn.lapsed ? "host_lost" : (turn.failure ?? undefined),
246
+ }));
247
+ }
248
+ export const sqlChatStore = {
249
+ async listThreads(db, owner) {
250
+ const rows = await query(db, sql `select ${THREAD_COLUMNS} from parlor_chat_threads
251
+ where owner_id = ${owner.userId} and scope = ${owner.scope}
252
+ order by updated_at desc, id limit ${THREAD_LIST_LIMIT}`, threadRow);
253
+ return rows.map(toThreadSummary);
254
+ },
255
+ async readPage(db, owner, threadId, limit) {
256
+ const [row] = await query(db, threadId
257
+ ? sql `select ${THREAD_COLUMNS} from parlor_chat_threads
258
+ where id = ${threadId} and owner_id = ${owner.userId} and scope = ${owner.scope}`
259
+ : sql `select ${THREAD_COLUMNS} from parlor_chat_threads
260
+ where owner_id = ${owner.userId} and scope = ${owner.scope}
261
+ order by updated_at desc, id limit 1`, threadRow);
262
+ if (!row)
263
+ return { thread: undefined, items: [], turns: [], hasMore: false };
264
+ const rows = await query(db, sql `select ${ITEM_COLUMNS} from parlor_chat_items
265
+ where thread_id = ${row.id} order by seq desc limit ${limit + 1}`, itemRow);
266
+ const hasMore = rows.length > limit;
267
+ const items = rows.slice(0, limit).reverse().map(toLoggedItem);
268
+ return {
269
+ thread: toThreadSummary(row),
270
+ items,
271
+ turns: await turnsOf(db, row.id, items),
272
+ hasMore,
273
+ };
274
+ },
275
+ async readItemsBefore(db, owner, threadId, beforeSeq, limit) {
276
+ const rows = await query(db, sql `select ${ITEM_COLUMNS_OF_I} from parlor_chat_items i
277
+ join parlor_chat_threads t on t.id = i.thread_id
278
+ where t.id = ${threadId} and t.owner_id = ${owner.userId} and t.scope = ${owner.scope}
279
+ and i.seq < ${beforeSeq}
280
+ order by i.seq desc limit ${limit + 1}`, itemRow);
281
+ if (rows.length === 0)
282
+ await requireThread(db, owner, threadId);
283
+ const items = rows.slice(0, limit).reverse().map(toLoggedItem);
284
+ return {
285
+ items,
286
+ turns: await turnsOf(db, threadId, items),
287
+ hasMore: rows.length > limit,
288
+ };
289
+ },
290
+ async listThreadsBefore(db, owner, before, limit) {
291
+ const updated = ms("updated_at");
292
+ const rows = await query(db, sql `select ${THREAD_COLUMNS} from parlor_chat_threads
293
+ where owner_id = ${owner.userId} and scope = ${owner.scope}
294
+ and (${updated} < ${before.updatedAt}
295
+ or (${updated} = ${before.updatedAt} and id < ${before.id}))
296
+ order by updated_at desc, id desc limit ${limit + 1}`, threadRow);
297
+ return {
298
+ threads: rows.slice(0, limit).map(toThreadSummary),
299
+ hasMore: rows.length > limit,
300
+ };
301
+ },
302
+ async readItemsAfter(db, owner, threadId, afterSeq) {
303
+ const rows = await query(db, sql `select ${ITEM_COLUMNS_OF_I} from parlor_chat_items i
304
+ join parlor_chat_threads t on t.id = i.thread_id
305
+ where t.id = ${threadId} and t.owner_id = ${owner.userId} and t.scope = ${owner.scope}
306
+ and i.seq > ${afterSeq}
307
+ order by i.seq`, itemRow);
308
+ if (rows.length === 0)
309
+ await requireThread(db, owner, threadId);
310
+ return rows.map(toLoggedItem);
311
+ },
312
+ async appendUserItems(tx, owner, input) {
313
+ const { threadId, turnId, items, turn } = input;
314
+ if (turn) {
315
+ const prompt = items.find((draft) => draft.kind === "prompt");
316
+ await tx.execute(sql `insert into parlor_chat_threads (id, scope, owner_id, title, host_id, agent_id)
317
+ values (${threadId}, ${owner.scope}, ${owner.userId},
318
+ ${prompt?.kind === "prompt" ? titleOf(prompt.body.text) : ""},
319
+ ${turn.agent.hostId}, ${turn.agent.agentId})
320
+ on conflict (id) do nothing`);
321
+ }
322
+ const { lastSeq } = await lockLog(tx, owner, threadId);
323
+ if (turn) {
324
+ const prompt = items.find((draft) => draft.kind === "prompt");
325
+ if (!prompt)
326
+ throw new ActionError("invalid_input", "A turn starts with a prompt.");
327
+ await createTurn(tx, owner, threadId, turn, prompt.id);
328
+ }
329
+ const known = await itemThreads(tx, items.map((draft) => draft.id));
330
+ const fresh = [];
331
+ for (const draft of items) {
332
+ const where = known.get(draft.id);
333
+ if (where === undefined) {
334
+ if (await admitsUserItem(tx, threadId, turnId, draft))
335
+ fresh.push(draft);
336
+ }
337
+ else if (where !== threadId) {
338
+ throw threadMissing();
339
+ }
340
+ }
341
+ await insertItems(tx, threadId, lastSeq, fresh.map((draft) => ({
342
+ id: draft.id,
343
+ turnId,
344
+ kind: draft.kind,
345
+ body: draft.body,
346
+ })));
347
+ if (turn) {
348
+ await tx.execute(sql `update parlor_chat_threads set host_id = ${turn.agent.hostId}, agent_id = ${turn.agent.agentId}
349
+ where id = ${threadId}`);
350
+ }
351
+ return itemsById(tx, threadId, items.map((draft) => draft.id));
352
+ },
353
+ async hasLapsed(db, owner) {
354
+ return lapsedFor(db, owner);
355
+ },
356
+ expireLapsed,
357
+ async claimTurn(tx, owner, host, leaseMs) {
358
+ const [claimed] = await query(tx, sql `select t.id, t.thread_id, t.agent_id, t.client_id, t.prompt_item_id
359
+ from parlor_chat_turns t
360
+ where t.host_id = ${host.hostId} and t.owner_id = ${owner.userId}
361
+ and t.scope = ${owner.scope} and t.state = 'queued'
362
+ and not exists (
363
+ select 1 from parlor_chat_turns earlier
364
+ where earlier.thread_id = t.thread_id and earlier.state in ('queued', 'running')
365
+ and (earlier.created_at, earlier.id) < (t.created_at, t.id))
366
+ order by t.created_at, t.id
367
+ limit 1
368
+ for update of t skip locked`, z.object({
369
+ id: idSchema("turn"),
370
+ thread_id: idSchema("thread"),
371
+ agent_id: z.string(),
372
+ client_id: z.string(),
373
+ prompt_item_id: idSchema("item"),
374
+ }));
375
+ if (!claimed)
376
+ return undefined;
377
+ const [lease] = await query(tx, sql `update parlor_chat_turns set state = 'running', lease_until = ${leaseSql(leaseMs)}
378
+ where id = ${claimed.id}
379
+ returning ${ms("lease_until")} as lease_until`, z.object({ lease_until: count }));
380
+ const [prompt] = await itemsById(tx, claimed.thread_id, [claimed.prompt_item_id]);
381
+ if (!lease || prompt?.kind !== "prompt") {
382
+ throw new ActionError("internal", "The claimed turn lost its prompt.");
383
+ }
384
+ return {
385
+ id: claimed.thread_id,
386
+ turnId: claimed.id,
387
+ agent: { hostId: host.hostId, agentId: claimed.agent_id },
388
+ clientId: claimed.client_id,
389
+ prompt,
390
+ leaseUntil: lease.lease_until,
391
+ };
392
+ },
393
+ async releaseClaim(tx, owner, host, turnId) {
394
+ const released = await query(tx, sql `update parlor_chat_turns set state = 'queued', lease_until = null
395
+ where id = ${turnId} and host_id = ${host.hostId} and owner_id = ${owner.userId}
396
+ and scope = ${owner.scope} and state = 'running' and lease_until >= now()
397
+ and not exists (
398
+ select 1 from parlor_chat_items i
399
+ where i.turn_id = parlor_chat_turns.id and i.author <> 'user')
400
+ returning thread_id`, z.object({ thread_id: idSchema("thread") }));
401
+ return released.length > 0;
402
+ },
403
+ async appendHostItems(tx, owner, host, turnId, items, leaseMs) {
404
+ const { threadId, lastSeq, clientId, agentId } = await lockRunningTurn(tx, owner, host, turnId);
405
+ const known = await itemThreads(tx, items.map((draft) => draft.id));
406
+ const fresh = [];
407
+ for (const draft of items) {
408
+ const where = known.get(draft.id);
409
+ if (where === undefined)
410
+ fresh.push(draft);
411
+ else if (where !== threadId)
412
+ throw new ActionError("forbidden", "That item id is taken.");
413
+ }
414
+ await insertItems(tx, threadId, lastSeq, fresh.map((draft) => ({
415
+ id: draft.id,
416
+ turnId,
417
+ kind: draft.kind,
418
+ body: draft.body,
419
+ })));
420
+ const [lease] = await query(tx, sql `update parlor_chat_turns set lease_until = ${leaseSql(leaseMs)}
421
+ where id = ${turnId} returning ${ms("lease_until")} as lease_until`, z.object({ lease_until: count }));
422
+ return { threadId, leaseUntil: lease?.lease_until ?? 0, clientId, agentId };
423
+ },
424
+ async finishTurn(tx, owner, host, turnId, outcome) {
425
+ const { threadId } = await lockRunningTurn(tx, owner, host, turnId);
426
+ await tx.execute(sql `update parlor_chat_turns
427
+ set state = ${outcome.state}, outcome = ${JSON.stringify(outcome)}::jsonb, finished_at = now()
428
+ where id = ${turnId}`);
429
+ await tx.execute(sql `update parlor_chat_threads set updated_at = now() where id = ${threadId}`);
430
+ return { threadId };
431
+ },
432
+ async readSignals(db, owner, host, turns) {
433
+ if (turns.length === 0)
434
+ return [];
435
+ const wanted = turns.map(({ turnId, afterSeq }) => sql `(${turnId}::text, ${afterSeq}::bigint)`);
436
+ const rows = await query(db, sql `select ${ITEM_COLUMNS_OF_I}
437
+ from parlor_chat_items i
438
+ join parlor_chat_turns t on t.id = i.turn_id and t.thread_id = i.thread_id
439
+ join (values ${sql.join(wanted, sql `, `)}) as q(turn_id, after_seq)
440
+ on q.turn_id = i.turn_id
441
+ where t.host_id = ${host.hostId} and t.owner_id = ${owner.userId}
442
+ and t.scope = ${owner.scope} and i.kind in ('answer', 'cancel')
443
+ and i.seq > q.after_seq
444
+ order by i.turn_id, i.seq`, itemRow);
445
+ const byTurn = new Map();
446
+ for (const item of rows.map(toLoggedItem)) {
447
+ if (item.kind !== "answer" && item.kind !== "cancel")
448
+ continue;
449
+ const list = byTurn.get(item.turnId) ?? [];
450
+ list.push(item);
451
+ byTurn.set(item.turnId, list);
452
+ }
453
+ return [...byTurn].map(([turnId, items]) => ({ turnId, items }));
454
+ },
455
+ async reportHost(tx, owner, host, report) {
456
+ const ttlMs = report.online ? report.ttlMs : 0;
457
+ const [row] = await query(tx, sql `insert into parlor_chat_hosts
458
+ (id, scope, owner_id, grant_id, protocol, name, platform, agents, devices,
459
+ presence_due, online_until, announced_online)
460
+ values (${host.hostId}, ${owner.scope}, ${owner.userId}, ${host.grantId},
461
+ ${report.protocol}, ${report.name}, ${report.platform},
462
+ ${JSON.stringify(report.agents)}::jsonb,
463
+ ${report.devices ? JSON.stringify(report.devices) : null}::jsonb,
464
+ ${report.presenceDue ? JSON.stringify(report.presenceDue) : null}::jsonb,
465
+ ${leaseSql(ttlMs)}, ${report.online})
466
+ on conflict (id) do update set
467
+ grant_id = excluded.grant_id, protocol = excluded.protocol, name = excluded.name,
468
+ platform = excluded.platform, agents = excluded.agents,
469
+ devices = coalesce(excluded.devices, parlor_chat_hosts.devices),
470
+ presence_due = coalesce(excluded.presence_due, parlor_chat_hosts.presence_due),
471
+ online_until = excluded.online_until,
472
+ announced_online = excluded.announced_online
473
+ where parlor_chat_hosts.owner_id = excluded.owner_id
474
+ and parlor_chat_hosts.scope = excluded.scope
475
+ returning ${ms("online_until")} as online_until`, z.object({ online_until: count }));
476
+ if (!row)
477
+ throw new ActionError("forbidden", "This computer belongs to another account.");
478
+ return { onlineUntil: row.online_until };
479
+ },
480
+ async heartbeat(db, owner, host, ttlMs) {
481
+ const [beat] = await query(db, sql `update parlor_chat_hosts as h
482
+ set online_until = ${leaseSql(ttlMs)}, announced_online = true
483
+ from (select announced_online as was from parlor_chat_hosts where id = ${host.hostId}) as prev
484
+ where h.id = ${host.hostId} and h.owner_id = ${owner.userId}
485
+ and h.scope = ${owner.scope} and h.grant_id = ${host.grantId}
486
+ returning prev.was as was`, z.object({ was: z.boolean() }));
487
+ return { returned: beat !== undefined && !beat.was };
488
+ },
489
+ async listHosts(db, owner) {
490
+ return query(db, sql `select id, grant_id, protocol, name, platform, agents, devices, presence_due,
491
+ online_until > now() as online
492
+ from parlor_chat_hosts
493
+ where owner_id = ${owner.userId} and scope = ${owner.scope}
494
+ order by name, id`, z
495
+ .object({
496
+ id: idSchema("host"),
497
+ grant_id: idSchema("grant"),
498
+ protocol: z.number().int(),
499
+ name: z.string(),
500
+ platform: z.string(),
501
+ agents: z.array(agentInfoSchema),
502
+ devices: z.array(z.string()).nullable(),
503
+ presence_due: z.record(z.string(), z.number()).nullable(),
504
+ online: z.boolean(),
505
+ })
506
+ .transform(({ grant_id, devices, presence_due, ...host }) => ({
507
+ ...host,
508
+ ...(devices ? { devices } : {}),
509
+ ...(presence_due ? { presenceDue: presence_due } : {}),
510
+ grantId: grant_id,
511
+ })));
512
+ },
513
+ async pruneHosts(tx, owner, hostIds) {
514
+ if (hostIds.length === 0)
515
+ return;
516
+ await tx.execute(sql `delete from parlor_chat_hosts
517
+ where owner_id = ${owner.userId} and scope = ${owner.scope} and id in (${inList(hostIds)})`);
518
+ },
519
+ async turnHolder(db, turnId) {
520
+ const [row] = await query(db, sql `select scope, owner_id, thread_id, host_id,
521
+ coalesce(state = 'running' and lease_until >= now(), false) as live
522
+ from parlor_chat_turns where id = ${turnId}`, z.object({
523
+ scope: z.string(),
524
+ owner_id: idSchema("user"),
525
+ thread_id: idSchema("thread"),
526
+ host_id: idSchema("host"),
527
+ live: z.boolean(),
528
+ }));
529
+ return row
530
+ ? {
531
+ owner: { scope: row.scope, userId: row.owner_id },
532
+ threadId: row.thread_id,
533
+ hostId: row.host_id,
534
+ live: row.live,
535
+ }
536
+ : undefined;
537
+ },
538
+ };
539
+ //# sourceMappingURL=store.js.map