@timqi/pier 0.0.1 → 0.0.2
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/README.md +76 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +186 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +86 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +142 -18
- package/dist/settings.js +69 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +79 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +94 -0
- package/dist/web/auth.js +75 -22
- package/dist/web/explorer.js +146 -0
- package/dist/web/files.js +26 -11
- package/dist/web/instance.js +99 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +129 -0
- package/dist/web/public/assets/index-BK64pHmP.js +90 -0
- package/dist/web/public/assets/index-De4GlOq4.css +2 -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 +29 -11
- package/dist/web/public/index.html +43 -28
- package/dist/web/server.js +47 -120
- package/docs/deploy.md +120 -64
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +3 -2
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
package/dist/tasks/routes.js
CHANGED
|
@@ -5,9 +5,11 @@ export function registerTaskRoutes(app, tasks, activity) {
|
|
|
5
5
|
app.get("/api/activity", async (c) => {
|
|
6
6
|
const now = Date.now();
|
|
7
7
|
const recent = c.req.query("scope") === "recent";
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const windowStart = now - 24 * 60 * 60 * 1000;
|
|
9
|
+
// "recent" is a superset of "active": a run still going is part of the
|
|
10
|
+
// last 24h no matter when it was queued, so it never drops a live run.
|
|
11
|
+
const runs = tasks.recentRuns(200).filter((run) => run.state === "queued" || run.state === "running" ||
|
|
12
|
+
(recent && run.queuedAt >= windowStart));
|
|
11
13
|
const listed = await activity.factory.list();
|
|
12
14
|
const byId = new Map(listed.map((session) => [session.id, session]));
|
|
13
15
|
const linkedIds = new Set();
|
|
@@ -21,7 +23,7 @@ export function registerTaskRoutes(app, tasks, activity) {
|
|
|
21
23
|
if (activity.router.stateOf(session.id) === "streaming")
|
|
22
24
|
linkedIds.add(session.id);
|
|
23
25
|
}
|
|
24
|
-
const messages = tasks.recentMessages(
|
|
26
|
+
const messages = tasks.recentMessages(windowStart)
|
|
25
27
|
.filter((message) => runs.some((run) => run.id === message.runId));
|
|
26
28
|
for (const message of messages) {
|
|
27
29
|
if (message.fromSessionId !== "console")
|
package/dist/tasks/service.js
CHANGED
|
@@ -14,7 +14,10 @@ import { isTerminal } from "./types.js";
|
|
|
14
14
|
const log = logger("tasks");
|
|
15
15
|
export class TaskService {
|
|
16
16
|
store;
|
|
17
|
+
factory;
|
|
18
|
+
router;
|
|
17
19
|
hub;
|
|
20
|
+
instance;
|
|
18
21
|
timer = null;
|
|
19
22
|
ticking = false;
|
|
20
23
|
waiters = new Map();
|
|
@@ -24,12 +27,19 @@ export class TaskService {
|
|
|
24
27
|
groups;
|
|
25
28
|
runs;
|
|
26
29
|
execution;
|
|
27
|
-
constructor(store, factory, router, hub
|
|
30
|
+
constructor(store, factory, router, hub,
|
|
31
|
+
/** Structural on purpose: tasks/ must not import settings.ts — main.ts
|
|
32
|
+
* hands in a closure over the store instead. Absent in bare test rigs. */
|
|
33
|
+
instance) {
|
|
28
34
|
this.store = store;
|
|
35
|
+
this.factory = factory;
|
|
36
|
+
this.router = router;
|
|
29
37
|
this.hub = hub;
|
|
30
|
-
this.
|
|
38
|
+
this.instance = instance;
|
|
39
|
+
const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
|
|
40
|
+
this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.resume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }), unreachable);
|
|
31
41
|
this.definitions = new TaskDefinitions(store, factory, router, hub);
|
|
32
|
-
this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run));
|
|
42
|
+
this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
|
|
33
43
|
this.groups = new TaskGroups(store, router, {
|
|
34
44
|
getRun: (id) => this.getRun(id),
|
|
35
45
|
cancel: (id) => { this.cancel(id); },
|
|
@@ -41,7 +51,7 @@ export class TaskService {
|
|
|
41
51
|
background: true,
|
|
42
52
|
groupId,
|
|
43
53
|
}),
|
|
44
|
-
}, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }));
|
|
54
|
+
}, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }), unreachable);
|
|
45
55
|
const agent = new AgentTaskRunner(factory, router, store, this.messages, (run) => this.changed(run));
|
|
46
56
|
this.execution = new TaskExecution(store, this.definitions, this.callbacks, agent, {
|
|
47
57
|
runChild: (taskId, parent) => this.run(taskId, parent.input, "task", parent.id, {
|
|
@@ -61,6 +71,9 @@ export class TaskService {
|
|
|
61
71
|
start(tickMs = 1000) {
|
|
62
72
|
if (this.timer)
|
|
63
73
|
return;
|
|
74
|
+
// A service started again after pause()/stop() takes work again; without
|
|
75
|
+
// this, the refusal would outlive the drain that justified it.
|
|
76
|
+
this.paused = false;
|
|
64
77
|
const now = Date.now();
|
|
65
78
|
// A run that was running when the process died: it is being written off
|
|
66
79
|
// here, and the previous boot's log is where its work stopped.
|
|
@@ -80,10 +93,29 @@ export class TaskService {
|
|
|
80
93
|
this.timer.unref();
|
|
81
94
|
}
|
|
82
95
|
stop() {
|
|
96
|
+
this.pause();
|
|
97
|
+
this.execution.stop();
|
|
98
|
+
}
|
|
99
|
+
/** Stop taking new work but leave running runs alone — a graceful restart
|
|
100
|
+
* (src/drain.ts) waits for them, where stop() would abort them. The
|
|
101
|
+
* scheduler timer goes, and new root runs are refused; children of a run
|
|
102
|
+
* that is still finishing stay allowed, because refusing them would fail
|
|
103
|
+
* the very work the drain is waiting for. */
|
|
104
|
+
pause() {
|
|
105
|
+
this.paused = true;
|
|
83
106
|
if (this.timer)
|
|
84
107
|
clearInterval(this.timer);
|
|
85
108
|
this.timer = null;
|
|
86
|
-
|
|
109
|
+
}
|
|
110
|
+
paused = false;
|
|
111
|
+
refusePaused(parentRunId = null) {
|
|
112
|
+
if (this.paused && parentRunId === null) {
|
|
113
|
+
throw new Error("Pier is restarting — new task runs are not accepted; retry after the restart");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Runs a drain still has to wait for (queued ones start when a slot frees). */
|
|
117
|
+
activeRunCount() {
|
|
118
|
+
return this.store.countActiveRuns();
|
|
87
119
|
}
|
|
88
120
|
list() {
|
|
89
121
|
return this.definitions.list();
|
|
@@ -126,8 +158,8 @@ export class TaskService {
|
|
|
126
158
|
recentRuns(limit = 100) {
|
|
127
159
|
return this.store.listRecentRuns(limit);
|
|
128
160
|
}
|
|
129
|
-
recentMessages(since
|
|
130
|
-
return this.messages.recent(since
|
|
161
|
+
recentMessages(since) {
|
|
162
|
+
return this.messages.recent(since);
|
|
131
163
|
}
|
|
132
164
|
backgroundRuns(sessionId) {
|
|
133
165
|
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
@@ -138,6 +170,7 @@ export class TaskService {
|
|
|
138
170
|
.map((run) => this.backgroundRun(run));
|
|
139
171
|
}
|
|
140
172
|
run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
|
|
173
|
+
this.refusePaused(parentRunId);
|
|
141
174
|
const task = this.get(taskId);
|
|
142
175
|
// `enabled:false` pauses scheduling only; manual and agent triggers still
|
|
143
176
|
// run a paused task on demand. Archiving is the terminal state.
|
|
@@ -145,22 +178,15 @@ export class TaskService {
|
|
|
145
178
|
throw new Error("archived tasks cannot run");
|
|
146
179
|
return this.runs.enqueue(task, input, source, parentRunId, provenance);
|
|
147
180
|
}
|
|
148
|
-
async waitForRun(id
|
|
181
|
+
async waitForRun(id) {
|
|
149
182
|
const current = this.getRun(id);
|
|
150
183
|
if (isTerminal(current.state))
|
|
151
184
|
return current;
|
|
152
|
-
|
|
153
|
-
throw new Error("wait cancelled");
|
|
154
|
-
return new Promise((resolve, reject) => {
|
|
185
|
+
return new Promise((resolve) => {
|
|
155
186
|
let set = this.waiters.get(id);
|
|
156
187
|
if (!set)
|
|
157
188
|
this.waiters.set(id, (set = new Set()));
|
|
158
189
|
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
190
|
});
|
|
165
191
|
}
|
|
166
192
|
/** Cascades: orphans must not outlive the delegation that wanted them. */
|
|
@@ -179,6 +205,7 @@ export class TaskService {
|
|
|
179
205
|
return this.groups.members(id);
|
|
180
206
|
}
|
|
181
207
|
runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
|
|
208
|
+
this.refusePaused(parentRunId);
|
|
182
209
|
return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId);
|
|
183
210
|
}
|
|
184
211
|
descendants(run) {
|
|
@@ -212,6 +239,7 @@ export class TaskService {
|
|
|
212
239
|
return this.messages.reply(messageId, fromSessionId, message);
|
|
213
240
|
}
|
|
214
241
|
resume(id, message, provenance = {}) {
|
|
242
|
+
this.refusePaused();
|
|
215
243
|
const prior = this.getRun(id);
|
|
216
244
|
if (!isTerminal(prior.state))
|
|
217
245
|
throw new Error("run must be terminal before resume");
|
|
@@ -234,23 +262,52 @@ export class TaskService {
|
|
|
234
262
|
tool(raw, callerSessionId) {
|
|
235
263
|
return handleTaskTool(this, this.definitions, this.store, this.messages, raw, callerSessionId);
|
|
236
264
|
}
|
|
265
|
+
/** The deployment's model advice: the operator's pinned menu when one is
|
|
266
|
+
* set, the curated live catalog otherwise — an agent picks from names that
|
|
267
|
+
* exist right now, never from memory. */
|
|
268
|
+
async models() {
|
|
269
|
+
const menu = this.instance?.modelMenu() ?? [];
|
|
270
|
+
if (menu.length)
|
|
271
|
+
return { source: "menu", models: menu };
|
|
272
|
+
return { source: "catalog", models: await this.factory.availableModels() };
|
|
273
|
+
}
|
|
237
274
|
async tick() {
|
|
238
275
|
if (this.ticking)
|
|
239
276
|
return;
|
|
240
277
|
this.ticking = true;
|
|
241
278
|
try {
|
|
242
279
|
const now = Date.now();
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.
|
|
280
|
+
this.sweep("schedule", () => {
|
|
281
|
+
for (const task of this.definitions.claimDue(now)) {
|
|
282
|
+
this.run(task.id, null, task.trigger.type === "watch" ? "watch" : "cron");
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
this.sweep("run callbacks", () => this.callbacks.recover(now));
|
|
286
|
+
this.sweep("group callbacks", () => this.groups.recover(now));
|
|
287
|
+
this.sweep("messages", () => this.messages.retryUndelivered(now));
|
|
249
288
|
}
|
|
250
289
|
finally {
|
|
251
290
|
this.ticking = false;
|
|
252
291
|
}
|
|
253
292
|
}
|
|
293
|
+
/** A delivery nobody can complete. Retrying it forever costs the same
|
|
294
|
+
* silence as dropping it, so it stops here and says so on three surfaces:
|
|
295
|
+
* the operator's log, the record the tool and Console read, and the event
|
|
296
|
+
* stream of the session that was supposed to receive it (§5b). */
|
|
297
|
+
unreachable(sessionId, what, why) {
|
|
298
|
+
log.error(`gave up delivering ${what} to session ${sessionId}: ${why}`);
|
|
299
|
+
this.router.reportTo(sessionId, `${what} could not be delivered — ${why}`);
|
|
300
|
+
}
|
|
301
|
+
/** Four independent sweeps, isolated: one throwing (a group whose member row
|
|
302
|
+
* is gone throws on every pass) must not starve the retries behind it. */
|
|
303
|
+
sweep(what, run) {
|
|
304
|
+
try {
|
|
305
|
+
run();
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
log.error(`${what} sweep failed`, err);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
254
311
|
settled(run) {
|
|
255
312
|
const waiters = this.waiters.get(run.id);
|
|
256
313
|
if (waiters)
|
package/dist/tasks/store.js
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { pierDb } from "../db.js";
|
|
2
|
-
const
|
|
3
|
-
const parseRun = (json) => JSON.parse(json);
|
|
2
|
+
const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
|
|
4
3
|
export class TaskStore {
|
|
5
4
|
db;
|
|
6
5
|
constructor(db = pierDb()) {
|
|
7
6
|
this.db = db;
|
|
8
7
|
}
|
|
8
|
+
// Every table is one JSON column plus query columns; these two are the only
|
|
9
|
+
// readers, and the one seam where a future schema change normalizes old rows
|
|
10
|
+
// (pre-v1 databases are refused outright in db.ts).
|
|
11
|
+
#one(sql, ...params) {
|
|
12
|
+
const row = this.db.prepare(sql).get(...params);
|
|
13
|
+
return row ? JSON.parse(row.json) : undefined;
|
|
14
|
+
}
|
|
15
|
+
#many(sql, ...params) {
|
|
16
|
+
return this.db.prepare(sql).all(...params)
|
|
17
|
+
.map((row) => JSON.parse(row.json));
|
|
18
|
+
}
|
|
9
19
|
listTasks() {
|
|
10
|
-
return this
|
|
11
|
-
.map((r) => parseTask(r.json));
|
|
20
|
+
return this.#many("SELECT json FROM tasks ORDER BY updated_at DESC");
|
|
12
21
|
}
|
|
13
22
|
getTask(id) {
|
|
14
|
-
|
|
15
|
-
return row ? parseTask(row.json) : undefined;
|
|
23
|
+
return this.#one("SELECT json FROM tasks WHERE id = ?", id);
|
|
16
24
|
}
|
|
17
25
|
saveTask(task) {
|
|
18
26
|
this.db.prepare(`
|
|
@@ -21,15 +29,13 @@ export class TaskStore {
|
|
|
21
29
|
`).run(task.id, task.updatedAt, JSON.stringify(task));
|
|
22
30
|
}
|
|
23
31
|
listRuns(taskId, limit = 50, offset = 0) {
|
|
24
|
-
return this
|
|
32
|
+
return this.#many(`
|
|
25
33
|
SELECT json FROM task_runs WHERE task_id = ?
|
|
26
34
|
ORDER BY queued_at DESC LIMIT ? OFFSET ?
|
|
27
|
-
|
|
28
|
-
.map((r) => parseRun(r.json));
|
|
35
|
+
`, taskId, limit, offset);
|
|
29
36
|
}
|
|
30
37
|
getRun(id) {
|
|
31
|
-
|
|
32
|
-
return row ? parseRun(row.json) : undefined;
|
|
38
|
+
return this.#one("SELECT json FROM task_runs WHERE id = ?", id);
|
|
33
39
|
}
|
|
34
40
|
saveRun(run) {
|
|
35
41
|
this.db.prepare(`
|
|
@@ -40,42 +46,39 @@ export class TaskStore {
|
|
|
40
46
|
`).run(run.id, run.taskId, run.queuedAt, run.state, run.callbackState, JSON.stringify(run));
|
|
41
47
|
}
|
|
42
48
|
listRecentRuns(limit = 100) {
|
|
43
|
-
return this
|
|
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));
|
|
49
|
+
return this.#many("SELECT json FROM task_runs ORDER BY queued_at DESC LIMIT ?", clamp(limit, 500));
|
|
47
50
|
}
|
|
48
51
|
listRunsByRoot(rootRunId, limit = 100) {
|
|
49
|
-
return this
|
|
52
|
+
return this.#many(`
|
|
50
53
|
SELECT json FROM task_runs
|
|
51
54
|
WHERE json_extract(json, '$.rootRunId') = ?
|
|
52
55
|
ORDER BY queued_at LIMIT ?
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
`, rootRunId, clamp(limit, 500));
|
|
57
|
+
}
|
|
58
|
+
countActiveRuns() {
|
|
59
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
|
|
60
|
+
return row.n;
|
|
55
61
|
}
|
|
56
62
|
findActiveRun(taskId) {
|
|
57
|
-
|
|
63
|
+
return this.#one(`
|
|
58
64
|
SELECT json FROM task_runs
|
|
59
65
|
WHERE task_id = ? AND state IN ('queued', 'running') LIMIT 1
|
|
60
|
-
|
|
61
|
-
return row ? parseRun(row.json) : undefined;
|
|
66
|
+
`, taskId);
|
|
62
67
|
}
|
|
63
68
|
findActiveRunForTarget(sessionId) {
|
|
64
|
-
|
|
69
|
+
return this.#one(`
|
|
65
70
|
SELECT json FROM task_runs
|
|
66
71
|
WHERE state IN ('queued', 'running')
|
|
67
72
|
AND json_extract(json, '$.targetSessionId') = ?
|
|
68
73
|
ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END, queued_at DESC LIMIT 1
|
|
69
|
-
|
|
70
|
-
return row ? parseRun(row.json) : undefined;
|
|
74
|
+
`, sessionId);
|
|
71
75
|
}
|
|
72
76
|
listRunsForSession(sessionId, limit = 50) {
|
|
73
|
-
return this
|
|
77
|
+
return this.#many(`
|
|
74
78
|
SELECT json FROM task_runs
|
|
75
79
|
WHERE json_extract(json, '$.invokedBySessionId') = ?
|
|
76
80
|
ORDER BY queued_at DESC LIMIT ?
|
|
77
|
-
|
|
78
|
-
.map((row) => parseRun(row.json));
|
|
81
|
+
`, sessionId, clamp(limit, 200));
|
|
79
82
|
}
|
|
80
83
|
saveGroup(group) {
|
|
81
84
|
this.db.prepare(`
|
|
@@ -86,19 +89,18 @@ export class TaskStore {
|
|
|
86
89
|
`).run(group.id, group.createdAt, group.callbackState, group.finishedAt, JSON.stringify(group));
|
|
87
90
|
}
|
|
88
91
|
getGroup(id) {
|
|
89
|
-
|
|
90
|
-
return row ? JSON.parse(row.json) : undefined;
|
|
92
|
+
return this.#one("SELECT json FROM task_groups WHERE id = ?", id);
|
|
91
93
|
}
|
|
92
94
|
/** Unfinished joins plus deliverable group callbacks, for settle and recovery. */
|
|
93
95
|
listOpenGroups(now = Date.now()) {
|
|
94
|
-
return this
|
|
96
|
+
return this.#many(`
|
|
95
97
|
SELECT json FROM task_groups
|
|
96
98
|
WHERE finished_at IS NULL
|
|
97
99
|
OR (callback_state IN ('pending', 'failed')
|
|
98
100
|
AND (json_extract(json, '$.callbackNextAttemptAt') IS NULL
|
|
99
101
|
OR json_extract(json, '$.callbackNextAttemptAt') <= ?))
|
|
100
102
|
ORDER BY created_at
|
|
101
|
-
|
|
103
|
+
`, now);
|
|
102
104
|
}
|
|
103
105
|
saveMessage(message) {
|
|
104
106
|
this.db.prepare(`
|
|
@@ -108,54 +110,45 @@ export class TaskStore {
|
|
|
108
110
|
`).run(message.id, message.runId, message.state, message.createdAt, JSON.stringify(message));
|
|
109
111
|
}
|
|
110
112
|
getMessage(id) {
|
|
111
|
-
|
|
112
|
-
return row ? JSON.parse(row.json) : undefined;
|
|
113
|
+
return this.#one("SELECT json FROM task_messages WHERE id = ?", id);
|
|
113
114
|
}
|
|
114
115
|
listMessages(runId) {
|
|
115
|
-
return this
|
|
116
|
-
|
|
117
|
-
|
|
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));
|
|
116
|
+
return this.#many("SELECT json FROM task_messages WHERE run_id = ? ORDER BY created_at", runId);
|
|
117
|
+
}
|
|
118
|
+
listRecentMessages(since) {
|
|
119
|
+
return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
|
|
124
120
|
}
|
|
125
121
|
/** Messages whose injection never landed: the delivery sweep retries these. */
|
|
126
122
|
listUndeliveredMessages() {
|
|
127
|
-
return this
|
|
128
|
-
SELECT json FROM task_messages WHERE state IN ('pending', 'failed') ORDER BY created_at
|
|
129
|
-
`).all().map((row) => JSON.parse(row.json));
|
|
123
|
+
return this.#many("SELECT json FROM task_messages WHERE state IN ('pending', 'failed') ORDER BY created_at");
|
|
130
124
|
}
|
|
131
125
|
/** Decisions are excluded: they have no timeout and stay answerable across
|
|
132
126
|
* restarts — a reply to a terminal run resumes it. */
|
|
133
127
|
expirePendingMessages() {
|
|
134
|
-
|
|
128
|
+
return this.#many(`
|
|
135
129
|
SELECT json FROM task_messages
|
|
136
130
|
WHERE state = 'pending' AND json_extract(json, '$.kind') != 'decision'
|
|
137
|
-
`).
|
|
138
|
-
return rows.map((row) => {
|
|
139
|
-
const message = JSON.parse(row.json);
|
|
131
|
+
`).map((message) => {
|
|
140
132
|
message.state = "expired";
|
|
141
|
-
|
|
133
|
+
// "Confirmed", not "completed": the input may well have been read — the
|
|
134
|
+
// proof of it lives in the recipient's transcript, which this layer
|
|
135
|
+
// cannot see, and the run it steered is interrupted by the same restart.
|
|
136
|
+
message.error = "Pier restarted before delivery could be confirmed";
|
|
142
137
|
this.saveMessage(message);
|
|
143
138
|
return message;
|
|
144
139
|
});
|
|
145
140
|
}
|
|
146
141
|
listPendingCallbacks(now = Date.now()) {
|
|
147
|
-
return this
|
|
142
|
+
return this.#many(`
|
|
148
143
|
SELECT json FROM task_runs
|
|
149
144
|
WHERE callback_state IN ('pending', 'failed')
|
|
150
145
|
AND (json_extract(json, '$.callbackNextAttemptAt') IS NULL
|
|
151
146
|
OR json_extract(json, '$.callbackNextAttemptAt') <= ?)
|
|
152
147
|
ORDER BY queued_at
|
|
153
|
-
|
|
148
|
+
`, now);
|
|
154
149
|
}
|
|
155
150
|
interruptRunning(now = Date.now()) {
|
|
156
|
-
|
|
157
|
-
return rows.map((row) => {
|
|
158
|
-
const run = parseRun(row.json);
|
|
151
|
+
return this.#many("SELECT json FROM task_runs WHERE state IN ('queued', 'running')").map((run) => {
|
|
159
152
|
run.state = "interrupted";
|
|
160
153
|
run.error = "Pier restarted while the run was active";
|
|
161
154
|
run.finishedAt = now;
|
package/dist/tasks/tool.js
CHANGED
|
@@ -36,13 +36,26 @@ const summarize = (run, pendingDecisionId) => defined({
|
|
|
36
36
|
error: run.error,
|
|
37
37
|
skipReason: run.skipReason,
|
|
38
38
|
});
|
|
39
|
+
/** A list echoes many results at once, so each is capped; a single-run `get`
|
|
40
|
+
* stays whole — it is the escape hatch every truncation note points at. */
|
|
41
|
+
const trimResult = (summary) => {
|
|
42
|
+
if (summary.result?.type !== "agent" || summary.result.text.length <= 2000)
|
|
43
|
+
return summary;
|
|
44
|
+
return {
|
|
45
|
+
...summary,
|
|
46
|
+
result: {
|
|
47
|
+
...summary.result,
|
|
48
|
+
text: `${summary.result.text.slice(0, 2000)}\n[truncated — get run_id ${summary.runId} for the full text]`,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
};
|
|
39
52
|
const summarizeGroup = (group, members, messages) => defined({
|
|
40
53
|
groupId: group.id,
|
|
41
54
|
join: group.join,
|
|
42
55
|
state: group.finishedAt ? "finished" : "running",
|
|
43
56
|
callbackState: group.callbackState,
|
|
44
57
|
winnerRunId: group.winnerRunId,
|
|
45
|
-
members: members.map((run) => summarize(run, messages.openDecisionId(run.id))),
|
|
58
|
+
members: members.map((run) => trimResult(summarize(run, messages.openDecisionId(run.id)))),
|
|
46
59
|
});
|
|
47
60
|
// Model-facing draft shape. Guidance only: runtime truth stays in parseDraft,
|
|
48
61
|
// so schema drift can never loosen boundary validation.
|
|
@@ -92,9 +105,9 @@ export function taskToolSpec(execute) {
|
|
|
92
105
|
return {
|
|
93
106
|
name: "task",
|
|
94
107
|
label: "Pier Task",
|
|
95
|
-
description: "Manage durable Pier tasks and subagents. Agent tasks support reused, fresh, or forked sessions. Run executes a stored task by task_id, a one-shot subagent from an inline task draft, or a core-joined fan-out via tasks[] with join all|first. Get accepts run_id, group_id, or task_id for that task's recent runs. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages. Use steer/follow_up/resume for child control and contact/reply for supervisor decisions.",
|
|
108
|
+
description: "Manage durable Pier tasks and subagents. Agent tasks support reused, fresh, or forked sessions. Run executes a stored task by task_id, a one-shot subagent from an inline task draft, or a core-joined fan-out via tasks[] with join all|first. Get accepts run_id, group_id, or task_id for that task's recent runs. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages. Use steer/follow_up/resume for child control and contact/reply for supervisor decisions. models lists the deployment's model menu (operator pins with intent notes, else the live catalog).",
|
|
96
109
|
parameters: Type.Object({
|
|
97
|
-
operation: strEnum("list", "create", "update", "run", "get", "cancel", "steer", "follow_up", "resume", "contact", "reply"),
|
|
110
|
+
operation: strEnum("list", "create", "update", "run", "get", "cancel", "steer", "follow_up", "resume", "contact", "reply", "models"),
|
|
98
111
|
task_id: Type.Optional(Type.String()),
|
|
99
112
|
run_id: Type.Optional(Type.String()),
|
|
100
113
|
group_id: Type.Optional(Type.String()),
|
|
@@ -126,6 +139,8 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
|
|
|
126
139
|
const active = store.findActiveRunForTarget(callerSessionId);
|
|
127
140
|
if (input.operation === "list")
|
|
128
141
|
return definitions.list().filter((task) => task.kind !== "subagent");
|
|
142
|
+
if (input.operation === "models")
|
|
143
|
+
return host.models();
|
|
129
144
|
if (input.operation === "create") {
|
|
130
145
|
if (active)
|
|
131
146
|
throw new Error("subagents cannot create task definitions");
|
|
@@ -188,7 +203,7 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
|
|
|
188
203
|
// Run history by task: without it, checking what a task did (or whether a
|
|
189
204
|
// cascade landed) means leaving the tool for the database.
|
|
190
205
|
if (input.run_id === undefined && typeof input.task_id === "string") {
|
|
191
|
-
return host.listRuns(input.task_id, 10).map((run) => summarize(run, messages.openDecisionId(run.id)));
|
|
206
|
+
return host.listRuns(input.task_id, 10).map((run) => trimResult(summarize(run, messages.openDecisionId(run.id))));
|
|
192
207
|
}
|
|
193
208
|
const run = host.getRun(requiredString(input.run_id, "run_id"));
|
|
194
209
|
return summarize(run, messages.openDecisionId(run.id));
|
package/dist/tasks/types.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
export const retryDelay = (attempts) => Math.min(60_000, 1000 * 2 ** Math.min(attempts, 6));
|
|
2
|
+
/** Attempts before a delivery is given up on and reported. With the backoff
|
|
3
|
+
* above that is ~4 minutes: long enough to outlast a busy or restarting
|
|
4
|
+
* recipient, short enough that whoever is waiting still cares. */
|
|
5
|
+
export const MAX_DELIVERY_ATTEMPTS = 8;
|
|
6
|
+
/** What a delivery says when it stops trying. */
|
|
7
|
+
export const undeliverable = (attempts, error) => `undeliverable after ${String(attempts)} attempts${error ? `: ${error}` : ""}`;
|
|
1
8
|
export const isTerminal = (state) => state === "succeeded" ||
|
|
2
9
|
state === "failed" ||
|
|
3
10
|
state === "cancelled" ||
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Whether a newer Pier exists. Checking, and nothing more.
|
|
2
|
+
//
|
|
3
|
+
// The registry is asked, not the GitHub API: `registry.npmjs.org` is a CDN with
|
|
4
|
+
// no rate limit and no token, and it is the same place `npm i -g` would look,
|
|
5
|
+
// so what it reports is what an update would actually get.
|
|
6
|
+
//
|
|
7
|
+
// Deliberately only an answer. Applying it is `pier update`, a command someone
|
|
8
|
+
// types: this process holds provider keys and can run a shell, so a service
|
|
9
|
+
// that rewrites its own code on a timer is a supply-chain surface (AGENTS.md 8)
|
|
10
|
+
// — and the updater's hard stop would kill whatever turn was mid-flight.
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { logger } from "./log.js";
|
|
13
|
+
const log = logger("update");
|
|
14
|
+
const PACKAGE = "@timqi/pier";
|
|
15
|
+
const ENDPOINT = `https://registry.npmjs.org/${PACKAGE}/latest`;
|
|
16
|
+
/** Long, because the answer changes on release days and never in between. */
|
|
17
|
+
const TTL_MS = 6 * 60 * 60_000;
|
|
18
|
+
const TIMEOUT_MS = 5_000;
|
|
19
|
+
export const currentVersion = () => createRequire(import.meta.url)("../package.json").version;
|
|
20
|
+
export const isValidVersion = (version) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version);
|
|
21
|
+
/**
|
|
22
|
+
* `1.2.3` against `1.10.0`, numerically per field: a string compare would call
|
|
23
|
+
* the second one older. A prerelease suffix loses to the release it precedes,
|
|
24
|
+
* which is all the semver this needs.
|
|
25
|
+
*/
|
|
26
|
+
export function isNewer(candidate, than) {
|
|
27
|
+
const parse = (v) => (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
28
|
+
const [a, b] = [parse(candidate), parse(than)];
|
|
29
|
+
for (let i = 0; i < 3; i++) {
|
|
30
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
31
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
32
|
+
}
|
|
33
|
+
// Equal numbers: a prerelease is older than the release of the same version.
|
|
34
|
+
const pre = (v) => v.split("-")[1] ?? "";
|
|
35
|
+
return pre(candidate) === "" && pre(than) !== "";
|
|
36
|
+
}
|
|
37
|
+
/** One registry answer, cached, shared by every caller. */
|
|
38
|
+
export class UpdateCheck {
|
|
39
|
+
current;
|
|
40
|
+
fetchLatest;
|
|
41
|
+
now;
|
|
42
|
+
#latest = null;
|
|
43
|
+
#checkedAt = 0;
|
|
44
|
+
#inFlight;
|
|
45
|
+
constructor(current = currentVersion(), fetchLatest = fetchLatestVersion, now = Date.now) {
|
|
46
|
+
this.current = current;
|
|
47
|
+
this.fetchLatest = fetchLatest;
|
|
48
|
+
this.now = now;
|
|
49
|
+
}
|
|
50
|
+
/** The last answer, and a refresh in the background when it is stale. Never
|
|
51
|
+
* awaits the network: a workbench that loads is worth more than a fresh
|
|
52
|
+
* version number. */
|
|
53
|
+
status() {
|
|
54
|
+
if (this.now() - this.#checkedAt >= TTL_MS)
|
|
55
|
+
void this.refresh();
|
|
56
|
+
return {
|
|
57
|
+
current: this.current,
|
|
58
|
+
latest: this.#latest,
|
|
59
|
+
available: this.#latest !== null && isNewer(this.#latest, this.current),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Ask now. Concurrent callers share the one request. */
|
|
63
|
+
refresh() {
|
|
64
|
+
this.#inFlight ??= this.fetchLatest()
|
|
65
|
+
.then((latest) => {
|
|
66
|
+
this.#latest = latest;
|
|
67
|
+
if (isNewer(latest, this.current))
|
|
68
|
+
log.info(`${latest} is available (running ${this.current})`);
|
|
69
|
+
})
|
|
70
|
+
// A failed check is not a failure: no network, an offline box, a registry
|
|
71
|
+
// hiccup. It is reported once at debug and retried at the next TTL.
|
|
72
|
+
.catch((err) => log.debug(`registry check failed: ${String(err)}`))
|
|
73
|
+
.finally(() => {
|
|
74
|
+
this.#checkedAt = this.now();
|
|
75
|
+
this.#inFlight = undefined;
|
|
76
|
+
});
|
|
77
|
+
return this.#inFlight;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function fetchLatestVersion() {
|
|
81
|
+
// Plain JSON: the abbreviated-packument content type npm uses for a whole
|
|
82
|
+
// package is a 406 on this endpoint, which answers one version already.
|
|
83
|
+
const res = await fetch(ENDPOINT, {
|
|
84
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
85
|
+
headers: { accept: "application/json" },
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok)
|
|
88
|
+
throw new Error(`registry answered ${res.status}`);
|
|
89
|
+
const body = (await res.json());
|
|
90
|
+
if (typeof body.version !== "string" || !isValidVersion(body.version)) {
|
|
91
|
+
throw new Error("registry answered without a valid version");
|
|
92
|
+
}
|
|
93
|
+
return body.version;
|
|
94
|
+
}
|