@realqiyan/agent-task 0.0.0-stage → 0.0.1-rc.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 +21 -0
- package/README.md +45 -2
- package/bin/agent-task.mjs +66 -0
- package/config.example.json +7 -0
- package/deploy/agent-task.service.example +17 -0
- package/dist/acp.js +168 -0
- package/dist/admin.js +31 -0
- package/dist/config.js +113 -0
- package/dist/coordinator-provider.js +28 -0
- package/dist/coordinator-storage.js +43 -0
- package/dist/coordinator.js +117 -0
- package/dist/environment.js +23 -0
- package/dist/http-auth.js +9 -0
- package/dist/human-input.js +62 -0
- package/dist/lock.js +27 -0
- package/dist/main.js +131 -0
- package/dist/mcp.js +40 -0
- package/dist/model.js +28 -0
- package/dist/node-execution.js +103 -0
- package/dist/permissions.js +10 -0
- package/dist/private-files.js +81 -0
- package/dist/results.js +85 -0
- package/dist/settings-contract.js +16 -0
- package/dist/settings.js +163 -0
- package/dist/task-contract.js +37 -0
- package/dist/task-model.js +2 -0
- package/dist/task-storage.js +123 -0
- package/dist/tasks.js +509 -0
- package/dist/version.js +3 -0
- package/dist/web/api.js +26 -0
- package/dist/web/app.js +65 -0
- package/dist/web/dom.js +65 -0
- package/dist/web/index.html +33 -0
- package/dist/web/settings-view.js +121 -0
- package/dist/web/style.css +24 -0
- package/dist/web/tasks-view.js +83 -0
- package/dist/web.js +105 -0
- package/dist/worker.js +89 -0
- package/dist/workspace.js +16 -0
- package/docs/architecture-guidelines.md +52 -0
- package/docs/deployment.md +113 -0
- package/docs/design-proposal.md +55 -0
- package/docs/release.md +57 -0
- package/docs/technical-design.md +37 -0
- package/docs/validation.md +100 -0
- package/docs/web-console.md +46 -0
- package/package.json +68 -4
- package/scripts/install-lib.mjs +191 -0
- package/scripts/mcp-call.mjs +17 -0
- package/scripts/probe-agent.mjs +34 -0
- package/scripts/service-runner.mjs +14 -0
- package/scripts/setup-wizard.mjs +29 -0
- package/scripts/setup.mjs +126 -0
- package/scripts/uninstall.mjs +53 -0
package/dist/results.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, fstatSync, fsyncSync, realpathSync, statSync, accessSync, constants } from "node:fs";
|
|
2
|
+
import { join, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { OUTPUT_LIMIT } from "./model.js";
|
|
4
|
+
/** Exact per-run file allowlist intersected with administrator-granted roots. Never offers general filesystem access. */
|
|
5
|
+
export function readInputFile(executor, allowed, path, line = 1, limit) {
|
|
6
|
+
if (!allowed.includes(path) || realpathSync(path) !== path)
|
|
7
|
+
throw new Error("Input file is not authorized for this run");
|
|
8
|
+
if (!executor.resultReadRoots.some(root => {
|
|
9
|
+
const rel = relative(realpathSync(root), path);
|
|
10
|
+
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
11
|
+
}))
|
|
12
|
+
throw new Error("Input file outside authorized read scope");
|
|
13
|
+
if (!Number.isInteger(line) || line < 1 || (limit !== undefined && (!Number.isInteger(limit) || limit < 1)))
|
|
14
|
+
throw new Error("Invalid line range");
|
|
15
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
16
|
+
try {
|
|
17
|
+
const stat = fstatSync(fd);
|
|
18
|
+
if (!stat.isFile() || stat.size > OUTPUT_LIMIT)
|
|
19
|
+
throw new Error("Invalid or oversized input file");
|
|
20
|
+
const content = readFileSync(fd, "utf8");
|
|
21
|
+
if (Buffer.byteLength(content) > OUTPUT_LIMIT)
|
|
22
|
+
throw new Error("Input file grew beyond limit");
|
|
23
|
+
if (line === 1 && limit === undefined)
|
|
24
|
+
return content;
|
|
25
|
+
return content.split("\n").slice(line - 1, limit === undefined ? undefined : line - 1 + limit).join("\n");
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
closeSync(fd);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class Results {
|
|
32
|
+
root;
|
|
33
|
+
constructor(directory) {
|
|
34
|
+
mkdirSync(join(directory, "results"), { recursive: true, mode: 0o700 });
|
|
35
|
+
this.root = realpathSync(join(directory, "results"));
|
|
36
|
+
}
|
|
37
|
+
inside(root, path) {
|
|
38
|
+
const rel = relative(root, path);
|
|
39
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
40
|
+
}
|
|
41
|
+
authorize(executor) {
|
|
42
|
+
if (!executor.resultReadRoots.some(root => this.inside(realpathSync(root), this.root))) {
|
|
43
|
+
throw new Error("Executor " + executor.id + " lacks administrator-approved result read scope");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
readable(path, executor) {
|
|
47
|
+
this.authorize(executor);
|
|
48
|
+
if (realpathSync(path) !== path || !this.inside(this.root, path) || !statSync(path).isFile())
|
|
49
|
+
throw new Error("Invalid result file");
|
|
50
|
+
accessSync(path, constants.R_OK);
|
|
51
|
+
// Actually open it. Native agent sandbox access remains an administrator-reviewed prerequisite.
|
|
52
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
53
|
+
closeSync(fd);
|
|
54
|
+
}
|
|
55
|
+
save(taskRunId, nodeId, runId, text) {
|
|
56
|
+
for (const id of [taskRunId, nodeId, runId]) {
|
|
57
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/.test(id))
|
|
58
|
+
throw new Error("Invalid result identity");
|
|
59
|
+
}
|
|
60
|
+
const directory = join(this.root, taskRunId);
|
|
61
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
62
|
+
if (realpathSync(directory) !== directory)
|
|
63
|
+
throw new Error("Result directory must not be a symlink");
|
|
64
|
+
const path = join(directory, nodeId + "." + runId + ".txt");
|
|
65
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(fd, text, "utf8");
|
|
68
|
+
fsyncSync(fd);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
for (const dir of [directory, this.root]) {
|
|
74
|
+
const handle = openSync(dir, constants.O_RDONLY);
|
|
75
|
+
try {
|
|
76
|
+
fsyncSync(handle);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
closeSync(handle);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return path;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=results.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { configSchema } from "./config.js";
|
|
3
|
+
export const settingsConfigSchema = z.object({
|
|
4
|
+
editToken: z.string().uuid(),
|
|
5
|
+
config: configSchema.pick({ executors: true, coordinator: true }),
|
|
6
|
+
}).strict();
|
|
7
|
+
const credential = {
|
|
8
|
+
editToken: z.string().uuid(),
|
|
9
|
+
key: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(200),
|
|
10
|
+
};
|
|
11
|
+
export const credentialUpdateSchema = z.discriminatedUnion("action", [
|
|
12
|
+
z.object({ ...credential, action: z.literal("replace"),
|
|
13
|
+
value: z.string().min(1).max(16_384).refine(value => Boolean(value.trim()) && !value.includes("\0")) }).strict(),
|
|
14
|
+
z.object({ ...credential, action: z.literal("delete") }).strict(),
|
|
15
|
+
]);
|
|
16
|
+
//# sourceMappingURL=settings-contract.js.map
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ADMIN_TOKEN_ENV, configSchema, coordinatorCredentialEnv, validateConfig } from "./config.js";
|
|
4
|
+
import { parseEnvironment } from "./environment.js";
|
|
5
|
+
import { FileCommitError, readPrivateText, replacePrivateText } from "./private-files.js";
|
|
6
|
+
import { credentialUpdateSchema, settingsConfigSchema } from "./settings-contract.js";
|
|
7
|
+
export class SettingsError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const protectedKeys = new Set(["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR", "NODE_OPTIONS", "NODE_PATH",
|
|
15
|
+
"PI_ACP_PI_COMMAND", "AGENT_TASK_TOKEN", ADMIN_TOKEN_ENV, "__proto__", "constructor", "prototype"]);
|
|
16
|
+
const modelKeys = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY"];
|
|
17
|
+
function parse(schema, value) {
|
|
18
|
+
const result = schema.safeParse(value);
|
|
19
|
+
if (!result.success) {
|
|
20
|
+
// Field paths only: parser diagnostics and rejected values may contain credentials.
|
|
21
|
+
const fields = result.error.issues.slice(0, 8).map(issue => issue.path.join(".") || "request");
|
|
22
|
+
throw new SettingsError("invalid", "Invalid fields: " + fields.join(", "));
|
|
23
|
+
}
|
|
24
|
+
return result.data;
|
|
25
|
+
}
|
|
26
|
+
function referenced(config) {
|
|
27
|
+
return new Set([...config.executors.flatMap(executor => executor.envKeys),
|
|
28
|
+
...(config.coordinator ? [coordinatorCredentialEnv(config.coordinator)] : [])]);
|
|
29
|
+
}
|
|
30
|
+
function editableKey(key, config) {
|
|
31
|
+
return !protectedKeys.has(key) && key !== config.tokenEnv && !/^(NODE_|LD_|DYLD_)/.test(key) &&
|
|
32
|
+
(/(?:_KEY|_TOKEN|_SECRET|_PASSWORD)$/.test(key) || referenced(config).has(key));
|
|
33
|
+
}
|
|
34
|
+
/** Disk settings are next-start inputs only. This service never mutates live objects or process.env. */
|
|
35
|
+
export class Settings {
|
|
36
|
+
configPath;
|
|
37
|
+
environmentPath;
|
|
38
|
+
edits = new Map();
|
|
39
|
+
initial;
|
|
40
|
+
running;
|
|
41
|
+
runningEnvironment;
|
|
42
|
+
base;
|
|
43
|
+
closed = false;
|
|
44
|
+
uncertain = false;
|
|
45
|
+
startedAt = new Date().toISOString();
|
|
46
|
+
constructor(configPath, environmentPath, running, base, environment) {
|
|
47
|
+
this.configPath = configPath;
|
|
48
|
+
this.environmentPath = environmentPath;
|
|
49
|
+
if (configPath === environmentPath)
|
|
50
|
+
throw new SettingsError("invalid", "Configuration and environment must be separate files");
|
|
51
|
+
this.running = structuredClone(running);
|
|
52
|
+
this.base = { ...base };
|
|
53
|
+
this.runningEnvironment = { ...environment };
|
|
54
|
+
this.initial = this.snapshot();
|
|
55
|
+
}
|
|
56
|
+
snapshot() {
|
|
57
|
+
if (this.closed)
|
|
58
|
+
throw new SettingsError("unavailable", "Configuration management is closed");
|
|
59
|
+
try {
|
|
60
|
+
const configText = readPrivateText(this.configPath), environmentText = readPrivateText(this.environmentPath);
|
|
61
|
+
return { configText, environmentText, config: configSchema.parse(JSON.parse(configText)), environment: parseEnvironment(environmentText) };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new SettingsError("unavailable", "Cannot read private configuration files; inspect their format, ownership and permissions offline");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
pending(snapshot) {
|
|
68
|
+
return snapshot.configText !== this.initial.configText || snapshot.environmentText !== this.initial.environmentText;
|
|
69
|
+
}
|
|
70
|
+
status() {
|
|
71
|
+
return { pendingRestart: this.pending(this.snapshot()), writeBlocked: this.uncertain, startedAt: this.startedAt };
|
|
72
|
+
}
|
|
73
|
+
get() {
|
|
74
|
+
const snapshot = this.snapshot(), now = Date.now();
|
|
75
|
+
for (const [key, edit] of this.edits)
|
|
76
|
+
if (edit.expires <= now)
|
|
77
|
+
this.edits.delete(key);
|
|
78
|
+
while (this.edits.size >= 32)
|
|
79
|
+
this.edits.delete(this.edits.keys().next().value);
|
|
80
|
+
const editToken = randomUUID();
|
|
81
|
+
this.edits.set(editToken, { configText: snapshot.configText, environmentText: snapshot.environmentText, expires: now + 15 * 60_000 });
|
|
82
|
+
const keys = new Set([...modelKeys, ...Object.keys(snapshot.environment), ...referenced(snapshot.config), ...referenced(this.running)]);
|
|
83
|
+
const credentials = [...keys].filter(key => editableKey(key, snapshot.config)).sort().map(key => ({
|
|
84
|
+
key, saved: Boolean(snapshot.environment[key]), running: Boolean(this.runningEnvironment[key]),
|
|
85
|
+
pendingRestart: snapshot.environment[key] !== this.runningEnvironment[key],
|
|
86
|
+
referenced: referenced(snapshot.config).has(key),
|
|
87
|
+
}));
|
|
88
|
+
return { editToken, running: structuredClone(this.running), saved: snapshot.config, credentials,
|
|
89
|
+
pendingRestart: this.pending(snapshot), writeBlocked: this.uncertain };
|
|
90
|
+
}
|
|
91
|
+
editing(token) {
|
|
92
|
+
if (this.uncertain)
|
|
93
|
+
throw new SettingsError("unavailable", "Publication was uncertain; stop the service and inspect configuration files before further saves");
|
|
94
|
+
const snapshot = this.snapshot(), edit = this.edits.get(token);
|
|
95
|
+
if (!edit || edit.expires <= Date.now() || edit.configText !== snapshot.configText || edit.environmentText !== snapshot.environmentText) {
|
|
96
|
+
throw new SettingsError("conflict", "Configuration changed or the edit expired; reload before saving");
|
|
97
|
+
}
|
|
98
|
+
return snapshot;
|
|
99
|
+
}
|
|
100
|
+
candidate(value) {
|
|
101
|
+
const input = parse(settingsConfigSchema, value), snapshot = this.editing(input.editToken);
|
|
102
|
+
const environment = { ...this.base, ...snapshot.environment };
|
|
103
|
+
let config;
|
|
104
|
+
try {
|
|
105
|
+
config = validateConfig({ ...snapshot.config, ...input.config, coordinator: input.config.coordinator }, environment);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error instanceof z.ZodError)
|
|
109
|
+
throw new SettingsError("invalid", "Invalid configuration fields");
|
|
110
|
+
throw new SettingsError("invalid", "Local validation failed: check executable paths, dependencies, workspaces, read roots and credential isolation");
|
|
111
|
+
}
|
|
112
|
+
const previous = snapshot.config.coordinator, next = config.coordinator;
|
|
113
|
+
const endpoint = (model) => model.provider === "openai-compatible" ? model.baseUrl : model.provider;
|
|
114
|
+
if (previous && next && endpoint(previous) !== endpoint(next) && coordinatorCredentialEnv(previous) === coordinatorCredentialEnv(next)) {
|
|
115
|
+
throw new SettingsError("invalid", "Changing the model endpoint requires a new credential reference; save the new key first");
|
|
116
|
+
}
|
|
117
|
+
for (const key of referenced(config)) {
|
|
118
|
+
if (!environment[key])
|
|
119
|
+
throw new SettingsError("invalid", "A referenced environment value is missing; prepare credentials or native configuration first");
|
|
120
|
+
}
|
|
121
|
+
return config;
|
|
122
|
+
}
|
|
123
|
+
validate(value) { return this.candidate(value); }
|
|
124
|
+
saveConfig(value) {
|
|
125
|
+
const config = this.candidate(value);
|
|
126
|
+
this.publish(this.configPath, config);
|
|
127
|
+
return this.get();
|
|
128
|
+
}
|
|
129
|
+
saveCredential(value) {
|
|
130
|
+
const input = parse(credentialUpdateSchema, value), snapshot = this.editing(input.editToken);
|
|
131
|
+
if (!editableKey(input.key, snapshot.config))
|
|
132
|
+
throw new SettingsError("invalid", "This environment key is not console-managed; new credential names must end in _KEY, _TOKEN, _SECRET or _PASSWORD");
|
|
133
|
+
if (input.action === "delete") {
|
|
134
|
+
if (referenced(snapshot.config).has(input.key))
|
|
135
|
+
throw new SettingsError("invalid", "The saved configuration still references this credential");
|
|
136
|
+
delete snapshot.environment[input.key];
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
if ([snapshot.environment[snapshot.config.tokenEnv], snapshot.environment[ADMIN_TOKEN_ENV],
|
|
140
|
+
this.runningEnvironment[this.running.tokenEnv], this.runningEnvironment[ADMIN_TOKEN_ENV]].includes(input.value)) {
|
|
141
|
+
throw new SettingsError("invalid", "Service and administrator tokens cannot be reused as credentials");
|
|
142
|
+
}
|
|
143
|
+
snapshot.environment[input.key] = input.value;
|
|
144
|
+
}
|
|
145
|
+
// Recheck the complete environment format, not only the edited value.
|
|
146
|
+
parseEnvironment(JSON.stringify(snapshot.environment));
|
|
147
|
+
this.publish(this.environmentPath, snapshot.environment);
|
|
148
|
+
return this.get();
|
|
149
|
+
}
|
|
150
|
+
publish(path, value) {
|
|
151
|
+
try {
|
|
152
|
+
replacePrivateText(path, JSON.stringify(value, null, 2) + "\n");
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error instanceof FileCommitError && error.uncertain)
|
|
156
|
+
this.uncertain = true;
|
|
157
|
+
throw new SettingsError("unavailable", this.uncertain ? "Publication could not be confirmed; stop and inspect the files" : "Save failed; no replacement was published");
|
|
158
|
+
}
|
|
159
|
+
this.edits.clear();
|
|
160
|
+
}
|
|
161
|
+
close() { this.closed = true; this.edits.clear(); }
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=settings.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { identifier, scheduleSchema, timestamp } from "./model.js";
|
|
4
|
+
export const nodeSchema = z.object({
|
|
5
|
+
id: identifier, prompt: z.string().trim().min(1).max(64_000),
|
|
6
|
+
executorId: identifier.describe("Administrator-registered ACP executor assigned to this node; not a command or URL."),
|
|
7
|
+
dependsOn: z.array(identifier).max(20).default([]),
|
|
8
|
+
maxRunSeconds: z.number().int().min(1).max(3600).default(600),
|
|
9
|
+
}).strict();
|
|
10
|
+
export const questionSchema = z.object({
|
|
11
|
+
nodeId: identifier.optional(), question: z.string().trim().min(1).max(4000),
|
|
12
|
+
completedWork: z.string().trim().min(1).max(4000),
|
|
13
|
+
}).strict();
|
|
14
|
+
export const replySchema = z.object({
|
|
15
|
+
runId: z.string().uuid(), questionId: z.string().uuid(), requestKey: identifier,
|
|
16
|
+
answer: z.string().trim().min(1).max(16000),
|
|
17
|
+
}).strict();
|
|
18
|
+
export const taskIdSchema = z.object({ id: z.string().uuid() }).strict();
|
|
19
|
+
export const taskListSchema = z.object({
|
|
20
|
+
after: z.number().int().nonnegative().default(0), limit: z.number().int().min(1).max(100).default(20),
|
|
21
|
+
}).strict();
|
|
22
|
+
export const taskRunListSchema = taskListSchema.extend({ taskId: z.string().uuid().optional() });
|
|
23
|
+
export const taskUpdateSchema = z.object({
|
|
24
|
+
id: z.string().uuid(), action: z.enum(["pause", "resume", "cancel"]),
|
|
25
|
+
}).strict();
|
|
26
|
+
export const taskSchema = z.object({
|
|
27
|
+
requestKey: identifier, name: z.string().trim().min(1).max(200),
|
|
28
|
+
prompt: z.string().trim().min(1).max(64_000).describe("Overall goal and coordination constraints; does not generate or replace the DAG."),
|
|
29
|
+
nodes: z.array(nodeSchema).min(1).max(20).describe("Caller-defined fixed DAG. Each node specifies its prompt, executorId and direct dependencies."),
|
|
30
|
+
workspace: z.string().min(1).max(4096).refine(path => isAbsolute(path) && !path.includes("\0"), "Workspace must be an absolute path without NUL").optional(),
|
|
31
|
+
schedule: scheduleSchema, expiresAt: timestamp.optional(),
|
|
32
|
+
maxRunSeconds: z.number().int().min(1).max(86_400).default(1800),
|
|
33
|
+
maxSteps: z.number().int().min(1).max(200).default(40),
|
|
34
|
+
maxNodeTurns: z.number().int().min(1).max(10).default(3),
|
|
35
|
+
inputWaitSeconds: z.number().int().min(1).max(604800).default(86400),
|
|
36
|
+
}).strict();
|
|
37
|
+
//# sourceMappingURL=task-contract.js.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { executorSchema, identifier, timestamp } from "./model.js";
|
|
6
|
+
import { nodeSchema, questionSchema, taskSchema } from "./task-contract.js";
|
|
7
|
+
const definitionSchema = z.object({
|
|
8
|
+
id: z.string().uuid(), input: taskSchema, workspace: z.string().nullable(), executors: z.record(identifier, executorSchema),
|
|
9
|
+
status: z.enum(["active", "paused", "cancelled", "completed", "expired"]),
|
|
10
|
+
nextAt: z.number().finite().nullable(), createdAt: timestamp,
|
|
11
|
+
}).strict();
|
|
12
|
+
const roundSchema = z.object({
|
|
13
|
+
id: z.string().uuid(), taskId: z.string().uuid(), scheduledAt: z.number().finite(),
|
|
14
|
+
status: z.enum(["running", "succeeded", "failed", "interrupted", "skipped", "cancelled", "timed_out", "waiting_input"]),
|
|
15
|
+
createdAt: timestamp, finishedAt: timestamp.nullable(), error: z.string().nullable(), skippedCount: z.number().int().nonnegative(),
|
|
16
|
+
steps: z.number().int().nonnegative(), coordinatorStartedAt: timestamp.nullable(), summary: z.string().nullable(),
|
|
17
|
+
waitedMs: z.number().finite().nonnegative(),
|
|
18
|
+
questions: z.array(questionSchema.extend({
|
|
19
|
+
id: z.string().uuid(), createdAt: timestamp, expiresAt: timestamp, sessionExpiresAt: timestamp.nullable(),
|
|
20
|
+
reply: z.object({ requestKey: identifier, answer: z.string().min(1).max(16000), receivedAt: timestamp }).strict().nullable(),
|
|
21
|
+
})).max(20),
|
|
22
|
+
nodes: z.array(z.object({
|
|
23
|
+
id: z.string().uuid(), definition: nodeSchema,
|
|
24
|
+
status: z.enum(["pending", "running", "awaiting_review", "succeeded", "failed", "blocked", "interrupted"]),
|
|
25
|
+
workspace: z.string().nullable(), inputFiles: z.array(z.string()), resultFile: z.string().nullable(), result: z.string().nullable(), acceptance: z.string().nullable(),
|
|
26
|
+
turns: z.array(z.object({ prompt: z.string(), output: z.string(), stopReason: z.string().nullable() }).strict()),
|
|
27
|
+
error: z.string().nullable(), sessionId: z.string().nullable(), startedAt: timestamp.nullable(), eventCount: z.number().int().nonnegative(),
|
|
28
|
+
}).strict()).min(1).max(20),
|
|
29
|
+
}).strict();
|
|
30
|
+
export class StorageError extends Error {
|
|
31
|
+
}
|
|
32
|
+
/** Task definitions and rounds share one transactional store. */
|
|
33
|
+
export class TaskStore {
|
|
34
|
+
db;
|
|
35
|
+
closed = false;
|
|
36
|
+
get healthy() { return !this.closed; }
|
|
37
|
+
constructor(directory) {
|
|
38
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
39
|
+
this.db = new DatabaseSync(join(directory, "tasks.sqlite"));
|
|
40
|
+
try {
|
|
41
|
+
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
|
|
42
|
+
if (tables.length) {
|
|
43
|
+
this.db.prepare("SELECT seq,id,request_key,data FROM definitions LIMIT 0").all();
|
|
44
|
+
this.db.prepare("SELECT seq,id,task_id,scheduled_at,data FROM rounds LIMIT 0").all();
|
|
45
|
+
}
|
|
46
|
+
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON");
|
|
47
|
+
if (!tables.length)
|
|
48
|
+
this.db.exec(`BEGIN IMMEDIATE;
|
|
49
|
+
CREATE TABLE definitions(seq INTEGER PRIMARY KEY, id TEXT UNIQUE NOT NULL, request_key TEXT UNIQUE NOT NULL, data TEXT NOT NULL);
|
|
50
|
+
CREATE TABLE rounds(seq INTEGER PRIMARY KEY, id TEXT UNIQUE NOT NULL, task_id TEXT NOT NULL REFERENCES definitions(id),
|
|
51
|
+
scheduled_at REAL NOT NULL, data TEXT NOT NULL, UNIQUE(task_id,scheduled_at));
|
|
52
|
+
COMMIT;`);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
this.db.close();
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
check() { if (this.closed)
|
|
60
|
+
throw new StorageError("Task storage closed"); }
|
|
61
|
+
definitions() {
|
|
62
|
+
this.check();
|
|
63
|
+
return this.db.prepare("SELECT data FROM definitions ORDER BY seq").all().map(row => definitionSchema.parse(JSON.parse(String(row["data"]))));
|
|
64
|
+
}
|
|
65
|
+
rounds(taskId) {
|
|
66
|
+
this.check();
|
|
67
|
+
const rows = taskId ? this.db.prepare("SELECT data FROM rounds WHERE task_id=? ORDER BY seq").all(taskId)
|
|
68
|
+
: this.db.prepare("SELECT data FROM rounds ORDER BY seq").all();
|
|
69
|
+
return rows.map(row => roundSchema.parse(JSON.parse(String(row["data"]))));
|
|
70
|
+
}
|
|
71
|
+
activeRounds(taskId) {
|
|
72
|
+
this.check();
|
|
73
|
+
const rows = taskId
|
|
74
|
+
? this.db.prepare("SELECT data FROM rounds WHERE task_id=? AND json_extract(data,'$.status') IN ('running','waiting_input')").all(taskId)
|
|
75
|
+
: this.db.prepare("SELECT data FROM rounds WHERE json_extract(data,'$.status') IN ('running','waiting_input')").all();
|
|
76
|
+
return rows.map(row => roundSchema.parse(JSON.parse(String(row["data"]))));
|
|
77
|
+
}
|
|
78
|
+
get(id) {
|
|
79
|
+
this.check();
|
|
80
|
+
const row = this.db.prepare("SELECT data FROM definitions WHERE id=?").get(id);
|
|
81
|
+
if (!row)
|
|
82
|
+
throw new Error("Task not found");
|
|
83
|
+
return definitionSchema.parse(JSON.parse(String(row["data"])));
|
|
84
|
+
}
|
|
85
|
+
run(id) {
|
|
86
|
+
this.check();
|
|
87
|
+
const row = this.db.prepare("SELECT data FROM rounds WHERE id=?").get(id);
|
|
88
|
+
if (!row)
|
|
89
|
+
throw new Error("Task run not found");
|
|
90
|
+
return roundSchema.parse(JSON.parse(String(row["data"])));
|
|
91
|
+
}
|
|
92
|
+
/** The service supplies all policy; the store commits a cursor and round atomically. */
|
|
93
|
+
commit(definition, round) {
|
|
94
|
+
this.check();
|
|
95
|
+
if (definition)
|
|
96
|
+
definitionSchema.parse(definition);
|
|
97
|
+
if (round)
|
|
98
|
+
roundSchema.parse(round);
|
|
99
|
+
try {
|
|
100
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
101
|
+
if (definition)
|
|
102
|
+
this.db.prepare(`INSERT INTO definitions(id,request_key,data) VALUES(?,?,?)
|
|
103
|
+
ON CONFLICT(id) DO UPDATE SET data=excluded.data`).run(definition.id, definition.input.requestKey, JSON.stringify(definition));
|
|
104
|
+
if (round)
|
|
105
|
+
this.db.prepare(`INSERT INTO rounds(id,task_id,scheduled_at,data) VALUES(?,?,?,?)
|
|
106
|
+
ON CONFLICT(id) DO UPDATE SET data=excluded.data`).run(round.id, round.taskId, round.scheduledAt, JSON.stringify(round));
|
|
107
|
+
this.db.exec("COMMIT");
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
try {
|
|
111
|
+
this.db.exec("ROLLBACK");
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
this.close();
|
|
115
|
+
throw new StorageError("Task commit failed", { cause: error });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
close() { if (!this.closed) {
|
|
119
|
+
this.closed = true;
|
|
120
|
+
this.db.close();
|
|
121
|
+
} }
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=task-storage.js.map
|