@danypops/papyrus 0.5.0 → 0.6.0
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 +25 -0
- package/extension/src/domain-tools.ts +3 -1
- package/extension/src/tasks.ts +22 -0
- package/package.json +1 -1
- package/src/adapters/sqlite-gate-runner.ts +3 -3
- package/src/cli.ts +35 -1
- package/src/constants.ts +14 -0
- package/src/daemon.ts +20 -4
- package/src/domain/gate.ts +5 -0
- package/src/domain/task-event.ts +2 -0
- package/src/log.ts +6 -0
- package/src/ops.ts +18 -7
- package/src/ports/gate-runner.ts +2 -2
- package/src/service.ts +15 -3
- package/src/task-automation.ts +188 -0
- package/src/task-service.ts +27 -8
package/README.md
CHANGED
|
@@ -37,6 +37,8 @@ papyrus tasks plan
|
|
|
37
37
|
papyrus tasks depend <task-id> <prerequisite-id>
|
|
38
38
|
papyrus tasks start <task-id>
|
|
39
39
|
papyrus tasks complete <task-id>
|
|
40
|
+
papyrus tasks automate <task-id> <on|off>
|
|
41
|
+
papyrus automation status
|
|
40
42
|
```
|
|
41
43
|
|
|
42
44
|
For repository work, install the versioned ownership guard once:
|
|
@@ -138,8 +140,31 @@ papyrus tasks complete <id> --json
|
|
|
138
140
|
papyrus tasks reject <id> --json
|
|
139
141
|
papyrus tasks retry <id> --json
|
|
140
142
|
papyrus tasks cancel <id> --json
|
|
143
|
+
papyrus tasks automate <id> <on|off> --json
|
|
144
|
+
papyrus automation status --json
|
|
145
|
+
papyrus automation run --json
|
|
141
146
|
```
|
|
142
147
|
|
|
148
|
+
### Opt-in supervised automation
|
|
149
|
+
|
|
150
|
+
Background graph reconciliation is off by default and requires two independent opt-ins: daemon configuration and `automation.enabled` on each Task. Only opted-in Tasks already in `review` are eligible for automatic gate/checklist review; Papyrus never skips the review lifecycle. When one completes, directly dependent opted-in successors that become ready may move from `todo` to `in-progress`. Every completion, rejection, and start is written to append-only history with actor `daemon`, source `automation-reconciler`, reason, and bounded gate evidence.
|
|
151
|
+
|
|
152
|
+
Enable the daemon with a systemd user-service override and restart it:
|
|
153
|
+
|
|
154
|
+
```ini
|
|
155
|
+
[Service]
|
|
156
|
+
Environment=PAPYRUS_AUTOMATION_ENABLED=1
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
systemctl --user edit papyrus.service
|
|
161
|
+
systemctl --user restart papyrus.service
|
|
162
|
+
papyrus tasks automate <task-id> on
|
|
163
|
+
papyrus automation status
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Secure defaults are a 60-second interval, 10 Task transitions per sweep, gate concurrency 1, and a 120-second sweep deadline. Optional environment settings are `PAPYRUS_AUTOMATION_INTERVAL_MS` (10 seconds–1 hour), `PAPYRUS_AUTOMATION_MAX_TASKS` (1–100), `PAPYRUS_AUTOMATION_GATE_CONCURRENCY` (1–4), and `PAPYRUS_AUTOMATION_MAX_RUNTIME_MS` (1 ms–10 minutes). Candidate scans are capped at 1,000 review Tasks, sweeps are single-flight, subprocess gates inherit the sweep deadline, result arrays are bounded by the Task limit, and logs contain counts rather than gate output. `papyrus automation run` uses the same policy and refuses to reconcile while global automation is disabled.
|
|
167
|
+
|
|
143
168
|
Checklist criteria are an item-to-proof map. Every new item requires one or more typed references to inspectable evidence; proof presence does not imply that the evidence passed an executable gate:
|
|
144
169
|
|
|
145
170
|
```ts
|
|
@@ -31,7 +31,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
31
31
|
pi.registerTool({
|
|
32
32
|
name: "tasks",
|
|
33
33
|
label: "Tasks",
|
|
34
|
-
description: "Task domain tool. ACTIONS: create, list, show, history, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
|
|
34
|
+
description: "Task domain tool. ACTIONS: create, list, show, history, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, set_automation, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
|
|
35
35
|
parameters: Type.Object({
|
|
36
36
|
action: Type.String(),
|
|
37
37
|
id: Type.Optional(Type.String()),
|
|
@@ -44,6 +44,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
44
44
|
direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
|
|
45
45
|
reason: Type.Optional(Type.String()),
|
|
46
46
|
session_id: Type.Optional(Type.String()),
|
|
47
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
47
48
|
labels: Type.Optional(Type.Array(Type.String())),
|
|
48
49
|
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
49
50
|
gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
|
|
@@ -123,6 +124,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
123
124
|
reject: "tasks.reject",
|
|
124
125
|
retry: "tasks.retry",
|
|
125
126
|
cancel: "tasks.cancel",
|
|
127
|
+
set_automation: "tasks.set_automation",
|
|
126
128
|
depend: "tasks.depend",
|
|
127
129
|
contain: "tasks.contain",
|
|
128
130
|
} as const;
|
package/extension/src/tasks.ts
CHANGED
|
@@ -30,6 +30,12 @@ const STATUS_ACTIONS: Record<string, string[]> = {
|
|
|
30
30
|
|
|
31
31
|
type TaskRow = Artifact;
|
|
32
32
|
|
|
33
|
+
function taskAutomationEnabled(task: Artifact): boolean {
|
|
34
|
+
const automation = task.extra["automation"];
|
|
35
|
+
return typeof automation === "object" && automation !== null && !Array.isArray(automation)
|
|
36
|
+
&& (automation as Record<string, unknown>)["enabled"] === true;
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
export interface TaskHierarchyRow {
|
|
34
40
|
task: TaskRow;
|
|
35
41
|
depth: number;
|
|
@@ -86,9 +92,11 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
86
92
|
if (action.type !== "action" || !action.row) continue;
|
|
87
93
|
|
|
88
94
|
const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
|
|
95
|
+
const automationEnabled = taskAutomationEnabled(action.row);
|
|
89
96
|
const choices = [
|
|
90
97
|
"Show details",
|
|
91
98
|
...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
|
|
99
|
+
...(action.row.status !== "done" && action.row.status !== "canceled" ? [automationEnabled ? "Disable automation" : "Enable automation"] : []),
|
|
92
100
|
...(action.row.status === "review" ? ["Run gates"] : []),
|
|
93
101
|
...(STATUS_ACTIONS[action.row.status] ?? []),
|
|
94
102
|
];
|
|
@@ -107,6 +115,20 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
107
115
|
} catch (error) {
|
|
108
116
|
ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
109
117
|
}
|
|
118
|
+
} else if (choice === "Enable automation" || choice === "Disable automation") {
|
|
119
|
+
try {
|
|
120
|
+
const enabled = choice === "Enable automation";
|
|
121
|
+
const updated = await callService<Record<string, unknown>, Artifact>("tasks.set_automation", {
|
|
122
|
+
id: action.row.id,
|
|
123
|
+
enabled,
|
|
124
|
+
actor: "user",
|
|
125
|
+
source: "tasks-tui",
|
|
126
|
+
});
|
|
127
|
+
action.row.extra = updated.extra;
|
|
128
|
+
ctx.ui.notify(`Automation ${enabled ? "enabled" : "disabled"}: ${action.row.title}`, enabled ? "warning" : "info");
|
|
129
|
+
} catch (error) {
|
|
130
|
+
ctx.ui.notify(`Automation setting failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
131
|
+
}
|
|
110
132
|
} else if (choice === "Run gates") {
|
|
111
133
|
try {
|
|
112
134
|
const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id, actor: "user", source: "tasks-tui" });
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Db } from "../db.ts";
|
|
2
|
-
import type { GateResult } from "../domain/gate.ts";
|
|
2
|
+
import type { GateResult, GateRunOptions } from "../domain/gate.ts";
|
|
3
3
|
import type { GateRunner } from "../ports/gate-runner.ts";
|
|
4
4
|
import { runGates, runGatesAsync } from "../ops.ts";
|
|
5
5
|
|
|
@@ -10,7 +10,7 @@ export class SQLiteGateRunner implements GateRunner {
|
|
|
10
10
|
return runGates(this.db, artifactId);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
runAsync(artifactId: string): Promise<GateResult[]> {
|
|
14
|
-
return runGatesAsync(this.db, artifactId);
|
|
13
|
+
runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]> {
|
|
14
|
+
return runGatesAsync(this.db, artifactId, options);
|
|
15
15
|
}
|
|
16
16
|
}
|
package/src/cli.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { serveMain } from "./daemon.ts";
|
|
|
10
10
|
import type { GateResult } from "./domain/gate.ts";
|
|
11
11
|
import type { TaskExecutionPlan } from "./task-execution.ts";
|
|
12
12
|
import type { TaskBlockage, TaskCompletion } from "./task-service.ts";
|
|
13
|
+
import type { TaskAutomationResult, TaskAutomationSettings } from "./task-automation.ts";
|
|
13
14
|
|
|
14
15
|
export interface SystemdUnitOptions {
|
|
15
16
|
bunBin: string;
|
|
@@ -57,6 +58,7 @@ const USAGE = `Usage:
|
|
|
57
58
|
papyrus serve
|
|
58
59
|
papyrus service <install|start|stop|restart|status>
|
|
59
60
|
papyrus migrate task-history [--json]
|
|
61
|
+
papyrus automation <status|run> [--json]
|
|
60
62
|
papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
|
|
61
63
|
papyrus tasks plan [--json]
|
|
62
64
|
papyrus tasks graph [--json]
|
|
@@ -69,6 +71,7 @@ const USAGE = `Usage:
|
|
|
69
71
|
papyrus tasks reject <id> [--json]
|
|
70
72
|
papyrus tasks retry <id> [--json]
|
|
71
73
|
papyrus tasks cancel <id> [--json]
|
|
74
|
+
papyrus tasks automate <id> <on|off> [--json]
|
|
72
75
|
papyrus tasks depend <id> <prerequisite-id> [--json]`;
|
|
73
76
|
|
|
74
77
|
function usage(): never {
|
|
@@ -116,6 +119,20 @@ export async function runMigrationCli(args: string[], client: TaskCliClient): Pr
|
|
|
116
119
|
return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
export async function runAutomationCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
123
|
+
const json = args.includes("--json");
|
|
124
|
+
const positional = args.filter((argument) => argument !== "--json");
|
|
125
|
+
if (positional.length !== 1 || (positional[0] !== "status" && positional[0] !== "run")) {
|
|
126
|
+
throw new Error("automation requires exactly `status` or `run`");
|
|
127
|
+
}
|
|
128
|
+
if (positional[0] === "status") {
|
|
129
|
+
const status = await client.call<Record<string, never>, TaskAutomationSettings & { inFlight: boolean }>("automation.status", {});
|
|
130
|
+
return json ? JSON.stringify(status) : `Automation: ${status.enabled ? "enabled" : "disabled"} · interval ${status.intervalMs}ms · max ${status.maxTasksPerSweep} tasks · concurrency ${status.gateConcurrency}`;
|
|
131
|
+
}
|
|
132
|
+
const result = await client.call<Record<string, never>, TaskAutomationResult>("automation.reconcile", {});
|
|
133
|
+
return json ? JSON.stringify(result) : `Automation sweep: ${result.examined} examined · ${result.completed} completed · ${result.rejected} rejected · ${result.started} started · ${result.errors.length} errors${result.skipped ? ` · skipped ${result.skipped}` : ""}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
export async function runSkillCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
120
137
|
const json = args.includes("--json");
|
|
121
138
|
const positional: string[] = [];
|
|
@@ -241,6 +258,18 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
241
258
|
human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
|
|
242
259
|
break;
|
|
243
260
|
}
|
|
261
|
+
case "automate": {
|
|
262
|
+
if (!id || (dependencyId !== "on" && dependencyId !== "off") || positional.length !== 3) throw new Error("tasks automate requires a task id and on or off");
|
|
263
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.set_automation", {
|
|
264
|
+
id,
|
|
265
|
+
enabled: dependencyId === "on",
|
|
266
|
+
actor: "user",
|
|
267
|
+
source: "cli",
|
|
268
|
+
});
|
|
269
|
+
result = artifact;
|
|
270
|
+
human = `Automation ${dependencyId}: ${artifactLabel(artifact)}`;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
244
273
|
case "depend": {
|
|
245
274
|
if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
|
|
246
275
|
const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
|
|
@@ -252,7 +281,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
252
281
|
break;
|
|
253
282
|
}
|
|
254
283
|
default:
|
|
255
|
-
throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, or depend");
|
|
284
|
+
throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, automate, or depend");
|
|
256
285
|
}
|
|
257
286
|
return json ? JSON.stringify(result) : human;
|
|
258
287
|
}
|
|
@@ -265,6 +294,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
265
294
|
console.log(await runTaskCli(args.slice(1), client));
|
|
266
295
|
return;
|
|
267
296
|
}
|
|
297
|
+
if (command === "automation") {
|
|
298
|
+
const client = await connectPapyrusClient();
|
|
299
|
+
console.log(await runAutomationCli(args.slice(1), client));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
268
302
|
if (command === "skills") {
|
|
269
303
|
const client = await connectPapyrusClient();
|
|
270
304
|
console.log(await runSkillCli(args.slice(1), client));
|
package/src/constants.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const GATE_COMMAND_TIMEOUT_MS = 30_000;
|
|
|
15
15
|
export const GATE_TEST_TIMEOUT_MS = 60_000;
|
|
16
16
|
export const GATE_OUTPUT_LIMIT = 200;
|
|
17
17
|
export const GATE_MAX_BUFFER_BYTES = 1_048_576;
|
|
18
|
+
export const GATE_FILE_MAX_BYTES = 1_048_576;
|
|
18
19
|
|
|
19
20
|
/** Compact task-context limits keep recurring prompt injection bounded. */
|
|
20
21
|
export const TASK_CONTEXT_CURRENT_LIMIT = 3;
|
|
@@ -48,6 +49,19 @@ export const TASK_HISTORY_MAX_LIMIT = 100;
|
|
|
48
49
|
export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
|
|
49
50
|
export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
50
51
|
export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
|
|
52
|
+
/** Explicitly opt-in supervised Task graph reconciliation bounds. */
|
|
53
|
+
export const TASK_AUTOMATION_INTERVAL_MS = 60_000;
|
|
54
|
+
export const TASK_AUTOMATION_MIN_INTERVAL_MS = 10_000;
|
|
55
|
+
export const TASK_AUTOMATION_MAX_INTERVAL_MS = 3_600_000;
|
|
56
|
+
export const TASK_AUTOMATION_MAX_TASKS_PER_SWEEP = 10;
|
|
57
|
+
export const TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP = 100;
|
|
58
|
+
export const TASK_AUTOMATION_GATE_CONCURRENCY = 1;
|
|
59
|
+
export const TASK_AUTOMATION_MAX_GATE_CONCURRENCY = 4;
|
|
60
|
+
export const TASK_AUTOMATION_MAX_RUNTIME_MS = 120_000;
|
|
61
|
+
export const TASK_AUTOMATION_HARD_MAX_RUNTIME_MS = 600_000;
|
|
62
|
+
export const TASK_AUTOMATION_MAX_CANDIDATE_SCAN = 1_000;
|
|
63
|
+
export const TASK_AUTOMATION_ERROR_ID_MAX_LENGTH = 128;
|
|
64
|
+
export const TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH = 500;
|
|
51
65
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
52
66
|
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
53
67
|
export const GRAPH_RENDER_BOX_PADDING = 0;
|
package/src/daemon.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, WAL_CHECKPOINT_INTERVAL_MS, dbPath } from "./constants.ts";
|
|
2
2
|
import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
|
|
3
3
|
import { createApp, createPapyrusService } from "./service.ts";
|
|
4
|
+
import { scheduleTaskAutomation, taskAutomationSettings, type TaskAutomationResult } from "./task-automation.ts";
|
|
5
|
+
import { logEvent } from "./log.ts";
|
|
4
6
|
|
|
5
7
|
/** Start the supervised, long-running Papyrus service. */
|
|
6
8
|
export function serveMain(): void {
|
|
7
9
|
const stateDir = daemonStateDir();
|
|
8
10
|
const token = loadOrCreateToken(stateDir);
|
|
9
|
-
const
|
|
11
|
+
const automation = taskAutomationSettings(process.env);
|
|
12
|
+
const service = createPapyrusService(dbPath(), { automation });
|
|
10
13
|
const app = createApp({ service, token });
|
|
11
14
|
const server = Bun.serve({
|
|
12
15
|
hostname: DAEMON_HOST,
|
|
@@ -19,11 +22,23 @@ export function serveMain(): void {
|
|
|
19
22
|
}
|
|
20
23
|
writeDaemonPort(stateDir, server.port);
|
|
21
24
|
const checkpointTimer = setInterval(() => {
|
|
22
|
-
try { service.checkpoint(); } catch (error) {
|
|
25
|
+
try { service.checkpoint(); } catch (error) { logEvent("error", "checkpoint_failed", { message: error instanceof Error ? error.message : String(error) }); }
|
|
23
26
|
}, WAL_CHECKPOINT_INTERVAL_MS);
|
|
24
27
|
const optimizeTimer = setInterval(() => {
|
|
25
|
-
try { service.optimize(); } catch (error) {
|
|
28
|
+
try { service.optimize(); } catch (error) { logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) }); }
|
|
26
29
|
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
30
|
+
const stopAutomation = scheduleTaskAutomation(automation, async () => {
|
|
31
|
+
const result = await service.execute("automation.reconcile", {}) as TaskAutomationResult;
|
|
32
|
+
logEvent(result.errors.length > 0 ? "warn" : "info", "automation_sweep", {
|
|
33
|
+
examined: result.examined,
|
|
34
|
+
completed: result.completed,
|
|
35
|
+
rejected: result.rejected,
|
|
36
|
+
started: result.started,
|
|
37
|
+
errors: result.errors.length,
|
|
38
|
+
timedOut: result.timedOut,
|
|
39
|
+
skipped: result.skipped,
|
|
40
|
+
});
|
|
41
|
+
}, (error) => logEvent("error", "automation_sweep_failed", { message: error instanceof Error ? error.message : String(error) }));
|
|
27
42
|
|
|
28
43
|
let stopping = false;
|
|
29
44
|
const shutdown = () => {
|
|
@@ -31,11 +46,12 @@ export function serveMain(): void {
|
|
|
31
46
|
stopping = true;
|
|
32
47
|
clearInterval(checkpointTimer);
|
|
33
48
|
clearInterval(optimizeTimer);
|
|
49
|
+
stopAutomation();
|
|
34
50
|
clearDaemonPort(stateDir);
|
|
35
51
|
service.close();
|
|
36
52
|
void server.stop(true).finally(() => process.exit(0));
|
|
37
53
|
};
|
|
38
54
|
process.on("SIGINT", shutdown);
|
|
39
55
|
process.on("SIGTERM", shutdown);
|
|
40
|
-
|
|
56
|
+
logEvent("info", "listening", { host: DAEMON_HOST, port: server.port, automationEnabled: automation.enabled });
|
|
41
57
|
}
|
package/src/domain/gate.ts
CHANGED
package/src/domain/task-event.ts
CHANGED
package/src/log.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type LogLevel = "info" | "warn" | "error";
|
|
2
|
+
|
|
3
|
+
/** Credential-safe structured daemon event. Callers must pass bounded, non-sensitive fields. */
|
|
4
|
+
export function logEvent(level: LogLevel, event: string, fields: Record<string, unknown> = {}): void {
|
|
5
|
+
console.error(JSON.stringify({ timestamp: new Date().toISOString(), level, component: "papyrus-daemon", event, ...fields }));
|
|
6
|
+
}
|
package/src/ops.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { exec } from "node:child_process";
|
|
|
7
7
|
import type { Db } from "./db.ts";
|
|
8
8
|
import { inTransaction } from "./db.ts";
|
|
9
9
|
import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
|
|
10
|
-
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
10
|
+
import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
|
|
11
11
|
export type { Artifact } from "./domain/artifact.ts";
|
|
12
12
|
export type { Gate, GateResult } from "./domain/gate.ts";
|
|
13
13
|
export type CreateInput = CreateArtifactInput;
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
GATE_TEST_TIMEOUT_MS,
|
|
21
21
|
GATE_OUTPUT_LIMIT,
|
|
22
22
|
GATE_MAX_BUFFER_BYTES,
|
|
23
|
+
GATE_FILE_MAX_BYTES,
|
|
23
24
|
} from "./constants.ts";
|
|
24
25
|
|
|
25
26
|
const require_ = createRequire(import.meta.url);
|
|
@@ -234,6 +235,12 @@ export function injectableRules(db: Db): Array<{ id: string; title: string; body
|
|
|
234
235
|
});
|
|
235
236
|
}
|
|
236
237
|
|
|
238
|
+
function readBoundedGateFile(path: string): string {
|
|
239
|
+
const { readFileSync, statSync } = require_("node:fs");
|
|
240
|
+
if (statSync(path).size > GATE_FILE_MAX_BYTES) throw new Error(`file exceeds ${GATE_FILE_MAX_BYTES} bytes`);
|
|
241
|
+
return readFileSync(path, "utf-8") as string;
|
|
242
|
+
}
|
|
243
|
+
|
|
237
244
|
export function runGates(db: Db, artifactId: string): GateResult[] {
|
|
238
245
|
const art = getArtifact(db, artifactId);
|
|
239
246
|
if (!art) throw new Error("artifact not found");
|
|
@@ -246,9 +253,8 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
|
|
|
246
253
|
return { gate, passed: exists, output: exists ? "exists" : "not found" };
|
|
247
254
|
}
|
|
248
255
|
case "contains": {
|
|
249
|
-
const { readFileSync } = require_("node:fs");
|
|
250
256
|
try {
|
|
251
|
-
const content =
|
|
257
|
+
const content = readBoundedGateFile(gate.target);
|
|
252
258
|
const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
|
|
253
259
|
return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
|
|
254
260
|
} catch {
|
|
@@ -299,9 +305,8 @@ function runNonProcessGate(gate: Gate): GateResult {
|
|
|
299
305
|
return { gate, passed: exists, output: exists ? "exists" : "not found" };
|
|
300
306
|
}
|
|
301
307
|
if (gate.type === "contains") {
|
|
302
|
-
const { readFileSync } = require_("node:fs");
|
|
303
308
|
try {
|
|
304
|
-
const content =
|
|
309
|
+
const content = readBoundedGateFile(gate.target);
|
|
305
310
|
const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
|
|
306
311
|
return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
|
|
307
312
|
} catch {
|
|
@@ -312,15 +317,21 @@ function runNonProcessGate(gate: Gate): GateResult {
|
|
|
312
317
|
}
|
|
313
318
|
|
|
314
319
|
/** Gate runner for daemon request paths; subprocess gates never block the event loop. */
|
|
315
|
-
export async function runGatesAsync(db: Db, artifactId: string): Promise<GateResult[]> {
|
|
320
|
+
export async function runGatesAsync(db: Db, artifactId: string, options: GateRunOptions = {}): Promise<GateResult[]> {
|
|
316
321
|
const art = getArtifact(db, artifactId);
|
|
317
322
|
if (!art) throw new Error("artifact not found");
|
|
318
323
|
const gates = (art.extra["gates"] as Gate[]) ?? [];
|
|
319
324
|
const results: GateResult[] = [];
|
|
320
325
|
for (const gate of gates) {
|
|
326
|
+
const remainingMs = options.deadlineMs === undefined ? undefined : options.deadlineMs - Date.now();
|
|
327
|
+
if (remainingMs !== undefined && remainingMs <= 0) {
|
|
328
|
+
results.push({ gate, passed: false, output: "gate runtime deadline exceeded" });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
321
331
|
if (gate.type === "command" || gate.type === "test") {
|
|
322
332
|
const command = gate.type === "test" ? `npx vitest run ${gate.target} --reporter=dot` : gate.target;
|
|
323
|
-
const
|
|
333
|
+
const configuredTimeout = gate.type === "test" ? GATE_TEST_TIMEOUT_MS : GATE_COMMAND_TIMEOUT_MS;
|
|
334
|
+
const timeout = remainingMs === undefined ? configuredTimeout : Math.max(1, Math.min(configuredTimeout, remainingMs));
|
|
324
335
|
const executed = await executeGateCommand(command, timeout);
|
|
325
336
|
results.push({
|
|
326
337
|
gate,
|
package/src/ports/gate-runner.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { GateResult } from "../domain/gate.ts";
|
|
1
|
+
import type { GateResult, GateRunOptions } from "../domain/gate.ts";
|
|
2
2
|
|
|
3
3
|
export interface GateRunner {
|
|
4
4
|
run(artifactId: string): GateResult[];
|
|
5
|
-
runAsync(artifactId: string): Promise<GateResult[]>;
|
|
5
|
+
runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]>;
|
|
6
6
|
}
|
package/src/service.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { GateRunner } from "./ports/gate-runner.ts";
|
|
|
13
13
|
import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
14
14
|
import { projectTaskExecution } from "./task-execution.ts";
|
|
15
15
|
import { Tasks, type TaskStatus } from "./task-service.ts";
|
|
16
|
+
import { TaskAutomationReconciler, taskAutomationSettings, type TaskAutomationSettings } from "./task-automation.ts";
|
|
16
17
|
import {
|
|
17
18
|
createArtifactTemplate,
|
|
18
19
|
createDocument,
|
|
@@ -40,6 +41,8 @@ import { instantiateSkillWorkflow } from "./skill-execution.ts";
|
|
|
40
41
|
|
|
41
42
|
export const EXPECTED_OPERATION_NAMES = [
|
|
42
43
|
"system.migrate",
|
|
44
|
+
"automation.status",
|
|
45
|
+
"automation.reconcile",
|
|
43
46
|
"artifact.create",
|
|
44
47
|
"artifact.query",
|
|
45
48
|
"artifact.show",
|
|
@@ -61,6 +64,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
61
64
|
"tasks.complete",
|
|
62
65
|
"tasks.run_gates",
|
|
63
66
|
"tasks.set_checklist",
|
|
67
|
+
"tasks.set_automation",
|
|
64
68
|
"tasks.context",
|
|
65
69
|
"tasks.reject",
|
|
66
70
|
"tasks.retry",
|
|
@@ -144,6 +148,7 @@ function handlers(
|
|
|
144
148
|
artifacts: ArtifactStore,
|
|
145
149
|
gates: GateRunner,
|
|
146
150
|
tasks: Tasks,
|
|
151
|
+
automation: TaskAutomationReconciler,
|
|
147
152
|
events: TaskEventStore,
|
|
148
153
|
migrate: () => unknown,
|
|
149
154
|
): Record<OperationName, OperationHandler> {
|
|
@@ -164,6 +169,8 @@ function handlers(
|
|
|
164
169
|
});
|
|
165
170
|
return {
|
|
166
171
|
"system.migrate": () => migrate(),
|
|
172
|
+
"automation.status": () => automation.status(),
|
|
173
|
+
"automation.reconcile": () => automation.reconcile(),
|
|
167
174
|
"artifact.create": (input) => {
|
|
168
175
|
const normalized = normalizeCreateInput(input);
|
|
169
176
|
if (normalized.kind !== "task") return artifacts.create(normalized);
|
|
@@ -241,6 +248,10 @@ function handlers(
|
|
|
241
248
|
"tasks.complete": (input) => tasks.completeAsync(string(input, "id"), eventContext(input)),
|
|
242
249
|
"tasks.run_gates": (input) => tasks.runGates(string(input, "id"), eventContext(input)),
|
|
243
250
|
"tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
|
|
251
|
+
"tasks.set_automation": (input) => {
|
|
252
|
+
if (typeof input["enabled"] !== "boolean") throw new Error("enabled must be a boolean");
|
|
253
|
+
return tasks.setAutomation(string(input, "id"), input["enabled"], eventContext(input));
|
|
254
|
+
},
|
|
244
255
|
"tasks.context": () => taskContext(artifacts, tasks.active()?.id),
|
|
245
256
|
"tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
|
|
246
257
|
"tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
|
|
@@ -293,14 +304,15 @@ function handlers(
|
|
|
293
304
|
};
|
|
294
305
|
}
|
|
295
306
|
|
|
296
|
-
export function createPapyrusService(path: string): PapyrusService {
|
|
307
|
+
export function createPapyrusService(path: string, options: { automation?: TaskAutomationSettings } = {}): PapyrusService {
|
|
297
308
|
const db = openDb(path);
|
|
298
309
|
const artifacts = new SQLiteArtifactStore(db);
|
|
299
310
|
const gates = new SQLiteGateRunner(db);
|
|
300
311
|
const focus = new SQLiteTaskFocusStore(db);
|
|
301
312
|
const events = new SQLiteTaskEventStore(db);
|
|
302
313
|
const tasks = new Tasks(artifacts, gates, focus, events);
|
|
303
|
-
const
|
|
314
|
+
const automation = new TaskAutomationReconciler(tasks, options.automation ?? taskAutomationSettings({}));
|
|
315
|
+
const registry = handlers(artifacts, gates, tasks, automation, events, () => migrateDb(db));
|
|
304
316
|
const state = (): SchemaState => {
|
|
305
317
|
const current = schemaVersion(db);
|
|
306
318
|
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|
|
@@ -311,7 +323,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
311
323
|
async execute(operation, input = {}) {
|
|
312
324
|
const handler = registry[operation as OperationName];
|
|
313
325
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
314
|
-
if (operation !== "system.migrate" && state().migrationRequired) {
|
|
326
|
+
if (operation !== "system.migrate" && operation !== "automation.status" && state().migrationRequired) {
|
|
315
327
|
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-history`");
|
|
316
328
|
}
|
|
317
329
|
return handler(input);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TASK_AUTOMATION_ERROR_ID_MAX_LENGTH,
|
|
3
|
+
TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH,
|
|
4
|
+
TASK_AUTOMATION_GATE_CONCURRENCY,
|
|
5
|
+
TASK_AUTOMATION_HARD_MAX_RUNTIME_MS,
|
|
6
|
+
TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP,
|
|
7
|
+
TASK_AUTOMATION_INTERVAL_MS,
|
|
8
|
+
TASK_AUTOMATION_MAX_CANDIDATE_SCAN,
|
|
9
|
+
TASK_AUTOMATION_MAX_GATE_CONCURRENCY,
|
|
10
|
+
TASK_AUTOMATION_MAX_INTERVAL_MS,
|
|
11
|
+
TASK_AUTOMATION_MAX_RUNTIME_MS,
|
|
12
|
+
TASK_AUTOMATION_MAX_TASKS_PER_SWEEP,
|
|
13
|
+
TASK_AUTOMATION_MIN_INTERVAL_MS,
|
|
14
|
+
} from "./constants.ts";
|
|
15
|
+
import type { Artifact } from "./domain/artifact.ts";
|
|
16
|
+
import { projectTaskExecution } from "./task-execution.ts";
|
|
17
|
+
import type { Tasks } from "./task-service.ts";
|
|
18
|
+
|
|
19
|
+
export interface TaskAutomationSettings {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
intervalMs: number;
|
|
22
|
+
maxTasksPerSweep: number;
|
|
23
|
+
gateConcurrency: number;
|
|
24
|
+
maxRuntimeMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TaskAutomationResult {
|
|
28
|
+
skipped?: "disabled" | "in-flight";
|
|
29
|
+
examined: number;
|
|
30
|
+
completed: number;
|
|
31
|
+
rejected: number;
|
|
32
|
+
started: number;
|
|
33
|
+
errors: Array<{ taskId: string; message: string }>;
|
|
34
|
+
timedOut: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function boundedInteger(
|
|
38
|
+
env: Record<string, string | undefined>,
|
|
39
|
+
name: string,
|
|
40
|
+
fallback: number,
|
|
41
|
+
minimum: number,
|
|
42
|
+
maximum: number,
|
|
43
|
+
): number {
|
|
44
|
+
const source = env[name];
|
|
45
|
+
if (source === undefined || source === "") return fallback;
|
|
46
|
+
const value = Number(source);
|
|
47
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
48
|
+
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function taskAutomationSettings(env: Record<string, string | undefined> = process.env): TaskAutomationSettings {
|
|
54
|
+
const enabled = env["PAPYRUS_AUTOMATION_ENABLED"] === "1";
|
|
55
|
+
if (env["PAPYRUS_AUTOMATION_ENABLED"] !== undefined && env["PAPYRUS_AUTOMATION_ENABLED"] !== "0" && !enabled) {
|
|
56
|
+
throw new Error("PAPYRUS_AUTOMATION_ENABLED must be 0 or 1");
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
enabled,
|
|
60
|
+
intervalMs: boundedInteger(env, "PAPYRUS_AUTOMATION_INTERVAL_MS", TASK_AUTOMATION_INTERVAL_MS, TASK_AUTOMATION_MIN_INTERVAL_MS, TASK_AUTOMATION_MAX_INTERVAL_MS),
|
|
61
|
+
maxTasksPerSweep: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_TASKS", TASK_AUTOMATION_MAX_TASKS_PER_SWEEP, 1, TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP),
|
|
62
|
+
gateConcurrency: boundedInteger(env, "PAPYRUS_AUTOMATION_GATE_CONCURRENCY", TASK_AUTOMATION_GATE_CONCURRENCY, 1, TASK_AUTOMATION_MAX_GATE_CONCURRENCY),
|
|
63
|
+
maxRuntimeMs: boundedInteger(env, "PAPYRUS_AUTOMATION_MAX_RUNTIME_MS", TASK_AUTOMATION_MAX_RUNTIME_MS, 1, TASK_AUTOMATION_HARD_MAX_RUNTIME_MS),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function automationEnabled(task: Artifact): boolean {
|
|
68
|
+
const automation = task.extra["automation"];
|
|
69
|
+
return typeof automation === "object"
|
|
70
|
+
&& automation !== null
|
|
71
|
+
&& !Array.isArray(automation)
|
|
72
|
+
&& (automation as Record<string, unknown>)["enabled"] === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function emptyResult(skipped?: TaskAutomationResult["skipped"]): TaskAutomationResult {
|
|
76
|
+
return { ...(skipped ? { skipped } : {}), examined: 0, completed: 0, rejected: 0, started: 0, errors: [], timedOut: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function boundedError(taskId: string, error: unknown): TaskAutomationResult["errors"][number] {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return {
|
|
82
|
+
taskId: taskId.slice(0, TASK_AUTOMATION_ERROR_ID_MAX_LENGTH),
|
|
83
|
+
message: message.slice(0, TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface TaskAutomationScheduler {
|
|
88
|
+
setInterval(callback: () => void, intervalMs: number): unknown;
|
|
89
|
+
clearInterval(handle: unknown): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SYSTEM_SCHEDULER: TaskAutomationScheduler = {
|
|
93
|
+
setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
|
|
94
|
+
clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function scheduleTaskAutomation(
|
|
98
|
+
settings: TaskAutomationSettings,
|
|
99
|
+
sweep: () => Promise<unknown>,
|
|
100
|
+
onError: (error: unknown) => void,
|
|
101
|
+
scheduler: TaskAutomationScheduler = SYSTEM_SCHEDULER,
|
|
102
|
+
): () => void {
|
|
103
|
+
if (!settings.enabled) return () => {};
|
|
104
|
+
const handle = scheduler.setInterval(() => { void sweep().catch(onError); }, settings.intervalMs);
|
|
105
|
+
return () => scheduler.clearInterval(handle);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class TaskAutomationReconciler {
|
|
109
|
+
private inFlight = false;
|
|
110
|
+
|
|
111
|
+
constructor(
|
|
112
|
+
private readonly tasks: Tasks,
|
|
113
|
+
private readonly settings: TaskAutomationSettings,
|
|
114
|
+
private readonly now: () => number = () => Date.now(),
|
|
115
|
+
) {}
|
|
116
|
+
|
|
117
|
+
status(): TaskAutomationSettings & { inFlight: boolean } {
|
|
118
|
+
return { ...this.settings, inFlight: this.inFlight };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async reconcile(): Promise<TaskAutomationResult> {
|
|
122
|
+
if (!this.settings.enabled) return emptyResult("disabled");
|
|
123
|
+
if (this.inFlight) return emptyResult("in-flight");
|
|
124
|
+
this.inFlight = true;
|
|
125
|
+
try { return await this.runSweep(); }
|
|
126
|
+
finally { this.inFlight = false; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async runSweep(): Promise<TaskAutomationResult> {
|
|
130
|
+
const result = emptyResult();
|
|
131
|
+
const deadline = this.now() + this.settings.maxRuntimeMs;
|
|
132
|
+
const candidates = this.tasks.list({ status: "review", limit: TASK_AUTOMATION_MAX_CANDIDATE_SCAN })
|
|
133
|
+
.filter(automationEnabled)
|
|
134
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
135
|
+
.slice(0, this.settings.maxTasksPerSweep);
|
|
136
|
+
const completedIds = new Set<string>();
|
|
137
|
+
|
|
138
|
+
for (let offset = 0; offset < candidates.length; offset += this.settings.gateConcurrency) {
|
|
139
|
+
if (this.now() >= deadline) { result.timedOut = true; break; }
|
|
140
|
+
const batch = candidates.slice(offset, offset + this.settings.gateConcurrency);
|
|
141
|
+
await Promise.all(batch.map(async (task) => {
|
|
142
|
+
result.examined += 1;
|
|
143
|
+
try {
|
|
144
|
+
const completion = await this.tasks.completeAsync(task.id, {
|
|
145
|
+
actor: "daemon",
|
|
146
|
+
source: "automation-reconciler",
|
|
147
|
+
reason: "automation-enabled review reconciliation",
|
|
148
|
+
}, { focusSuccessor: false, gateDeadlineMs: deadline });
|
|
149
|
+
if (completion.completed) {
|
|
150
|
+
result.completed += 1;
|
|
151
|
+
completedIds.add(task.id);
|
|
152
|
+
} else result.rejected += 1;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
result.errors.push(boundedError(task.id, error));
|
|
155
|
+
}
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let remaining = Math.max(0, this.settings.maxTasksPerSweep - result.examined);
|
|
160
|
+
if (remaining > 0 && completedIds.size > 0 && this.now() < deadline) {
|
|
161
|
+
let graph: ReturnType<Tasks["graph"]>;
|
|
162
|
+
try { graph = this.tasks.graph(); }
|
|
163
|
+
catch (error) {
|
|
164
|
+
result.errors.push(boundedError("graph", error));
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
const stateById = new Map(projectTaskExecution(graph).nodes.map((node) => [node.id, node.state]));
|
|
168
|
+
for (const node of [...graph.nodes].sort((left, right) => left.task.id.localeCompare(right.task.id))) {
|
|
169
|
+
if (remaining === 0 || this.now() >= deadline) break;
|
|
170
|
+
if (node.task.status !== "todo" || !automationEnabled(node.task) || stateById.get(node.task.id) !== "ready") continue;
|
|
171
|
+
if (!node.dependencyIds.some((id) => completedIds.has(id))) continue;
|
|
172
|
+
try {
|
|
173
|
+
this.tasks.transition(node.task.id, "start", {
|
|
174
|
+
actor: "daemon",
|
|
175
|
+
source: "automation-reconciler",
|
|
176
|
+
reason: "automation-enabled successor became ready",
|
|
177
|
+
});
|
|
178
|
+
result.started += 1;
|
|
179
|
+
remaining -= 1;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
result.errors.push(boundedError(node.task.id, error));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (this.now() >= deadline) result.timedOut = true;
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/task-service.ts
CHANGED
|
@@ -46,6 +46,11 @@ export interface ChecklistReview {
|
|
|
46
46
|
reason?: string;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export interface TaskCompletionOptions {
|
|
50
|
+
focusSuccessor?: boolean;
|
|
51
|
+
gateDeadlineMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
export interface TaskCompletion {
|
|
50
55
|
artifact: Artifact;
|
|
51
56
|
gates: GateResult[];
|
|
@@ -215,23 +220,23 @@ export class Tasks {
|
|
|
215
220
|
});
|
|
216
221
|
}
|
|
217
222
|
|
|
218
|
-
complete(id: string, context: TaskEventContext = {}): TaskCompletion {
|
|
223
|
+
complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
|
|
219
224
|
const task = this.requireReview(id);
|
|
220
225
|
const attemptId = crypto.randomUUID();
|
|
221
226
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
222
227
|
const checklist = this.reviewChecklist(task);
|
|
223
228
|
const results = this.gates.run(id);
|
|
224
|
-
return this.resolveCompletion(id, attemptId, results, checklist, context);
|
|
229
|
+
return this.resolveCompletion(id, attemptId, results, checklist, context, options);
|
|
225
230
|
}
|
|
226
231
|
|
|
227
|
-
async completeAsync(id: string, context: TaskEventContext = {}): Promise<TaskCompletion> {
|
|
232
|
+
async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
|
|
228
233
|
const task = this.requireReview(id);
|
|
229
234
|
const attemptId = crypto.randomUUID();
|
|
230
235
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
231
236
|
const checklist = this.reviewChecklist(task);
|
|
232
|
-
const results = await this.gates.runAsync(id);
|
|
237
|
+
const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs });
|
|
233
238
|
this.requireReview(id);
|
|
234
|
-
return this.resolveCompletion(id, attemptId, results, checklist, context);
|
|
239
|
+
return this.resolveCompletion(id, attemptId, results, checklist, context, options);
|
|
235
240
|
}
|
|
236
241
|
|
|
237
242
|
async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
|
|
@@ -251,6 +256,19 @@ export class Tasks {
|
|
|
251
256
|
return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
setAutomation(id: string, enabled: boolean, context: TaskEventContext = {}): Artifact {
|
|
260
|
+
return this.events.atomic(() => {
|
|
261
|
+
const task = this.require(id);
|
|
262
|
+
const current = task.extra["automation"];
|
|
263
|
+
const automation = typeof current === "object" && current !== null && !Array.isArray(current)
|
|
264
|
+
? current as Record<string, unknown>
|
|
265
|
+
: {};
|
|
266
|
+
const updated = this.artifacts.setExtra(id, { ...task.extra, automation: { ...automation, enabled } })!;
|
|
267
|
+
this.appendEvent({ taskId: id, type: enabled ? "automation_enabled" : "automation_disabled" }, context);
|
|
268
|
+
return updated;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
254
272
|
depend(id: string, dependencyId: string): Artifact {
|
|
255
273
|
this.require(id);
|
|
256
274
|
this.require(dependencyId);
|
|
@@ -345,6 +363,7 @@ export class Tasks {
|
|
|
345
363
|
gates: GateResult[],
|
|
346
364
|
checklist: ChecklistReview[],
|
|
347
365
|
context: TaskEventContext,
|
|
366
|
+
options: TaskCompletionOptions,
|
|
348
367
|
): TaskCompletion {
|
|
349
368
|
const failed = gates.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted);
|
|
350
369
|
if (failed) {
|
|
@@ -361,10 +380,10 @@ export class Tasks {
|
|
|
361
380
|
return { artifact, gates, checklist, completed: false, focused: this.active(), blocked: [] };
|
|
362
381
|
});
|
|
363
382
|
}
|
|
364
|
-
return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context));
|
|
383
|
+
return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context, options));
|
|
365
384
|
}
|
|
366
385
|
|
|
367
|
-
private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext): TaskCompletion {
|
|
386
|
+
private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext, options: TaskCompletionOptions): TaskCompletion {
|
|
368
387
|
const successorIds = this.relationships(id)
|
|
369
388
|
.filter((edge) => edge.relation === "depends_on" && edge.to === id)
|
|
370
389
|
.map((edge) => edge.from);
|
|
@@ -392,7 +411,7 @@ export class Tasks {
|
|
|
392
411
|
blocked.push({ artifact: successor, dependencyIds });
|
|
393
412
|
continue;
|
|
394
413
|
}
|
|
395
|
-
if (!focused) {
|
|
414
|
+
if (options.focusSuccessor !== false && !focused) {
|
|
396
415
|
this.focusStore.set(successor.id);
|
|
397
416
|
focused = successor;
|
|
398
417
|
}
|