@timqi/pier 0.0.1

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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { logger } from "../log.js";
3
+ import { TaskCallbacks } from "./callbacks.js";
4
+ import { TaskStore } from "./store.js";
5
+ const log = logger("tasks");
6
+ const MAX_DEPTH = 2;
7
+ const MAX_CHILDREN_PER_ROOT = 16;
8
+ export class TaskRunQueue {
9
+ store;
10
+ callbacks;
11
+ getRun;
12
+ execute;
13
+ changed;
14
+ constructor(store, callbacks, getRun, execute, changed) {
15
+ this.store = store;
16
+ this.callbacks = callbacks;
17
+ this.getRun = getRun;
18
+ this.execute = execute;
19
+ this.changed = changed;
20
+ }
21
+ enqueue(definition, input, source, parentRunId, provenance) {
22
+ const id = randomUUID();
23
+ const parent = parentRunId ? this.getRun(parentRunId) : null;
24
+ const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
25
+ const rootRunId = provenance.rootRunId ?? parent?.rootRunId ?? id;
26
+ if (depth > MAX_DEPTH)
27
+ throw new Error(`subagent depth limit is ${MAX_DEPTH}`);
28
+ if (depth > 0 && this.store.listRunsByRoot(rootRunId, MAX_CHILDREN_PER_ROOT + 1).filter((run) => run.depth > 0).length >= MAX_CHILDREN_PER_ROOT) {
29
+ throw new Error(`subagent child limit is ${MAX_CHILDREN_PER_ROOT} per root run`);
30
+ }
31
+ const invokedBySessionId = provenance.invokedBySessionId ?? null;
32
+ const sourceSessionId = provenance.sourceSessionId ?? invokedBySessionId;
33
+ const sessionMode = definition.action.type === "agent"
34
+ ? provenance.sessionMode ?? definition.action.session.mode
35
+ : null;
36
+ if (sessionMode === "fork" && !sourceSessionId)
37
+ throw new Error("fork requires a source session");
38
+ if (sessionMode === "fork" && (source === "cron" || source === "watch")) {
39
+ throw new Error("scheduled and watch runs cannot fork a caller session");
40
+ }
41
+ const targetSessionId = provenance.targetSessionId ?? (definition.action.type === "agent" && sessionMode === "reuse" && definition.action.session.mode === "reuse"
42
+ ? definition.action.session.sessionId
43
+ : null);
44
+ const callbackSessionId = provenance.callbackSessionId !== undefined
45
+ ? provenance.callbackSessionId
46
+ : this.callbacks.target(definition.callback, invokedBySessionId);
47
+ // Overlap is derived from the durable run store — no parallel bookkeeping.
48
+ const interactiveAgent = definition.action.type === "agent" && source !== "cron" && source !== "watch";
49
+ const overlapped = !interactiveAgent && this.store.findActiveRun(definition.id) !== undefined;
50
+ const now = Date.now();
51
+ const run = {
52
+ id,
53
+ taskId: definition.id,
54
+ taskRevision: definition.revision,
55
+ parentRunId,
56
+ groupId: provenance.groupId ?? null,
57
+ rootRunId,
58
+ depth,
59
+ resumedFromRunId: provenance.resumedFromRunId ?? null,
60
+ triggerSource: source,
61
+ invokedBySessionId,
62
+ sourceSessionId,
63
+ targetSessionId,
64
+ sessionMode,
65
+ callbackSessionId,
66
+ background: provenance.background ?? false,
67
+ callbackState: overlapped && callbackSessionId ? "pending" : null,
68
+ callbackAttempts: 0,
69
+ callbackError: null,
70
+ callbackNextAttemptAt: null,
71
+ state: overlapped ? "skipped" : "queued",
72
+ input,
73
+ context: {
74
+ definition: structuredClone(definition),
75
+ ...(provenance.resumePrompt ? { resumePrompt: provenance.resumePrompt } : {}),
76
+ },
77
+ probe: null,
78
+ matched: null,
79
+ result: null,
80
+ error: null,
81
+ skipReason: overlapped ? "overlap" : null,
82
+ queuedAt: now,
83
+ startedAt: null,
84
+ finishedAt: overlapped ? now : null,
85
+ };
86
+ this.store.saveRun(run);
87
+ this.changed(run);
88
+ // Why a run exists is the first question asked of a surprising one, and it
89
+ // is answerable only here: the row keeps the ids, not the reason. A watch
90
+ // probe queues on every interval and mostly matches nothing, so it says so
91
+ // at debug and lets its settled line (execution.ts) carry the news.
92
+ const queued = `run ${id} ${run.state}: ${definition.name} via ${source}` +
93
+ `${overlapped ? " (overlapped)" : ""}${depth > 0 ? ` depth ${String(depth)}` : ""}`;
94
+ if (source === "watch" && !overlapped)
95
+ log.debug(queued);
96
+ else
97
+ log.info(queued);
98
+ if (run.state === "queued")
99
+ this.execute(run);
100
+ else if (run.callbackState === "pending")
101
+ void this.callbacks.deliver(run);
102
+ return run;
103
+ }
104
+ }
@@ -0,0 +1,282 @@
1
+ import { EventHub } from "../core/hub.js";
2
+ import { Router } from "../core/router.js";
3
+ import { logger } from "../log.js";
4
+ import { AgentTaskRunner } from "./agent.js";
5
+ import { TaskCallbacks } from "./callbacks.js";
6
+ import { TaskDefinitions, requiredString } from "./definitions.js";
7
+ import { TaskExecution } from "./execution.js";
8
+ import { TaskGroups } from "./groups.js";
9
+ import { TaskMessenger } from "./messages.js";
10
+ import { TaskRunQueue } from "./runs.js";
11
+ import { TaskStore } from "./store.js";
12
+ import { handleTaskTool } from "./tool.js";
13
+ import { isTerminal } from "./types.js";
14
+ const log = logger("tasks");
15
+ export class TaskService {
16
+ store;
17
+ hub;
18
+ timer = null;
19
+ ticking = false;
20
+ waiters = new Map();
21
+ messages;
22
+ definitions;
23
+ callbacks;
24
+ groups;
25
+ runs;
26
+ execution;
27
+ constructor(store, factory, router, hub) {
28
+ this.store = store;
29
+ this.hub = hub;
30
+ this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.resume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }));
31
+ this.definitions = new TaskDefinitions(store, factory, router, hub);
32
+ this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run));
33
+ this.groups = new TaskGroups(store, router, {
34
+ getRun: (id) => this.getRun(id),
35
+ cancel: (id) => { this.cancel(id); },
36
+ openDecisionId: (runId) => this.messages.openDecisionId(runId),
37
+ startMember: (taskId, groupId, callerSessionId, parentRunId) => this.run(taskId, null, "agent", parentRunId, {
38
+ invokedBySessionId: callerSessionId,
39
+ sourceSessionId: callerSessionId,
40
+ callbackSessionId: null,
41
+ background: true,
42
+ groupId,
43
+ }),
44
+ }, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }));
45
+ const agent = new AgentTaskRunner(factory, router, store, this.messages, (run) => this.changed(run));
46
+ this.execution = new TaskExecution(store, this.definitions, this.callbacks, agent, {
47
+ runChild: (taskId, parent) => this.run(taskId, parent.input, "task", parent.id, {
48
+ invokedBySessionId: parent.invokedBySessionId,
49
+ sourceSessionId: parent.sourceSessionId,
50
+ callbackSessionId: null,
51
+ background: false,
52
+ }),
53
+ waitForRun: (id) => this.waitForRun(id),
54
+ cancel: (id) => { this.cancel(id); },
55
+ settled: (run) => this.settled(run),
56
+ changed: (run) => this.changed(run),
57
+ openDecisionId: (runId) => this.messages.openDecisionId(runId),
58
+ });
59
+ this.runs = new TaskRunQueue(store, this.callbacks, (id) => this.getRun(id), (run) => this.execution.start(run), (run) => this.changed(run));
60
+ }
61
+ start(tickMs = 1000) {
62
+ if (this.timer)
63
+ return;
64
+ const now = Date.now();
65
+ // A run that was running when the process died: it is being written off
66
+ // here, and the previous boot's log is where its work stopped.
67
+ for (const run of this.store.interruptRunning(now)) {
68
+ log.warn(`run ${run.id} (${run.context.definition.name}) interrupted by a restart`);
69
+ this.changed(run);
70
+ }
71
+ this.messages.expirePending();
72
+ this.definitions.resetNextRuns(now);
73
+ this.callbacks.recover(now);
74
+ this.groups.recover(now);
75
+ this.timer = setInterval(() => {
76
+ // The scheduler's own loop: a throw here would stop nothing (the next
77
+ // tick still fires) and say nothing, so due tasks would just stop.
78
+ void this.tick().catch((err) => log.error("scheduler tick failed", err));
79
+ }, tickMs);
80
+ this.timer.unref();
81
+ }
82
+ stop() {
83
+ if (this.timer)
84
+ clearInterval(this.timer);
85
+ this.timer = null;
86
+ this.execution.stop();
87
+ }
88
+ list() {
89
+ return this.definitions.list();
90
+ }
91
+ get(id) {
92
+ return this.definitions.get(id);
93
+ }
94
+ create(raw, creator = "http") {
95
+ return this.definitions.create(raw, creator);
96
+ }
97
+ update(id, raw) {
98
+ return this.definitions.update(id, raw);
99
+ }
100
+ setEnabled(id, enabled) {
101
+ return this.definitions.setEnabled(id, enabled);
102
+ }
103
+ archive(id) {
104
+ return this.definitions.archive(id);
105
+ }
106
+ sessionExists(sessionId) {
107
+ return this.definitions.sessionExists(sessionId);
108
+ }
109
+ listRuns(taskId, limit = 50, offset = 0) {
110
+ this.get(taskId);
111
+ return this.store.listRuns(taskId, Math.min(Math.max(limit, 1), 200), Math.max(offset, 0));
112
+ }
113
+ getRun(id) {
114
+ const run = this.store.getRun(id);
115
+ if (!run)
116
+ throw new Error(`unknown task run: ${id}`);
117
+ return run;
118
+ }
119
+ listMessages(runId) {
120
+ this.getRun(runId);
121
+ return this.messages.list(runId);
122
+ }
123
+ openDecisionId(runId) {
124
+ return this.messages.openDecisionId(runId);
125
+ }
126
+ recentRuns(limit = 100) {
127
+ return this.store.listRecentRuns(limit);
128
+ }
129
+ recentMessages(since, limit = 200) {
130
+ return this.messages.recent(since, limit);
131
+ }
132
+ backgroundRuns(sessionId) {
133
+ const cutoff = Date.now() - 60 * 60 * 1000;
134
+ return this.store.listRunsForSession(sessionId, 50)
135
+ .filter((run) => run.background && (!isTerminal(run.state) || run.queuedAt >= cutoff))
136
+ .slice(0, 20)
137
+ .reverse()
138
+ .map((run) => this.backgroundRun(run));
139
+ }
140
+ run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
141
+ const task = this.get(taskId);
142
+ // `enabled:false` pauses scheduling only; manual and agent triggers still
143
+ // run a paused task on demand. Archiving is the terminal state.
144
+ if (task.archived)
145
+ throw new Error("archived tasks cannot run");
146
+ return this.runs.enqueue(task, input, source, parentRunId, provenance);
147
+ }
148
+ async waitForRun(id, signal) {
149
+ const current = this.getRun(id);
150
+ if (isTerminal(current.state))
151
+ return current;
152
+ if (signal?.aborted)
153
+ throw new Error("wait cancelled");
154
+ return new Promise((resolve, reject) => {
155
+ let set = this.waiters.get(id);
156
+ if (!set)
157
+ this.waiters.set(id, (set = new Set()));
158
+ set.add(resolve);
159
+ // An aborted caller must not leave its waiter behind forever.
160
+ signal?.addEventListener("abort", () => {
161
+ set.delete(resolve);
162
+ reject(new Error("wait cancelled"));
163
+ }, { once: true });
164
+ });
165
+ }
166
+ /** Cascades: orphans must not outlive the delegation that wanted them. */
167
+ cancel(id) {
168
+ const run = this.getRun(id);
169
+ for (const target of [run, ...this.descendants(run)]) {
170
+ if (!isTerminal(target.state))
171
+ this.execution.cancel(target.id);
172
+ }
173
+ return this.getRun(id);
174
+ }
175
+ cancelGroup(id) {
176
+ return this.groups.cancelAll(id);
177
+ }
178
+ getGroup(id) {
179
+ return this.groups.members(id);
180
+ }
181
+ runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
182
+ return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId);
183
+ }
184
+ descendants(run) {
185
+ const byParent = new Map();
186
+ for (const member of this.store.listRunsByRoot(run.rootRunId, 500)) {
187
+ if (!member.parentRunId)
188
+ continue;
189
+ const siblings = byParent.get(member.parentRunId) ?? [];
190
+ siblings.push(member);
191
+ byParent.set(member.parentRunId, siblings);
192
+ }
193
+ const collected = [];
194
+ const queue = [run.id];
195
+ while (queue.length > 0) {
196
+ for (const child of byParent.get(queue.shift()) ?? []) {
197
+ collected.push(child);
198
+ queue.push(child.id);
199
+ }
200
+ }
201
+ return collected;
202
+ }
203
+ async control(id, fromSessionId, mode, message) {
204
+ const run = this.getRun(id);
205
+ if (run.context.definition.action.type !== "agent")
206
+ throw new Error("only Agent runs can be steered");
207
+ if (isTerminal(run.state))
208
+ throw new Error("terminal run cannot be steered; resume it instead");
209
+ return this.messages.control(run, fromSessionId, mode, message);
210
+ }
211
+ reply(messageId, fromSessionId, message) {
212
+ return this.messages.reply(messageId, fromSessionId, message);
213
+ }
214
+ resume(id, message, provenance = {}) {
215
+ const prior = this.getRun(id);
216
+ if (!isTerminal(prior.state))
217
+ throw new Error("run must be terminal before resume");
218
+ if (prior.context.definition.action.type !== "agent" || !prior.targetSessionId) {
219
+ throw new Error("only persisted Agent runs can be resumed");
220
+ }
221
+ const prompt = requiredString(message, "message");
222
+ this.messages.expireDecisions(prior.id, "superseded by a manual resume");
223
+ return this.runs.enqueue(prior.context.definition, null, "agent", null, {
224
+ ...provenance,
225
+ sourceSessionId: provenance.invokedBySessionId ?? prior.invokedBySessionId,
226
+ targetSessionId: prior.targetSessionId,
227
+ sessionMode: "reuse",
228
+ resumedFromRunId: prior.id,
229
+ rootRunId: prior.rootRunId,
230
+ depth: prior.depth,
231
+ resumePrompt: prompt,
232
+ });
233
+ }
234
+ tool(raw, callerSessionId) {
235
+ return handleTaskTool(this, this.definitions, this.store, this.messages, raw, callerSessionId);
236
+ }
237
+ async tick() {
238
+ if (this.ticking)
239
+ return;
240
+ this.ticking = true;
241
+ try {
242
+ const now = Date.now();
243
+ for (const task of this.definitions.claimDue(now)) {
244
+ this.run(task.id, null, task.trigger.type === "watch" ? "watch" : "cron");
245
+ }
246
+ this.callbacks.recover(now);
247
+ this.groups.recover(now);
248
+ this.messages.retryUndelivered(now);
249
+ }
250
+ finally {
251
+ this.ticking = false;
252
+ }
253
+ }
254
+ settled(run) {
255
+ const waiters = this.waiters.get(run.id);
256
+ if (waiters)
257
+ for (const resolve of waiters)
258
+ resolve(run);
259
+ this.waiters.delete(run.id);
260
+ this.groups.onSettled(run);
261
+ }
262
+ backgroundRun(run) {
263
+ return {
264
+ runId: run.id,
265
+ taskId: run.taskId,
266
+ taskName: run.context.definition.name,
267
+ state: run.state,
268
+ targetSessionId: run.targetSessionId,
269
+ sessionMode: run.sessionMode,
270
+ depth: run.depth,
271
+ queuedAt: run.queuedAt,
272
+ startedAt: run.startedAt,
273
+ finishedAt: run.finishedAt,
274
+ };
275
+ }
276
+ changed(run) {
277
+ this.hub.emitWorkspace({ type: "task-run-changed", taskId: run.taskId, runId: run.id });
278
+ if (run.background && run.invokedBySessionId) {
279
+ this.hub.emit(run.invokedBySessionId, { type: "task-status", run: this.backgroundRun(run) });
280
+ }
281
+ }
282
+ }
@@ -0,0 +1,168 @@
1
+ import { pierDb } from "../db.js";
2
+ const parseTask = (json) => JSON.parse(json);
3
+ const parseRun = (json) => JSON.parse(json);
4
+ export class TaskStore {
5
+ db;
6
+ constructor(db = pierDb()) {
7
+ this.db = db;
8
+ }
9
+ listTasks() {
10
+ return this.db.prepare("SELECT json FROM tasks ORDER BY updated_at DESC").all()
11
+ .map((r) => parseTask(r.json));
12
+ }
13
+ getTask(id) {
14
+ const row = this.db.prepare("SELECT json FROM tasks WHERE id = ?").get(id);
15
+ return row ? parseTask(row.json) : undefined;
16
+ }
17
+ saveTask(task) {
18
+ this.db.prepare(`
19
+ INSERT INTO tasks(id, updated_at, json) VALUES (?, ?, ?)
20
+ ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, json=excluded.json
21
+ `).run(task.id, task.updatedAt, JSON.stringify(task));
22
+ }
23
+ listRuns(taskId, limit = 50, offset = 0) {
24
+ return this.db.prepare(`
25
+ SELECT json FROM task_runs WHERE task_id = ?
26
+ ORDER BY queued_at DESC LIMIT ? OFFSET ?
27
+ `).all(taskId, limit, offset)
28
+ .map((r) => parseRun(r.json));
29
+ }
30
+ getRun(id) {
31
+ const row = this.db.prepare("SELECT json FROM task_runs WHERE id = ?").get(id);
32
+ return row ? parseRun(row.json) : undefined;
33
+ }
34
+ saveRun(run) {
35
+ this.db.prepare(`
36
+ INSERT INTO task_runs(id, task_id, queued_at, state, callback_state, json)
37
+ VALUES (?, ?, ?, ?, ?, ?)
38
+ ON CONFLICT(id) DO UPDATE SET
39
+ state=excluded.state, callback_state=excluded.callback_state, json=excluded.json
40
+ `).run(run.id, run.taskId, run.queuedAt, run.state, run.callbackState, JSON.stringify(run));
41
+ }
42
+ listRecentRuns(limit = 100) {
43
+ return this.db.prepare(`
44
+ SELECT json FROM task_runs ORDER BY queued_at DESC LIMIT ?
45
+ `).all(Math.min(Math.max(limit, 1), 500))
46
+ .map((row) => parseRun(row.json));
47
+ }
48
+ listRunsByRoot(rootRunId, limit = 100) {
49
+ return this.db.prepare(`
50
+ SELECT json FROM task_runs
51
+ WHERE json_extract(json, '$.rootRunId') = ?
52
+ ORDER BY queued_at LIMIT ?
53
+ `).all(rootRunId, Math.min(Math.max(limit, 1), 500))
54
+ .map((row) => parseRun(row.json));
55
+ }
56
+ findActiveRun(taskId) {
57
+ const row = this.db.prepare(`
58
+ SELECT json FROM task_runs
59
+ WHERE task_id = ? AND state IN ('queued', 'running') LIMIT 1
60
+ `).get(taskId);
61
+ return row ? parseRun(row.json) : undefined;
62
+ }
63
+ findActiveRunForTarget(sessionId) {
64
+ const row = this.db.prepare(`
65
+ SELECT json FROM task_runs
66
+ WHERE state IN ('queued', 'running')
67
+ AND json_extract(json, '$.targetSessionId') = ?
68
+ ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END, queued_at DESC LIMIT 1
69
+ `).get(sessionId);
70
+ return row ? parseRun(row.json) : undefined;
71
+ }
72
+ listRunsForSession(sessionId, limit = 50) {
73
+ return this.db.prepare(`
74
+ SELECT json FROM task_runs
75
+ WHERE json_extract(json, '$.invokedBySessionId') = ?
76
+ ORDER BY queued_at DESC LIMIT ?
77
+ `).all(sessionId, Math.min(Math.max(limit, 1), 200))
78
+ .map((row) => parseRun(row.json));
79
+ }
80
+ saveGroup(group) {
81
+ this.db.prepare(`
82
+ INSERT INTO task_groups(id, created_at, callback_state, finished_at, json)
83
+ VALUES (?, ?, ?, ?, ?)
84
+ ON CONFLICT(id) DO UPDATE SET
85
+ callback_state=excluded.callback_state, finished_at=excluded.finished_at, json=excluded.json
86
+ `).run(group.id, group.createdAt, group.callbackState, group.finishedAt, JSON.stringify(group));
87
+ }
88
+ getGroup(id) {
89
+ const row = this.db.prepare("SELECT json FROM task_groups WHERE id = ?").get(id);
90
+ return row ? JSON.parse(row.json) : undefined;
91
+ }
92
+ /** Unfinished joins plus deliverable group callbacks, for settle and recovery. */
93
+ listOpenGroups(now = Date.now()) {
94
+ return this.db.prepare(`
95
+ SELECT json FROM task_groups
96
+ WHERE finished_at IS NULL
97
+ OR (callback_state IN ('pending', 'failed')
98
+ AND (json_extract(json, '$.callbackNextAttemptAt') IS NULL
99
+ OR json_extract(json, '$.callbackNextAttemptAt') <= ?))
100
+ ORDER BY created_at
101
+ `).all(now).map((row) => JSON.parse(row.json));
102
+ }
103
+ saveMessage(message) {
104
+ this.db.prepare(`
105
+ INSERT INTO task_messages(id, run_id, state, created_at, json)
106
+ VALUES (?, ?, ?, ?, ?)
107
+ ON CONFLICT(id) DO UPDATE SET state=excluded.state, json=excluded.json
108
+ `).run(message.id, message.runId, message.state, message.createdAt, JSON.stringify(message));
109
+ }
110
+ getMessage(id) {
111
+ const row = this.db.prepare("SELECT json FROM task_messages WHERE id = ?").get(id);
112
+ return row ? JSON.parse(row.json) : undefined;
113
+ }
114
+ listMessages(runId) {
115
+ return this.db.prepare(`
116
+ SELECT json FROM task_messages WHERE run_id = ? ORDER BY created_at
117
+ `).all(runId).map((row) => JSON.parse(row.json));
118
+ }
119
+ listRecentMessages(since, limit = 200) {
120
+ return this.db.prepare(`
121
+ SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT ?
122
+ `).all(since, Math.min(Math.max(limit, 1), 500))
123
+ .map((row) => JSON.parse(row.json));
124
+ }
125
+ /** Messages whose injection never landed: the delivery sweep retries these. */
126
+ listUndeliveredMessages() {
127
+ return this.db.prepare(`
128
+ SELECT json FROM task_messages WHERE state IN ('pending', 'failed') ORDER BY created_at
129
+ `).all().map((row) => JSON.parse(row.json));
130
+ }
131
+ /** Decisions are excluded: they have no timeout and stay answerable across
132
+ * restarts — a reply to a terminal run resumes it. */
133
+ expirePendingMessages() {
134
+ const rows = this.db.prepare(`
135
+ SELECT json FROM task_messages
136
+ WHERE state = 'pending' AND json_extract(json, '$.kind') != 'decision'
137
+ `).all();
138
+ return rows.map((row) => {
139
+ const message = JSON.parse(row.json);
140
+ message.state = "expired";
141
+ message.error = "Pier restarted before delivery completed";
142
+ this.saveMessage(message);
143
+ return message;
144
+ });
145
+ }
146
+ listPendingCallbacks(now = Date.now()) {
147
+ return this.db.prepare(`
148
+ SELECT json FROM task_runs
149
+ WHERE callback_state IN ('pending', 'failed')
150
+ AND (json_extract(json, '$.callbackNextAttemptAt') IS NULL
151
+ OR json_extract(json, '$.callbackNextAttemptAt') <= ?)
152
+ ORDER BY queued_at
153
+ `).all(now).map((row) => parseRun(row.json));
154
+ }
155
+ interruptRunning(now = Date.now()) {
156
+ const rows = this.db.prepare("SELECT json FROM task_runs WHERE state IN ('queued', 'running')").all();
157
+ return rows.map((row) => {
158
+ const run = parseRun(row.json);
159
+ run.state = "interrupted";
160
+ run.error = "Pier restarted while the run was active";
161
+ run.finishedAt = now;
162
+ if (run.callbackSessionId)
163
+ run.callbackState = "pending";
164
+ this.saveRun(run);
165
+ return run;
166
+ });
167
+ }
168
+ }