@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.
- package/LICENSE +661 -0
- package/README.md +97 -0
- package/dist/agent/config.js +133 -0
- package/dist/agent/credentials.js +179 -0
- package/dist/agent/events.js +253 -0
- package/dist/agent/models.js +15 -0
- package/dist/agent/pi.js +296 -0
- package/dist/boards/boards.js +200 -0
- package/dist/boards/pier.css +445 -0
- package/dist/channels/chains.js +67 -0
- package/dist/channels/chunk.js +28 -0
- package/dist/channels/commands.js +28 -0
- package/dist/channels/config.js +172 -0
- package/dist/channels/control.js +71 -0
- package/dist/channels/conversations.js +65 -0
- package/dist/channels/gatekeeper.js +63 -0
- package/dist/channels/panel.js +233 -0
- package/dist/channels/receipts.js +104 -0
- package/dist/channels/routes.js +110 -0
- package/dist/channels/runtime.js +76 -0
- package/dist/channels/slack-api.js +296 -0
- package/dist/channels/slack-directory.js +77 -0
- package/dist/channels/slack-outbound.js +121 -0
- package/dist/channels/slack-panel.js +122 -0
- package/dist/channels/slack-render.js +214 -0
- package/dist/channels/slack-tool.js +334 -0
- package/dist/channels/slack.js +510 -0
- package/dist/channels/telegram-api.js +78 -0
- package/dist/channels/telegram-panel.js +113 -0
- package/dist/channels/telegram-render.js +96 -0
- package/dist/channels/telegram.js +473 -0
- package/dist/channels/types.js +27 -0
- package/dist/cli.js +101 -0
- package/dist/core/hub.js +53 -0
- package/dist/core/identity.js +66 -0
- package/dist/core/queue.js +11 -0
- package/dist/core/reply.js +202 -0
- package/dist/core/router.js +189 -0
- package/dist/core/types.js +7 -0
- package/dist/db.js +268 -0
- package/dist/log.js +55 -0
- package/dist/main.js +183 -0
- package/dist/paths.js +17 -0
- package/dist/secrets.js +191 -0
- package/dist/service.js +134 -0
- package/dist/settings.js +57 -0
- package/dist/tasks/agent.js +197 -0
- package/dist/tasks/callbacks.js +140 -0
- package/dist/tasks/command.js +74 -0
- package/dist/tasks/definitions.js +316 -0
- package/dist/tasks/execution.js +141 -0
- package/dist/tasks/groups.js +187 -0
- package/dist/tasks/messages.js +248 -0
- package/dist/tasks/routes.js +219 -0
- package/dist/tasks/runs.js +104 -0
- package/dist/tasks/service.js +282 -0
- package/dist/tasks/store.js +168 -0
- package/dist/tasks/tool.js +281 -0
- package/dist/tasks/types.js +5 -0
- package/dist/web/auth.js +280 -0
- package/dist/web/files.js +167 -0
- package/dist/web/public/assets/index-8CinH1uR.css +2 -0
- package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +19 -0
- package/dist/web/public/index.html +251 -0
- package/dist/web/public/manifest.webmanifest +16 -0
- package/dist/web/public/sw.js +21 -0
- package/dist/web/server.js +366 -0
- package/dist/web/session-state.js +39 -0
- package/docs/deploy.md +307 -0
- package/package.json +55 -0
- package/skills/pier-boards/SKILL.md +210 -0
- package/skills/pier-slack/SKILL.md +135 -0
- 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
|
+
}
|