@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,140 @@
|
|
|
1
|
+
import { Router } from "../core/router.js";
|
|
2
|
+
import { logger } from "../log.js";
|
|
3
|
+
import { TaskStore } from "./store.js";
|
|
4
|
+
const log = logger("tasks");
|
|
5
|
+
export function runResultText(run) {
|
|
6
|
+
let result = run.error ?? "No result";
|
|
7
|
+
if (run.result?.type === "agent")
|
|
8
|
+
result = run.result.text;
|
|
9
|
+
if (run.result?.type === "bash")
|
|
10
|
+
result = run.result.stdout || run.result.stderr || `exit ${String(run.result.exitCode)}`;
|
|
11
|
+
if (run.result?.type === "task")
|
|
12
|
+
result = JSON.stringify(run.result.result);
|
|
13
|
+
if (run.result?.type === "watch")
|
|
14
|
+
result = "Watch condition did not match";
|
|
15
|
+
if (result.length > 8000)
|
|
16
|
+
result = `${result.slice(0, 8000)}\n[truncated; open run ${run.id}]`;
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
export class TaskCallbacks {
|
|
20
|
+
store;
|
|
21
|
+
router;
|
|
22
|
+
changed;
|
|
23
|
+
delivering = new Set();
|
|
24
|
+
constructor(store, router, changed) {
|
|
25
|
+
this.store = store;
|
|
26
|
+
this.router = router;
|
|
27
|
+
this.changed = changed;
|
|
28
|
+
}
|
|
29
|
+
target(callback, origin) {
|
|
30
|
+
if (callback.type === "session")
|
|
31
|
+
return callback.sessionId;
|
|
32
|
+
if (callback.type === "origin")
|
|
33
|
+
return origin;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
recover(now = Date.now()) {
|
|
37
|
+
for (const run of this.store.listPendingCallbacks(now))
|
|
38
|
+
void this.deliver(run);
|
|
39
|
+
}
|
|
40
|
+
/** Delivers the candidate and, in the same system input, every other
|
|
41
|
+
* deliverable callback aimed at the same session: one model turn drains the
|
|
42
|
+
* backlog instead of one turn per run. */
|
|
43
|
+
async deliver(candidate) {
|
|
44
|
+
if (this.delivering.has(candidate.id))
|
|
45
|
+
return;
|
|
46
|
+
const first = this.store.getRun(candidate.id);
|
|
47
|
+
if (!first?.callbackSessionId || (first.callbackState !== "pending" && first.callbackState !== "failed"))
|
|
48
|
+
return;
|
|
49
|
+
const sessionId = first.callbackSessionId;
|
|
50
|
+
// Ignore retry due-times when sweeping the batch: once one callback is
|
|
51
|
+
// deliverable, everything pending for the session rides along.
|
|
52
|
+
const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER).filter((run) => run.callbackSessionId === sessionId && !this.delivering.has(run.id));
|
|
53
|
+
if (!batch.some((run) => run.id === first.id))
|
|
54
|
+
return;
|
|
55
|
+
for (const run of batch)
|
|
56
|
+
this.delivering.add(run.id);
|
|
57
|
+
try {
|
|
58
|
+
const session = await this.router.ensure({ channelId: "task", conversationId: sessionId });
|
|
59
|
+
// Crash-window idempotency: any run id already present in a persisted
|
|
60
|
+
// callback input (single or batched) must not be sent again.
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (const turn of await session.history()) {
|
|
63
|
+
if (turn.role !== "system" || turn.origin?.kind !== "task-callback")
|
|
64
|
+
continue;
|
|
65
|
+
for (const id of turn.origin.runIds ?? [turn.origin.runId])
|
|
66
|
+
seen.add(id);
|
|
67
|
+
}
|
|
68
|
+
const fresh = batch.filter((run) => !seen.has(run.id));
|
|
69
|
+
// Waiting for a busy target is not a delivery attempt: counting it would
|
|
70
|
+
// inflate `callbackAttempts` once per second and skip the real failure
|
|
71
|
+
// backoff straight to its ceiling.
|
|
72
|
+
if (fresh.length > 0 && session.state === "streaming") {
|
|
73
|
+
for (const run of batch) {
|
|
74
|
+
run.callbackNextAttemptAt = Date.now() + 1000;
|
|
75
|
+
this.store.saveRun(run);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const run of batch) {
|
|
80
|
+
run.callbackAttempts += 1;
|
|
81
|
+
run.callbackState = "pending";
|
|
82
|
+
run.callbackError = null;
|
|
83
|
+
this.store.saveRun(run);
|
|
84
|
+
}
|
|
85
|
+
// `systemInput` resolves when the turn it triggers settles, not when Pi
|
|
86
|
+
// accepts the input — so mark delivered first and let a rejection below
|
|
87
|
+
// flip it to failed. Otherwise a recipient turn that runs for minutes
|
|
88
|
+
// leaves the run "pending" and a restart in that window re-delivers.
|
|
89
|
+
const sent = fresh.length > 0
|
|
90
|
+
? session.systemInput(this.text(fresh), {
|
|
91
|
+
kind: "task-callback",
|
|
92
|
+
taskId: fresh[0].taskId,
|
|
93
|
+
runId: fresh[0].id,
|
|
94
|
+
sourceSessionId: fresh[0].targetSessionId,
|
|
95
|
+
runIds: fresh.map((run) => run.id),
|
|
96
|
+
}, "followUp")
|
|
97
|
+
: Promise.resolve();
|
|
98
|
+
for (const run of batch) {
|
|
99
|
+
run.callbackState = "delivered";
|
|
100
|
+
run.callbackNextAttemptAt = null;
|
|
101
|
+
this.store.saveRun(run);
|
|
102
|
+
this.changed(run);
|
|
103
|
+
}
|
|
104
|
+
if (fresh.length > 0) {
|
|
105
|
+
log.debug(`callback for ${fresh.map((run) => run.id).join(", ")} → session ${sessionId}`);
|
|
106
|
+
}
|
|
107
|
+
await sent;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// The delegating agent is waiting for an answer that is now late: the
|
|
111
|
+
// retry is silent, so this line is the only sign it is being retried.
|
|
112
|
+
log.warn(`callback to session ${sessionId} failed, will retry`, error);
|
|
113
|
+
for (const stale of batch) {
|
|
114
|
+
const run = this.store.getRun(stale.id);
|
|
115
|
+
if (!run)
|
|
116
|
+
continue;
|
|
117
|
+
run.callbackState = "failed";
|
|
118
|
+
run.callbackError = String(error);
|
|
119
|
+
run.callbackNextAttemptAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(run.callbackAttempts, 6));
|
|
120
|
+
this.store.saveRun(run);
|
|
121
|
+
this.changed(run);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
for (const run of batch)
|
|
126
|
+
this.delivering.delete(run.id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
text(runs) {
|
|
130
|
+
const sections = runs.map((run) => [
|
|
131
|
+
`Task "${run.context.definition.name}" finished with state: ${run.state}`,
|
|
132
|
+
`Run: ${run.id}`,
|
|
133
|
+
"",
|
|
134
|
+
runResultText(run),
|
|
135
|
+
].join("\n"));
|
|
136
|
+
if (sections.length === 1)
|
|
137
|
+
return sections[0];
|
|
138
|
+
return [`${String(sections.length)} task callbacks`, "", sections.join("\n\n---\n\n")].join("\n");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
const OUTPUT_LIMIT = 1024 * 1024;
|
|
3
|
+
class CappedOutput {
|
|
4
|
+
chunks = [];
|
|
5
|
+
bytes = 0;
|
|
6
|
+
truncated = false;
|
|
7
|
+
add(chunk) {
|
|
8
|
+
if (this.bytes >= OUTPUT_LIMIT) {
|
|
9
|
+
this.truncated = true;
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const kept = chunk.subarray(0, OUTPUT_LIMIT - this.bytes);
|
|
13
|
+
this.chunks.push(kept);
|
|
14
|
+
this.bytes += kept.length;
|
|
15
|
+
if (kept.length < chunk.length)
|
|
16
|
+
this.truncated = true;
|
|
17
|
+
}
|
|
18
|
+
text() {
|
|
19
|
+
return Buffer.concat(this.chunks).toString("utf8");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function runBash(script, cwd, input, signal) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
if (signal.aborted)
|
|
25
|
+
return reject(new Error("cancelled"));
|
|
26
|
+
const encodedInput = JSON.stringify(input ?? null);
|
|
27
|
+
const child = spawn("/bin/bash", ["-lc", script], {
|
|
28
|
+
cwd,
|
|
29
|
+
detached: process.platform !== "win32",
|
|
30
|
+
env: { ...process.env, PIER_TASK_INPUT: encodedInput },
|
|
31
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
32
|
+
});
|
|
33
|
+
const stdout = new CappedOutput();
|
|
34
|
+
const stderr = new CappedOutput();
|
|
35
|
+
let settled = false;
|
|
36
|
+
const kill = () => {
|
|
37
|
+
if (!child.pid)
|
|
38
|
+
return;
|
|
39
|
+
try {
|
|
40
|
+
if (process.platform === "win32")
|
|
41
|
+
child.kill("SIGTERM");
|
|
42
|
+
else
|
|
43
|
+
process.kill(-child.pid, "SIGTERM");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// The process may have exited between the state check and kill.
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
signal.addEventListener("abort", kill, { once: true });
|
|
50
|
+
child.stdout.on("data", (chunk) => stdout.add(chunk));
|
|
51
|
+
child.stderr.on("data", (chunk) => stderr.add(chunk));
|
|
52
|
+
child.on("error", (err) => {
|
|
53
|
+
if (settled)
|
|
54
|
+
return;
|
|
55
|
+
settled = true;
|
|
56
|
+
signal.removeEventListener("abort", kill);
|
|
57
|
+
reject(err);
|
|
58
|
+
});
|
|
59
|
+
child.on("close", (code) => {
|
|
60
|
+
if (settled)
|
|
61
|
+
return;
|
|
62
|
+
settled = true;
|
|
63
|
+
signal.removeEventListener("abort", kill);
|
|
64
|
+
resolve({
|
|
65
|
+
exitCode: code,
|
|
66
|
+
stdout: stdout.text(),
|
|
67
|
+
stderr: stderr.text(),
|
|
68
|
+
stdoutTruncated: stdout.truncated,
|
|
69
|
+
stderrTruncated: stderr.truncated,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
child.stdin.end(encodedInput);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { Cron } from "croner";
|
|
4
|
+
import { isThinkingLevel } from "../core/types.js";
|
|
5
|
+
import { EventHub } from "../core/hub.js";
|
|
6
|
+
import { Router } from "../core/router.js";
|
|
7
|
+
import { TaskStore } from "./store.js";
|
|
8
|
+
const DEFAULT_TIMEOUT = 900;
|
|
9
|
+
const MIN_WATCH_SECONDS = 5;
|
|
10
|
+
export const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
11
|
+
? value
|
|
12
|
+
: null;
|
|
13
|
+
export const requiredString = (value, field) => {
|
|
14
|
+
if (typeof value !== "string" || !value.trim())
|
|
15
|
+
throw new Error(`${field} required`);
|
|
16
|
+
return value.trim();
|
|
17
|
+
};
|
|
18
|
+
function parseTrigger(raw) {
|
|
19
|
+
const value = record(raw);
|
|
20
|
+
if (!value)
|
|
21
|
+
throw new Error("trigger required");
|
|
22
|
+
if (value.type === "manual")
|
|
23
|
+
return { type: "manual" };
|
|
24
|
+
if (value.type === "cron") {
|
|
25
|
+
const expression = requiredString(value.expression, "cron expression");
|
|
26
|
+
if (expression.split(/\s+/).length !== 5)
|
|
27
|
+
throw new Error("cron expression must have five fields");
|
|
28
|
+
const timezone = requiredString(value.timezone, "cron timezone");
|
|
29
|
+
new Intl.DateTimeFormat("en", { timeZone: timezone }).format();
|
|
30
|
+
new Cron(expression, { timezone });
|
|
31
|
+
return { type: "cron", expression, timezone };
|
|
32
|
+
}
|
|
33
|
+
if (value.type === "watch") {
|
|
34
|
+
const intervalSeconds = Number(value.intervalSeconds);
|
|
35
|
+
if (!Number.isInteger(intervalSeconds) || intervalSeconds < MIN_WATCH_SECONDS) {
|
|
36
|
+
throw new Error(`watch interval must be at least ${MIN_WATCH_SECONDS} seconds`);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
type: "watch",
|
|
40
|
+
script: requiredString(value.script, "watch script"),
|
|
41
|
+
cwd: requiredString(value.cwd, "watch cwd"),
|
|
42
|
+
intervalSeconds,
|
|
43
|
+
mode: value.mode === "once" ? "once" : "repeat",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
throw new Error("unknown trigger type");
|
|
47
|
+
}
|
|
48
|
+
export function nextRunAt(trigger, from) {
|
|
49
|
+
if (trigger.type === "manual")
|
|
50
|
+
return null;
|
|
51
|
+
if (trigger.type === "watch")
|
|
52
|
+
return from + trigger.intervalSeconds * 1000;
|
|
53
|
+
return new Cron(trigger.expression, { timezone: trigger.timezone }).nextRun(new Date(from))?.getTime() ?? null;
|
|
54
|
+
}
|
|
55
|
+
function parseLaunch(raw) {
|
|
56
|
+
if (raw === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
const value = record(raw);
|
|
59
|
+
if (!value)
|
|
60
|
+
throw new Error("agent launch policy must be an object");
|
|
61
|
+
const launch = {};
|
|
62
|
+
if (value.model !== undefined) {
|
|
63
|
+
const model = record(value.model);
|
|
64
|
+
if (!model)
|
|
65
|
+
throw new Error("agent model must be an object");
|
|
66
|
+
launch.model = {
|
|
67
|
+
provider: requiredString(model.provider, "model provider"),
|
|
68
|
+
id: requiredString(model.id, "model id"),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (value.thinking !== undefined) {
|
|
72
|
+
if (!isThinkingLevel(value.thinking)) {
|
|
73
|
+
throw new Error("invalid agent thinking level");
|
|
74
|
+
}
|
|
75
|
+
launch.thinking = value.thinking;
|
|
76
|
+
}
|
|
77
|
+
if (value.capabilities !== undefined) {
|
|
78
|
+
if (value.capabilities !== "read" && value.capabilities !== "write") {
|
|
79
|
+
throw new Error("agent capabilities must be read or write");
|
|
80
|
+
}
|
|
81
|
+
launch.capabilities = value.capabilities;
|
|
82
|
+
}
|
|
83
|
+
return Object.keys(launch).length ? launch : undefined;
|
|
84
|
+
}
|
|
85
|
+
export class TaskDefinitions {
|
|
86
|
+
store;
|
|
87
|
+
factory;
|
|
88
|
+
router;
|
|
89
|
+
hub;
|
|
90
|
+
constructor(store, factory, router, hub) {
|
|
91
|
+
this.store = store;
|
|
92
|
+
this.factory = factory;
|
|
93
|
+
this.router = router;
|
|
94
|
+
this.hub = hub;
|
|
95
|
+
}
|
|
96
|
+
list() { return this.store.listTasks(); }
|
|
97
|
+
get(id) {
|
|
98
|
+
const task = this.store.getTask(id);
|
|
99
|
+
if (!task)
|
|
100
|
+
throw new Error(`unknown task: ${id}`);
|
|
101
|
+
return task;
|
|
102
|
+
}
|
|
103
|
+
async create(raw, creator = "http", kind = "task") {
|
|
104
|
+
// The tool schema marks trigger optional, so a trigger-less new definition
|
|
105
|
+
// means manual. Update keeps requiring it: replacing a cron task with a
|
|
106
|
+
// draft that forgot its trigger must not silently unschedule it.
|
|
107
|
+
const value = record(raw);
|
|
108
|
+
const draft = await this.parseDraft(value && value.trigger === undefined ? { ...value, trigger: { type: "manual" } } : raw);
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const task = {
|
|
111
|
+
id: randomUUID(),
|
|
112
|
+
kind,
|
|
113
|
+
name: draft.name,
|
|
114
|
+
description: draft.description ?? "",
|
|
115
|
+
enabled: draft.enabled ?? true,
|
|
116
|
+
archived: false,
|
|
117
|
+
revision: 1,
|
|
118
|
+
trigger: draft.trigger,
|
|
119
|
+
action: draft.action,
|
|
120
|
+
callback: draft.callback ?? { type: "none" },
|
|
121
|
+
timeoutSeconds: draft.timeoutSeconds ?? DEFAULT_TIMEOUT,
|
|
122
|
+
nextRunAt: null,
|
|
123
|
+
creator,
|
|
124
|
+
createdBySessionId: creator.startsWith("session:") ? creator.slice("session:".length) : null,
|
|
125
|
+
createdAt: now,
|
|
126
|
+
updatedAt: now,
|
|
127
|
+
};
|
|
128
|
+
this.assertNoCycle(task);
|
|
129
|
+
task.nextRunAt = task.enabled ? nextRunAt(task.trigger, now) : null;
|
|
130
|
+
this.store.saveTask(task);
|
|
131
|
+
this.changed();
|
|
132
|
+
return task;
|
|
133
|
+
}
|
|
134
|
+
async update(id, raw) {
|
|
135
|
+
const old = this.get(id);
|
|
136
|
+
if (old.archived)
|
|
137
|
+
throw new Error("archived tasks cannot be edited");
|
|
138
|
+
const draft = await this.parseDraft(raw);
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const task = {
|
|
141
|
+
...old,
|
|
142
|
+
name: draft.name,
|
|
143
|
+
description: draft.description ?? "",
|
|
144
|
+
enabled: draft.enabled ?? old.enabled,
|
|
145
|
+
revision: old.revision + 1,
|
|
146
|
+
trigger: draft.trigger,
|
|
147
|
+
action: draft.action,
|
|
148
|
+
callback: draft.callback ?? old.callback,
|
|
149
|
+
timeoutSeconds: draft.timeoutSeconds ?? DEFAULT_TIMEOUT,
|
|
150
|
+
nextRunAt: null,
|
|
151
|
+
updatedAt: now,
|
|
152
|
+
};
|
|
153
|
+
this.assertNoCycle(task);
|
|
154
|
+
task.nextRunAt = task.enabled ? nextRunAt(task.trigger, now) : null;
|
|
155
|
+
this.store.saveTask(task);
|
|
156
|
+
this.changed();
|
|
157
|
+
return task;
|
|
158
|
+
}
|
|
159
|
+
setEnabled(id, enabled) {
|
|
160
|
+
const task = this.get(id);
|
|
161
|
+
if (task.archived && enabled)
|
|
162
|
+
throw new Error("archived tasks cannot be resumed");
|
|
163
|
+
task.enabled = enabled;
|
|
164
|
+
task.nextRunAt = enabled ? nextRunAt(task.trigger, Date.now()) : null;
|
|
165
|
+
task.updatedAt = Date.now();
|
|
166
|
+
this.store.saveTask(task);
|
|
167
|
+
this.changed();
|
|
168
|
+
return task;
|
|
169
|
+
}
|
|
170
|
+
archive(id) {
|
|
171
|
+
const task = this.get(id);
|
|
172
|
+
task.archived = true;
|
|
173
|
+
task.enabled = false;
|
|
174
|
+
task.nextRunAt = null;
|
|
175
|
+
task.updatedAt = Date.now();
|
|
176
|
+
this.store.saveTask(task);
|
|
177
|
+
this.changed();
|
|
178
|
+
return task;
|
|
179
|
+
}
|
|
180
|
+
resetNextRuns(now) {
|
|
181
|
+
for (const task of this.store.listTasks()) {
|
|
182
|
+
task.nextRunAt = task.enabled && !task.archived ? nextRunAt(task.trigger, now) : null;
|
|
183
|
+
this.store.saveTask(task);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
claimDue(now) {
|
|
187
|
+
const due = [];
|
|
188
|
+
for (const task of this.store.listTasks()) {
|
|
189
|
+
if (!task.enabled || task.archived || task.nextRunAt === null || task.nextRunAt > now)
|
|
190
|
+
continue;
|
|
191
|
+
task.nextRunAt = nextRunAt(task.trigger, now);
|
|
192
|
+
this.store.saveTask(task);
|
|
193
|
+
due.push(task);
|
|
194
|
+
}
|
|
195
|
+
if (due.length)
|
|
196
|
+
this.changed();
|
|
197
|
+
return due;
|
|
198
|
+
}
|
|
199
|
+
async sessionExists(sessionId) {
|
|
200
|
+
return this.router.stateOf(sessionId) !== undefined ||
|
|
201
|
+
(await this.factory.list()).some((session) => session.id === sessionId);
|
|
202
|
+
}
|
|
203
|
+
async parseDraft(raw) {
|
|
204
|
+
const value = record(raw);
|
|
205
|
+
if (!value)
|
|
206
|
+
throw new Error("task definition required");
|
|
207
|
+
const timeoutSeconds = value.timeoutSeconds === undefined ? DEFAULT_TIMEOUT : Number(value.timeoutSeconds);
|
|
208
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 86_400) {
|
|
209
|
+
throw new Error("timeoutSeconds must be between 1 and 86400");
|
|
210
|
+
}
|
|
211
|
+
const trigger = parseTrigger(value.trigger);
|
|
212
|
+
if (trigger.type === "watch")
|
|
213
|
+
await this.assertDirectory(trigger.cwd);
|
|
214
|
+
const actionRaw = record(value.action);
|
|
215
|
+
// Self-documenting: tool callers (models) recover from this in one retry.
|
|
216
|
+
if (!actionRaw) {
|
|
217
|
+
throw new Error('action required, e.g. {"type":"agent","session":{"mode":"fresh","cwd":"/abs/path"},"prompt":"..."}');
|
|
218
|
+
}
|
|
219
|
+
let action;
|
|
220
|
+
if (actionRaw.type === "bash") {
|
|
221
|
+
const cwd = requiredString(actionRaw.cwd, "bash cwd");
|
|
222
|
+
await this.assertDirectory(cwd);
|
|
223
|
+
action = { type: "bash", script: requiredString(actionRaw.script, "bash script"), cwd };
|
|
224
|
+
}
|
|
225
|
+
else if (actionRaw.type === "task") {
|
|
226
|
+
const taskId = requiredString(actionRaw.taskId, "target task");
|
|
227
|
+
this.get(taskId);
|
|
228
|
+
action = { type: "task", taskId };
|
|
229
|
+
}
|
|
230
|
+
else if (actionRaw.type === "agent") {
|
|
231
|
+
action = await this.parseAgentAction(actionRaw, trigger);
|
|
232
|
+
}
|
|
233
|
+
else
|
|
234
|
+
throw new Error("unknown action type");
|
|
235
|
+
return {
|
|
236
|
+
name: requiredString(value.name, "name"),
|
|
237
|
+
description: typeof value.description === "string" ? value.description.trim() : "",
|
|
238
|
+
enabled: typeof value.enabled === "boolean" ? value.enabled : undefined,
|
|
239
|
+
trigger,
|
|
240
|
+
action,
|
|
241
|
+
callback: await this.parseCallback(value.callback),
|
|
242
|
+
timeoutSeconds,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async parseAgentAction(raw, trigger) {
|
|
246
|
+
const prompt = requiredString(raw.prompt, "agent prompt");
|
|
247
|
+
const launch = parseLaunch(raw.launch);
|
|
248
|
+
const input = record(raw.session);
|
|
249
|
+
let session;
|
|
250
|
+
if (input?.mode === "reuse") {
|
|
251
|
+
const sessionId = requiredString(input.sessionId, "agent session");
|
|
252
|
+
if (!(await this.sessionExists(sessionId)))
|
|
253
|
+
throw new Error(`unknown session: ${sessionId}`);
|
|
254
|
+
session = { mode: "reuse", sessionId };
|
|
255
|
+
}
|
|
256
|
+
else if (input?.mode === "fresh") {
|
|
257
|
+
const cwd = requiredString(input.cwd, "agent cwd");
|
|
258
|
+
await this.assertDirectory(cwd);
|
|
259
|
+
session = { mode: "fresh", cwd };
|
|
260
|
+
}
|
|
261
|
+
else if (input?.mode === "fork") {
|
|
262
|
+
if (trigger.type !== "manual")
|
|
263
|
+
throw new Error("fork Agent tasks must use a manual trigger");
|
|
264
|
+
const cwd = typeof input.cwd === "string" && input.cwd.trim() ? input.cwd.trim() : undefined;
|
|
265
|
+
if (cwd)
|
|
266
|
+
await this.assertDirectory(cwd);
|
|
267
|
+
session = { mode: "fork", ...(cwd ? { cwd } : {}) };
|
|
268
|
+
}
|
|
269
|
+
else if (typeof raw.sessionId === "string" && raw.sessionId.trim()) {
|
|
270
|
+
const sessionId = raw.sessionId.trim();
|
|
271
|
+
if (!(await this.sessionExists(sessionId)))
|
|
272
|
+
throw new Error(`unknown session: ${sessionId}`);
|
|
273
|
+
session = { mode: "reuse", sessionId };
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Validation never mutates: a dedicated session is created explicitly
|
|
277
|
+
// (POST /api/sessions) and then referenced with mode:"reuse".
|
|
278
|
+
throw new Error('agent session policy required, e.g. {"mode":"fresh","cwd":"/abs/path"} or {"mode":"fork"} or {"mode":"reuse","sessionId":"..."}');
|
|
279
|
+
}
|
|
280
|
+
if (session.mode === "reuse" && launch)
|
|
281
|
+
throw new Error("launch policy only applies to fresh or fork sessions");
|
|
282
|
+
return { type: "agent", session, prompt, ...(launch ? { launch } : {}) };
|
|
283
|
+
}
|
|
284
|
+
async parseCallback(raw) {
|
|
285
|
+
if (raw === undefined)
|
|
286
|
+
return undefined;
|
|
287
|
+
const value = record(raw);
|
|
288
|
+
if (!value || (value.type !== "none" && value.type !== "origin" && value.type !== "session")) {
|
|
289
|
+
throw new Error("invalid callback");
|
|
290
|
+
}
|
|
291
|
+
if (value.type !== "session")
|
|
292
|
+
return { type: value.type };
|
|
293
|
+
const sessionId = requiredString(value.sessionId, "callback session");
|
|
294
|
+
if (!(await this.sessionExists(sessionId)))
|
|
295
|
+
throw new Error(`unknown session: ${sessionId}`);
|
|
296
|
+
return { type: "session", sessionId };
|
|
297
|
+
}
|
|
298
|
+
assertNoCycle(candidate) {
|
|
299
|
+
const byId = new Map(this.store.listTasks().map((task) => [task.id, task]));
|
|
300
|
+
byId.set(candidate.id, candidate);
|
|
301
|
+
const seen = new Set();
|
|
302
|
+
let task = candidate;
|
|
303
|
+
while (task?.action.type === "task") {
|
|
304
|
+
if (seen.has(task.id))
|
|
305
|
+
throw new Error("task dependency cycle");
|
|
306
|
+
seen.add(task.id);
|
|
307
|
+
task = byId.get(task.action.taskId);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async assertDirectory(cwd) {
|
|
311
|
+
const info = await stat(cwd).catch(() => null);
|
|
312
|
+
if (!info?.isDirectory())
|
|
313
|
+
throw new Error(`working directory does not exist: ${cwd}`);
|
|
314
|
+
}
|
|
315
|
+
changed() { this.hub.emitWorkspace({ type: "tasks-changed" }); }
|
|
316
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { logger } from "../log.js";
|
|
2
|
+
import { AgentTaskRunner } from "./agent.js";
|
|
3
|
+
import { TaskCallbacks } from "./callbacks.js";
|
|
4
|
+
import { runBash } from "./command.js";
|
|
5
|
+
import { TaskDefinitions } from "./definitions.js";
|
|
6
|
+
import { TaskStore } from "./store.js";
|
|
7
|
+
const log = logger("tasks");
|
|
8
|
+
export class TaskExecution {
|
|
9
|
+
store;
|
|
10
|
+
definitions;
|
|
11
|
+
callbacks;
|
|
12
|
+
agent;
|
|
13
|
+
host;
|
|
14
|
+
controllers = new Map();
|
|
15
|
+
constructor(store, definitions, callbacks, agent, host) {
|
|
16
|
+
this.store = store;
|
|
17
|
+
this.definitions = definitions;
|
|
18
|
+
this.callbacks = callbacks;
|
|
19
|
+
this.agent = agent;
|
|
20
|
+
this.host = host;
|
|
21
|
+
}
|
|
22
|
+
start(run) {
|
|
23
|
+
void this.execute(run);
|
|
24
|
+
}
|
|
25
|
+
stop() {
|
|
26
|
+
for (const controller of this.controllers.values())
|
|
27
|
+
controller.abort();
|
|
28
|
+
}
|
|
29
|
+
cancel(id) {
|
|
30
|
+
log.info(`run ${id} cancel requested`);
|
|
31
|
+
this.controllers.get(id)?.abort();
|
|
32
|
+
}
|
|
33
|
+
async execute(run) {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
this.controllers.set(run.id, controller);
|
|
36
|
+
let timedOut = false;
|
|
37
|
+
let cause;
|
|
38
|
+
const timeout = setTimeout(() => {
|
|
39
|
+
timedOut = true;
|
|
40
|
+
controller.abort();
|
|
41
|
+
}, run.context.definition.timeoutSeconds * 1000);
|
|
42
|
+
timeout.unref();
|
|
43
|
+
try {
|
|
44
|
+
const { definition } = run.context;
|
|
45
|
+
if (definition.trigger.type === "watch" && !run.resumedFromRunId) {
|
|
46
|
+
this.markRunning(run);
|
|
47
|
+
run.probe = await runBash(definition.trigger.script, definition.trigger.cwd, run.input, controller.signal);
|
|
48
|
+
run.matched = run.probe.exitCode === 0;
|
|
49
|
+
this.store.saveRun(run);
|
|
50
|
+
if (run.probe.exitCode === 1)
|
|
51
|
+
run.result = { type: "watch", matched: false };
|
|
52
|
+
else if (run.probe.exitCode !== 0)
|
|
53
|
+
throw new Error(`watch probe exited ${String(run.probe.exitCode)}`);
|
|
54
|
+
}
|
|
55
|
+
if (run.matched !== false)
|
|
56
|
+
run.result = await this.executeAction(run, controller.signal);
|
|
57
|
+
run.state = "succeeded";
|
|
58
|
+
if (definition.trigger.type === "watch" && !run.resumedFromRunId && definition.trigger.mode === "once" && run.matched) {
|
|
59
|
+
this.definitions.setEnabled(definition.id, false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
// A killed child reports `exited null`, not `cancelled`: report why we
|
|
64
|
+
// aborted instead of how the corpse looked.
|
|
65
|
+
const aborted = controller.signal.aborted;
|
|
66
|
+
run.state = aborted ? (timedOut ? "failed" : "cancelled") : "failed";
|
|
67
|
+
run.error = timedOut ? "task timed out" : aborted ? "cancelled" : String(error);
|
|
68
|
+
cause = timedOut ? run.error : error;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
clearTimeout(timeout);
|
|
72
|
+
run.finishedAt = Date.now();
|
|
73
|
+
const seconds = ((run.finishedAt - (run.startedAt ?? run.queuedAt)) / 1000).toFixed(1);
|
|
74
|
+
const settled = `run ${run.id} (${run.context.definition.name}) ${run.state} in ${seconds}s`;
|
|
75
|
+
// A scheduled run has no one watching it: the run row is the only other
|
|
76
|
+
// place this exists, and nobody opens the Console to find out it failed.
|
|
77
|
+
// A watch probe that did not match is the opposite case — it fires on
|
|
78
|
+
// every interval and at info would be most of the journal.
|
|
79
|
+
if (run.state === "failed")
|
|
80
|
+
log.error(settled, cause);
|
|
81
|
+
else if (run.matched === false)
|
|
82
|
+
log.debug(`${settled} (watch did not match)`);
|
|
83
|
+
else
|
|
84
|
+
log.info(settled);
|
|
85
|
+
// A run that ends awaiting a supervisor decision suppresses its
|
|
86
|
+
// completion callback: the pending question is the notification.
|
|
87
|
+
if (run.callbackSessionId && !this.host.openDecisionId(run.id))
|
|
88
|
+
run.callbackState = "pending";
|
|
89
|
+
this.store.saveRun(run);
|
|
90
|
+
this.controllers.delete(run.id);
|
|
91
|
+
this.host.changed(run);
|
|
92
|
+
this.host.settled(run);
|
|
93
|
+
if (run.callbackState === "pending")
|
|
94
|
+
void this.callbacks.deliver(run);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async executeAction(run, signal) {
|
|
98
|
+
const action = run.context.definition.action;
|
|
99
|
+
if (action.type === "bash") {
|
|
100
|
+
this.markRunning(run);
|
|
101
|
+
run.context.cwd = action.cwd;
|
|
102
|
+
this.store.saveRun(run);
|
|
103
|
+
const result = await runBash(action.script, action.cwd, run.input, signal);
|
|
104
|
+
const output = { type: "bash", ...result };
|
|
105
|
+
if (result.exitCode !== 0) {
|
|
106
|
+
run.result = output;
|
|
107
|
+
throw new Error(`bash exited ${String(result.exitCode)}`);
|
|
108
|
+
}
|
|
109
|
+
return output;
|
|
110
|
+
}
|
|
111
|
+
if (action.type === "task") {
|
|
112
|
+
this.markRunning(run);
|
|
113
|
+
const child = this.host.runChild(action.taskId, run);
|
|
114
|
+
let rejectWait = (reason) => { void reason; };
|
|
115
|
+
const aborted = new Promise((_, reject) => { rejectWait = reject; });
|
|
116
|
+
const onAbort = () => {
|
|
117
|
+
this.host.cancel(child.id);
|
|
118
|
+
rejectWait(new Error("cancelled"));
|
|
119
|
+
};
|
|
120
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
try {
|
|
122
|
+
const done = await Promise.race([this.host.waitForRun(child.id), aborted]);
|
|
123
|
+
if (done.state !== "succeeded")
|
|
124
|
+
throw new Error(`child run ${done.state}`);
|
|
125
|
+
return { type: "task", runId: done.id, result: done.result };
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
signal.removeEventListener("abort", onAbort);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return this.agent.execute(run, action, signal, () => this.markRunning(run));
|
|
132
|
+
}
|
|
133
|
+
markRunning(run) {
|
|
134
|
+
if (run.state === "running")
|
|
135
|
+
return;
|
|
136
|
+
run.state = "running";
|
|
137
|
+
run.startedAt = Date.now();
|
|
138
|
+
this.store.saveRun(run);
|
|
139
|
+
this.host.changed(run);
|
|
140
|
+
}
|
|
141
|
+
}
|