@timqi/pier 0.0.1 → 0.0.3
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 +87 -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-tool.js +28 -3
- 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 +200 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +180 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +192 -18
- package/dist/settings.js +77 -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 +92 -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 +146 -0
- package/dist/web/auth.js +89 -26
- package/dist/web/explorer.js +147 -0
- package/dist/web/files.js +28 -12
- package/dist/web/instance.js +165 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +141 -0
- package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
- package/dist/web/public/assets/index-fASxMPr6.js +90 -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 +50 -32
- package/dist/web/server.js +110 -120
- package/docs/deploy.md +142 -64
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +20 -3
- 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/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.
|
|
@@ -72,6 +85,9 @@ export class TaskService {
|
|
|
72
85
|
this.definitions.resetNextRuns(now);
|
|
73
86
|
this.callbacks.recover(now);
|
|
74
87
|
this.groups.recover(now);
|
|
88
|
+
this.runTimer(tickMs);
|
|
89
|
+
}
|
|
90
|
+
runTimer(tickMs) {
|
|
75
91
|
this.timer = setInterval(() => {
|
|
76
92
|
// The scheduler's own loop: a throw here would stop nothing (the next
|
|
77
93
|
// tick still fires) and say nothing, so due tasks would just stop.
|
|
@@ -79,11 +95,40 @@ export class TaskService {
|
|
|
79
95
|
}, tickMs);
|
|
80
96
|
this.timer.unref();
|
|
81
97
|
}
|
|
98
|
+
/** Undo a `pause()` that was not followed by an exit — the auto-updater
|
|
99
|
+
* drains before handing over, and a handover that never started must not
|
|
100
|
+
* leave the scheduler switched off. Deliberately not `start()`: the boot
|
|
101
|
+
* recovery in there would write off runs this process is still running. */
|
|
102
|
+
unpause(tickMs = 1000) {
|
|
103
|
+
if (this.timer)
|
|
104
|
+
return;
|
|
105
|
+
this.paused = false;
|
|
106
|
+
this.runTimer(tickMs);
|
|
107
|
+
}
|
|
82
108
|
stop() {
|
|
109
|
+
this.pause();
|
|
110
|
+
this.execution.stop();
|
|
111
|
+
}
|
|
112
|
+
/** Stop taking new work but leave running runs alone — a graceful restart
|
|
113
|
+
* (src/drain.ts) waits for them, where stop() would abort them. The
|
|
114
|
+
* scheduler timer goes, and new root runs are refused; children of a run
|
|
115
|
+
* that is still finishing stay allowed, because refusing them would fail
|
|
116
|
+
* the very work the drain is waiting for. */
|
|
117
|
+
pause() {
|
|
118
|
+
this.paused = true;
|
|
83
119
|
if (this.timer)
|
|
84
120
|
clearInterval(this.timer);
|
|
85
121
|
this.timer = null;
|
|
86
|
-
|
|
122
|
+
}
|
|
123
|
+
paused = false;
|
|
124
|
+
refusePaused(parentRunId = null) {
|
|
125
|
+
if (this.paused && parentRunId === null) {
|
|
126
|
+
throw new Error("Pier is restarting — new task runs are not accepted; retry after the restart");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Runs a drain still has to wait for (queued ones start when a slot frees). */
|
|
130
|
+
activeRunCount() {
|
|
131
|
+
return this.store.countActiveRuns();
|
|
87
132
|
}
|
|
88
133
|
list() {
|
|
89
134
|
return this.definitions.list();
|
|
@@ -126,8 +171,8 @@ export class TaskService {
|
|
|
126
171
|
recentRuns(limit = 100) {
|
|
127
172
|
return this.store.listRecentRuns(limit);
|
|
128
173
|
}
|
|
129
|
-
recentMessages(since
|
|
130
|
-
return this.messages.recent(since
|
|
174
|
+
recentMessages(since) {
|
|
175
|
+
return this.messages.recent(since);
|
|
131
176
|
}
|
|
132
177
|
backgroundRuns(sessionId) {
|
|
133
178
|
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
@@ -138,6 +183,7 @@ export class TaskService {
|
|
|
138
183
|
.map((run) => this.backgroundRun(run));
|
|
139
184
|
}
|
|
140
185
|
run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
|
|
186
|
+
this.refusePaused(parentRunId);
|
|
141
187
|
const task = this.get(taskId);
|
|
142
188
|
// `enabled:false` pauses scheduling only; manual and agent triggers still
|
|
143
189
|
// run a paused task on demand. Archiving is the terminal state.
|
|
@@ -145,22 +191,15 @@ export class TaskService {
|
|
|
145
191
|
throw new Error("archived tasks cannot run");
|
|
146
192
|
return this.runs.enqueue(task, input, source, parentRunId, provenance);
|
|
147
193
|
}
|
|
148
|
-
async waitForRun(id
|
|
194
|
+
async waitForRun(id) {
|
|
149
195
|
const current = this.getRun(id);
|
|
150
196
|
if (isTerminal(current.state))
|
|
151
197
|
return current;
|
|
152
|
-
|
|
153
|
-
throw new Error("wait cancelled");
|
|
154
|
-
return new Promise((resolve, reject) => {
|
|
198
|
+
return new Promise((resolve) => {
|
|
155
199
|
let set = this.waiters.get(id);
|
|
156
200
|
if (!set)
|
|
157
201
|
this.waiters.set(id, (set = new Set()));
|
|
158
202
|
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
203
|
});
|
|
165
204
|
}
|
|
166
205
|
/** Cascades: orphans must not outlive the delegation that wanted them. */
|
|
@@ -179,6 +218,7 @@ export class TaskService {
|
|
|
179
218
|
return this.groups.members(id);
|
|
180
219
|
}
|
|
181
220
|
runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
|
|
221
|
+
this.refusePaused(parentRunId);
|
|
182
222
|
return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId);
|
|
183
223
|
}
|
|
184
224
|
descendants(run) {
|
|
@@ -212,6 +252,7 @@ export class TaskService {
|
|
|
212
252
|
return this.messages.reply(messageId, fromSessionId, message);
|
|
213
253
|
}
|
|
214
254
|
resume(id, message, provenance = {}) {
|
|
255
|
+
this.refusePaused();
|
|
215
256
|
const prior = this.getRun(id);
|
|
216
257
|
if (!isTerminal(prior.state))
|
|
217
258
|
throw new Error("run must be terminal before resume");
|
|
@@ -234,23 +275,52 @@ export class TaskService {
|
|
|
234
275
|
tool(raw, callerSessionId) {
|
|
235
276
|
return handleTaskTool(this, this.definitions, this.store, this.messages, raw, callerSessionId);
|
|
236
277
|
}
|
|
278
|
+
/** The deployment's model advice: the operator's pinned menu when one is
|
|
279
|
+
* set, the curated live catalog otherwise — an agent picks from names that
|
|
280
|
+
* exist right now, never from memory. */
|
|
281
|
+
async models() {
|
|
282
|
+
const menu = this.instance?.modelMenu() ?? [];
|
|
283
|
+
if (menu.length)
|
|
284
|
+
return { source: "menu", models: menu };
|
|
285
|
+
return { source: "catalog", models: await this.factory.availableModels() };
|
|
286
|
+
}
|
|
237
287
|
async tick() {
|
|
238
288
|
if (this.ticking)
|
|
239
289
|
return;
|
|
240
290
|
this.ticking = true;
|
|
241
291
|
try {
|
|
242
292
|
const now = Date.now();
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.
|
|
293
|
+
this.sweep("schedule", () => {
|
|
294
|
+
for (const task of this.definitions.claimDue(now)) {
|
|
295
|
+
this.run(task.id, null, task.trigger.type === "watch" ? "watch" : "cron");
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
this.sweep("run callbacks", () => this.callbacks.recover(now));
|
|
299
|
+
this.sweep("group callbacks", () => this.groups.recover(now));
|
|
300
|
+
this.sweep("messages", () => this.messages.retryUndelivered(now));
|
|
249
301
|
}
|
|
250
302
|
finally {
|
|
251
303
|
this.ticking = false;
|
|
252
304
|
}
|
|
253
305
|
}
|
|
306
|
+
/** A delivery nobody can complete. Retrying it forever costs the same
|
|
307
|
+
* silence as dropping it, so it stops here and says so on three surfaces:
|
|
308
|
+
* the operator's log, the record the tool and Console read, and the event
|
|
309
|
+
* stream of the session that was supposed to receive it (§5b). */
|
|
310
|
+
unreachable(sessionId, what, why) {
|
|
311
|
+
log.error(`gave up delivering ${what} to session ${sessionId}: ${why}`);
|
|
312
|
+
this.router.reportTo(sessionId, `${what} could not be delivered — ${why}`);
|
|
313
|
+
}
|
|
314
|
+
/** Four independent sweeps, isolated: one throwing (a group whose member row
|
|
315
|
+
* is gone throws on every pass) must not starve the retries behind it. */
|
|
316
|
+
sweep(what, run) {
|
|
317
|
+
try {
|
|
318
|
+
run();
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
log.error(`${what} sweep failed`, err);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
254
324
|
settled(run) {
|
|
255
325
|
const waiters = this.waiters.get(run.id);
|
|
256
326
|
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,146 @@
|
|
|
1
|
+
// The newer Pier: whether one exists, and when this one may become it.
|
|
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
|
+
// Nothing here installs anything. Applying is handed to whatever supervises
|
|
8
|
+
// this process (service.ts's oneshot unit, injected as `apply`): a web server
|
|
9
|
+
// holding provider keys must not npm-install as its own child, and the two
|
|
10
|
+
// gates below — the operator switched it on, and nothing is running — are why
|
|
11
|
+
// a self-replacing timer is not simply a supply-chain surface (AGENTS.md 8).
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { logger } from "./log.js";
|
|
14
|
+
const log = logger("update");
|
|
15
|
+
const PACKAGE = "@timqi/pier";
|
|
16
|
+
const ENDPOINT = `https://registry.npmjs.org/${PACKAGE}/latest`;
|
|
17
|
+
/** One conditional-GET-sized request against a CDN, so the cost of asking is
|
|
18
|
+
* not what sets this — how long a released fix may sit unnoticed is. */
|
|
19
|
+
const TTL_MS = 30 * 60_000;
|
|
20
|
+
const TIMEOUT_MS = 5_000;
|
|
21
|
+
export const currentVersion = () => createRequire(import.meta.url)("../package.json").version;
|
|
22
|
+
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);
|
|
23
|
+
/**
|
|
24
|
+
* `1.2.3` against `1.10.0`, numerically per field: a string compare would call
|
|
25
|
+
* the second one older. A prerelease suffix loses to the release it precedes,
|
|
26
|
+
* which is all the semver this needs.
|
|
27
|
+
*/
|
|
28
|
+
export function isNewer(candidate, than) {
|
|
29
|
+
const parse = (v) => (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
30
|
+
const [a, b] = [parse(candidate), parse(than)];
|
|
31
|
+
for (let i = 0; i < 3; i++) {
|
|
32
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
33
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
34
|
+
}
|
|
35
|
+
// Equal numbers: a prerelease is older than the release of the same version.
|
|
36
|
+
const pre = (v) => v.split("-")[1] ?? "";
|
|
37
|
+
return pre(candidate) === "" && pre(than) !== "";
|
|
38
|
+
}
|
|
39
|
+
/** One registry answer, cached, shared by every caller. */
|
|
40
|
+
export class UpdateCheck {
|
|
41
|
+
current;
|
|
42
|
+
fetchLatest;
|
|
43
|
+
now;
|
|
44
|
+
#latest = null;
|
|
45
|
+
#checkedAt = 0;
|
|
46
|
+
#inFlight;
|
|
47
|
+
constructor(current = currentVersion(), fetchLatest = fetchLatestVersion, now = Date.now) {
|
|
48
|
+
this.current = current;
|
|
49
|
+
this.fetchLatest = fetchLatest;
|
|
50
|
+
this.now = now;
|
|
51
|
+
}
|
|
52
|
+
/** The last answer, and a refresh in the background when it is stale. Never
|
|
53
|
+
* awaits the network: a workbench that loads is worth more than a fresh
|
|
54
|
+
* version number. */
|
|
55
|
+
status() {
|
|
56
|
+
if (this.now() - this.#checkedAt >= TTL_MS)
|
|
57
|
+
void this.refresh();
|
|
58
|
+
return {
|
|
59
|
+
current: this.current,
|
|
60
|
+
latest: this.#latest,
|
|
61
|
+
available: this.#latest !== null && isNewer(this.#latest, this.current),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** The answer, waiting for the very first check instead of reporting "no
|
|
65
|
+
* idea". A browser asks once per page load, so a process that had never
|
|
66
|
+
* checked told every one of them `latest: null` — which is exactly how a
|
|
67
|
+
* published release looked undetected. Later loads are served from cache. */
|
|
68
|
+
async statusNow() {
|
|
69
|
+
if (this.#checkedAt === 0)
|
|
70
|
+
await this.refresh();
|
|
71
|
+
return this.status();
|
|
72
|
+
}
|
|
73
|
+
/** Ask now. Concurrent callers share the one request. */
|
|
74
|
+
refresh() {
|
|
75
|
+
this.#inFlight ??= this.fetchLatest()
|
|
76
|
+
.then((latest) => {
|
|
77
|
+
this.#latest = latest;
|
|
78
|
+
if (isNewer(latest, this.current))
|
|
79
|
+
log.info(`${latest} is available (running ${this.current})`);
|
|
80
|
+
})
|
|
81
|
+
// A failed check is not a failure: no network, an offline box, a registry
|
|
82
|
+
// hiccup. It is reported once at debug and retried at the next TTL.
|
|
83
|
+
.catch((err) => log.debug(`registry check failed: ${String(err)}`))
|
|
84
|
+
.finally(() => {
|
|
85
|
+
this.#checkedAt = this.now();
|
|
86
|
+
this.#inFlight = undefined;
|
|
87
|
+
});
|
|
88
|
+
return this.#inFlight;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Often enough to catch an idle window on a busy box, and cheap: the registry
|
|
92
|
+
* itself is still only asked once per TTL (`status()` owns that). */
|
|
93
|
+
const AUTO_POLL_MS = 15 * 60_000;
|
|
94
|
+
/** Watch for the moment all three conditions hold. Returns its own stop. */
|
|
95
|
+
export function startAutoUpdate(check, auto, pollMs = AUTO_POLL_MS) {
|
|
96
|
+
// A handover drains first, which can outlast a poll interval; a second
|
|
97
|
+
// attempt on top of it would drain an already-draining Pier.
|
|
98
|
+
let handingOver = false;
|
|
99
|
+
const tick = async () => {
|
|
100
|
+
if (handingOver || !auto.enabled())
|
|
101
|
+
return;
|
|
102
|
+
// Refreshes in the background when stale; today's answer is good enough,
|
|
103
|
+
// because the next tick is a quarter of an hour away either way.
|
|
104
|
+
const { latest, available } = check.status();
|
|
105
|
+
if (!available || !auto.idle())
|
|
106
|
+
return;
|
|
107
|
+
log.info(`auto-update: idle and ${latest ?? "a newer version"} is out — handing over to the updater`);
|
|
108
|
+
handingOver = true;
|
|
109
|
+
try {
|
|
110
|
+
const started = await auto.apply();
|
|
111
|
+
// Not silent (§5b): an update that never happens must not look like an
|
|
112
|
+
// update that was never wanted. `busy` is the one non-start that is
|
|
113
|
+
// fine — someone else is already restarting this Pier.
|
|
114
|
+
if (started === "busy")
|
|
115
|
+
log.info("auto-update: a handover or restart is already in progress");
|
|
116
|
+
else if (started !== "started")
|
|
117
|
+
log.error(`auto-update could not start: ${started}`);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
log.error("auto-update failed", err);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
// Only reached when the handover did *not* take the process with it, so
|
|
124
|
+
// the next tick is allowed to try again.
|
|
125
|
+
handingOver = false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const timer = setInterval(() => void tick(), pollMs);
|
|
129
|
+
timer.unref(); // a pending check must never be what keeps the process alive
|
|
130
|
+
return () => clearInterval(timer);
|
|
131
|
+
}
|
|
132
|
+
export async function fetchLatestVersion() {
|
|
133
|
+
// Plain JSON: the abbreviated-packument content type npm uses for a whole
|
|
134
|
+
// package is a 406 on this endpoint, which answers one version already.
|
|
135
|
+
const res = await fetch(ENDPOINT, {
|
|
136
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
137
|
+
headers: { accept: "application/json" },
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok)
|
|
140
|
+
throw new Error(`registry answered ${res.status}`);
|
|
141
|
+
const body = (await res.json());
|
|
142
|
+
if (typeof body.version !== "string" || !isValidVersion(body.version)) {
|
|
143
|
+
throw new Error("registry answered without a valid version");
|
|
144
|
+
}
|
|
145
|
+
return body.version;
|
|
146
|
+
}
|