@danypops/papyrus 0.4.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 +30 -4
- package/extension/src/domain-tools.ts +18 -5
- package/extension/src/task-detail-format.ts +24 -1
- package/extension/src/task-detail-view.ts +6 -3
- package/extension/src/tasks.ts +29 -5
- package/package.json +1 -1
- package/src/adapters/sqlite-gate-runner.ts +3 -3
- package/src/adapters/sqlite-task-event-store.ts +92 -0
- package/src/cli.ts +51 -7
- package/src/constants.ts +21 -1
- package/src/daemon.ts +20 -4
- package/src/db.ts +77 -27
- package/src/domain/gate.ts +5 -0
- package/src/domain/task-event.ts +104 -0
- package/src/log.ts +6 -0
- package/src/ops.ts +18 -7
- package/src/ports/gate-runner.ts +2 -2
- package/src/ports/task-event-store.ts +43 -0
- package/src/service.ts +72 -17
- package/src/skill-execution.ts +26 -10
- package/src/task-automation.ts +188 -0
- package/src/task-service.ts +140 -54
package/src/skill-execution.ts
CHANGED
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
type SkillDefinition,
|
|
10
10
|
} from "./domain/skill-definition.ts";
|
|
11
11
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
12
|
+
import type { TaskEventContext } from "./domain/task-event.ts";
|
|
13
|
+
import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
12
14
|
import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
13
15
|
import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
|
|
14
|
-
import type { TaskGraph, TaskNode } from "./task-service.ts";
|
|
16
|
+
import type { TaskGraph, TaskNode, TaskStatus } from "./task-service.ts";
|
|
15
17
|
|
|
16
18
|
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
17
19
|
const EXACT_PLACEHOLDER_PATTERN = /^{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}$/;
|
|
@@ -115,6 +117,7 @@ export function instantiateSkillWorkflow(
|
|
|
115
117
|
artifacts: ArtifactStore,
|
|
116
118
|
skillId: string,
|
|
117
119
|
input: InstantiateSkillWorkflowInput = {},
|
|
120
|
+
history?: { events: TaskEventStore; context?: TaskEventContext },
|
|
118
121
|
): SkillWorkflowRunResult {
|
|
119
122
|
const { definition } = requireWorkflowSkill(artifacts, skillId);
|
|
120
123
|
const arguments_ = resolveSkillArguments(definition, input.arguments);
|
|
@@ -138,7 +141,7 @@ export function instantiateSkillWorkflow(
|
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
const atomic = requireAtomicArtifactStore(artifacts);
|
|
141
|
-
|
|
144
|
+
const persist = () => atomic.atomic(() => {
|
|
142
145
|
const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
|
|
143
146
|
id: ids.get(blueprint.ref),
|
|
144
147
|
kind: "doc",
|
|
@@ -163,14 +166,26 @@ export function instantiateSkillWorkflow(
|
|
|
163
166
|
scope: { type: "skill-run", runId, taskIds },
|
|
164
167
|
},
|
|
165
168
|
}));
|
|
166
|
-
const tasks = rendered.blueprints.tasks.map((blueprint) =>
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
const tasks = rendered.blueprints.tasks.map((blueprint) => {
|
|
170
|
+
const task = artifacts.create({
|
|
171
|
+
id: ids.get(blueprint.ref),
|
|
172
|
+
kind: "task",
|
|
173
|
+
title: blueprint.title,
|
|
174
|
+
body: blueprint.body,
|
|
175
|
+
labels: withRunLabel(blueprint.labels, runId),
|
|
176
|
+
extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
|
|
177
|
+
});
|
|
178
|
+
if (history) history.events.append({
|
|
179
|
+
taskId: task.id,
|
|
180
|
+
type: "created",
|
|
181
|
+
actor: history.context?.actor ?? "system",
|
|
182
|
+
source: history.context?.source ?? "skill-run",
|
|
183
|
+
toStatus: task.status as TaskStatus,
|
|
184
|
+
...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
|
|
185
|
+
...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
|
|
186
|
+
});
|
|
187
|
+
return task;
|
|
188
|
+
});
|
|
174
189
|
|
|
175
190
|
for (const blueprint of rendered.blueprints.tasks) {
|
|
176
191
|
const id = ids.get(blueprint.ref)!;
|
|
@@ -201,4 +216,5 @@ export function instantiateSkillWorkflow(
|
|
|
201
216
|
execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
|
|
202
217
|
};
|
|
203
218
|
});
|
|
219
|
+
return history ? history.events.atomic(persist) : persist();
|
|
204
220
|
}
|
|
@@ -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
|
@@ -2,9 +2,11 @@ import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX
|
|
|
2
2
|
import type { Artifact } from "./domain/artifact.ts";
|
|
3
3
|
import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
|
|
4
4
|
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
5
|
+
import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
|
|
5
6
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
6
7
|
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
7
8
|
import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
|
|
9
|
+
import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
|
|
8
10
|
import { assertDependencyEdgeAllowed } from "./task-execution.ts";
|
|
9
11
|
|
|
10
12
|
export interface TaskFilter {
|
|
@@ -13,11 +15,13 @@ export interface TaskFilter {
|
|
|
13
15
|
limit?: number;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
export type TaskStatus =
|
|
18
|
+
export type TaskStatus = TaskLifecycleStatus;
|
|
17
19
|
|
|
18
20
|
export interface CreateTaskInput {
|
|
21
|
+
id?: string;
|
|
19
22
|
title: string;
|
|
20
23
|
body?: string;
|
|
24
|
+
subtype?: string;
|
|
21
25
|
status?: TaskStatus;
|
|
22
26
|
labels?: string[];
|
|
23
27
|
extra?: Record<string, unknown>;
|
|
@@ -42,6 +46,11 @@ export interface ChecklistReview {
|
|
|
42
46
|
reason?: string;
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
export interface TaskCompletionOptions {
|
|
50
|
+
focusSuccessor?: boolean;
|
|
51
|
+
gateDeadlineMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
export interface TaskCompletion {
|
|
46
55
|
artifact: Artifact;
|
|
47
56
|
gates: GateResult[];
|
|
@@ -77,6 +86,7 @@ export class Tasks {
|
|
|
77
86
|
private readonly artifacts: ArtifactStore,
|
|
78
87
|
private readonly gates: GateRunner,
|
|
79
88
|
private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
|
|
89
|
+
private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
|
|
80
90
|
) {}
|
|
81
91
|
|
|
82
92
|
private require(id: string): Artifact {
|
|
@@ -86,27 +96,32 @@ export class Tasks {
|
|
|
86
96
|
return artifact;
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
create(input: CreateTaskInput): Artifact {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
create(input: CreateTaskInput, context: TaskEventContext = {}): Artifact {
|
|
100
|
+
return this.events.atomic(() => {
|
|
101
|
+
if ((input.dependsOn?.length ?? 0) > TASK_EXECUTION_MAX_DEGREE) {
|
|
102
|
+
throw new Error(`task cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
|
|
103
|
+
}
|
|
104
|
+
if (input.parentId) this.require(input.parentId);
|
|
105
|
+
for (const dependency of input.dependsOn ?? []) this.require(dependency);
|
|
106
|
+
const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
|
|
107
|
+
if (input.gates !== undefined) extra["gates"] = input.gates;
|
|
108
|
+
if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
|
|
109
|
+
const task = this.artifacts.create({
|
|
110
|
+
id: input.id,
|
|
111
|
+
kind: "task",
|
|
112
|
+
title: input.title,
|
|
113
|
+
body: input.body,
|
|
114
|
+
subtype: input.subtype,
|
|
115
|
+
status: input.status,
|
|
116
|
+
labels: input.labels,
|
|
117
|
+
extra,
|
|
118
|
+
templateId: input.templateId,
|
|
119
|
+
});
|
|
120
|
+
if (input.parentId) this.contain(input.parentId, task.id);
|
|
121
|
+
for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
|
|
122
|
+
this.appendEvent({ taskId: task.id, type: "created", toStatus: task.status as TaskStatus }, context);
|
|
123
|
+
return this.show(task.id);
|
|
106
124
|
});
|
|
107
|
-
if (input.parentId) this.contain(input.parentId, task.id);
|
|
108
|
-
for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
|
|
109
|
-
return this.show(task.id);
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
list(filter: TaskFilter = {}): Artifact[] {
|
|
@@ -185,48 +200,55 @@ export class Tasks {
|
|
|
185
200
|
return task;
|
|
186
201
|
}
|
|
187
202
|
|
|
188
|
-
transition(id: string, action: TaskTransition): Artifact {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
|
|
204
|
+
return this.events.atomic(() => {
|
|
205
|
+
const task = this.require(id);
|
|
206
|
+
const transition = TASK_TRANSITIONS[action];
|
|
207
|
+
if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
|
|
208
|
+
if (action === "start") {
|
|
209
|
+
const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
|
|
210
|
+
if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
|
|
211
|
+
this.focusStore.set(id);
|
|
212
|
+
}
|
|
213
|
+
const updated = this.artifacts.setStatus(id, transition.to)!;
|
|
214
|
+
const eventType = { start: "started", submit: "submitted", reject: "review_rejected", retry: "retried", cancel: "canceled" }[action] as AppendTaskEvent["type"];
|
|
215
|
+
this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: transition.to }, context);
|
|
216
|
+
if (action === "start" || action === "retry") this.propagateProgressToAncestors(id, context);
|
|
217
|
+
if (action === "retry") this.focusStore.set(id);
|
|
218
|
+
if (action === "cancel") this.focusStore.clear(id);
|
|
219
|
+
return updated;
|
|
220
|
+
});
|
|
202
221
|
}
|
|
203
222
|
|
|
204
|
-
complete(id: string): TaskCompletion {
|
|
223
|
+
complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
|
|
205
224
|
const task = this.requireReview(id);
|
|
225
|
+
const attemptId = crypto.randomUUID();
|
|
226
|
+
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
206
227
|
const checklist = this.reviewChecklist(task);
|
|
207
228
|
const results = this.gates.run(id);
|
|
208
|
-
|
|
209
|
-
const artifact = this.artifacts.setStatus(id, "rejected")!;
|
|
210
|
-
return { artifact, gates: results, checklist, completed: false, focused: this.active(), blocked: [] };
|
|
211
|
-
}
|
|
212
|
-
return this.finish(id, results, checklist);
|
|
229
|
+
return this.resolveCompletion(id, attemptId, results, checklist, context, options);
|
|
213
230
|
}
|
|
214
231
|
|
|
215
|
-
async completeAsync(id: string): Promise<TaskCompletion> {
|
|
232
|
+
async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
|
|
216
233
|
const task = this.requireReview(id);
|
|
234
|
+
const attemptId = crypto.randomUUID();
|
|
235
|
+
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
217
236
|
const checklist = this.reviewChecklist(task);
|
|
237
|
+
const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs });
|
|
238
|
+
this.requireReview(id);
|
|
239
|
+
return this.resolveCompletion(id, attemptId, results, checklist, context, options);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
|
|
243
|
+
this.require(id);
|
|
218
244
|
const results = await this.gates.runAsync(id);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return { artifact, gates: results, checklist, completed: false, focused: this.active(), blocked: [] };
|
|
222
|
-
}
|
|
223
|
-
const current = this.requireReview(id);
|
|
224
|
-
return this.finish(current.id, results, checklist);
|
|
245
|
+
this.events.atomic(() => this.appendEvent({ taskId: id, type: "gates_evaluated", evidence: { gates: results, result: results.every((gate) => gate.passed) ? "passed" : "failed" } }, context));
|
|
246
|
+
return results;
|
|
225
247
|
}
|
|
226
248
|
|
|
227
|
-
|
|
249
|
+
history(id: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
|
|
228
250
|
this.require(id);
|
|
229
|
-
return this.
|
|
251
|
+
return this.events.history(id, query);
|
|
230
252
|
}
|
|
231
253
|
|
|
232
254
|
setChecklist(id: string, checklist: Checklist): Artifact {
|
|
@@ -234,6 +256,19 @@ export class Tasks {
|
|
|
234
256
|
return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
|
|
235
257
|
}
|
|
236
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
|
+
|
|
237
272
|
depend(id: string, dependencyId: string): Artifact {
|
|
238
273
|
this.require(id);
|
|
239
274
|
this.require(dependencyId);
|
|
@@ -282,7 +317,7 @@ export class Tasks {
|
|
|
282
317
|
.filter((parentId, index, ids) => ids.indexOf(parentId) === index);
|
|
283
318
|
}
|
|
284
319
|
|
|
285
|
-
private propagateProgressToAncestors(id: string): void {
|
|
320
|
+
private propagateProgressToAncestors(id: string, context: TaskEventContext): void {
|
|
286
321
|
const pending = this.parentIds(id);
|
|
287
322
|
const visited = new Set<string>();
|
|
288
323
|
while (pending.length > 0) {
|
|
@@ -291,7 +326,14 @@ export class Tasks {
|
|
|
291
326
|
if (visited.size >= TASK_EXECUTION_MAX_NODES) throw new Error("task ancestry exceeds execution node bound");
|
|
292
327
|
visited.add(parentId);
|
|
293
328
|
const parent = this.require(parentId);
|
|
294
|
-
if (parent.status === "todo")
|
|
329
|
+
if (parent.status === "todo") {
|
|
330
|
+
this.artifacts.setStatus(parentId, "in-progress");
|
|
331
|
+
this.appendEvent({ taskId: parentId, type: "started", fromStatus: "todo", toStatus: "in-progress" }, {
|
|
332
|
+
...context,
|
|
333
|
+
source: "task-ancestry",
|
|
334
|
+
reason: `nested task ${id} entered progress`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
295
337
|
pending.push(...this.parentIds(parentId));
|
|
296
338
|
}
|
|
297
339
|
}
|
|
@@ -315,7 +357,33 @@ export class Tasks {
|
|
|
315
357
|
return ids;
|
|
316
358
|
}
|
|
317
359
|
|
|
318
|
-
private
|
|
360
|
+
private resolveCompletion(
|
|
361
|
+
id: string,
|
|
362
|
+
attemptId: string,
|
|
363
|
+
gates: GateResult[],
|
|
364
|
+
checklist: ChecklistReview[],
|
|
365
|
+
context: TaskEventContext,
|
|
366
|
+
options: TaskCompletionOptions,
|
|
367
|
+
): TaskCompletion {
|
|
368
|
+
const failed = gates.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted);
|
|
369
|
+
if (failed) {
|
|
370
|
+
return this.events.atomic(() => {
|
|
371
|
+
const artifact = this.artifacts.setStatus(id, "rejected")!;
|
|
372
|
+
this.appendEvent({
|
|
373
|
+
taskId: id,
|
|
374
|
+
type: "review_rejected",
|
|
375
|
+
fromStatus: "review",
|
|
376
|
+
toStatus: "rejected",
|
|
377
|
+
attemptId,
|
|
378
|
+
evidence: { gates, checklist, result: "rejected" },
|
|
379
|
+
}, context);
|
|
380
|
+
return { artifact, gates, checklist, completed: false, focused: this.active(), blocked: [] };
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context, options));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext, options: TaskCompletionOptions): TaskCompletion {
|
|
319
387
|
const successorIds = this.relationships(id)
|
|
320
388
|
.filter((edge) => edge.relation === "depends_on" && edge.to === id)
|
|
321
389
|
.map((edge) => edge.from);
|
|
@@ -323,6 +391,14 @@ export class Tasks {
|
|
|
323
391
|
throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
|
|
324
392
|
}
|
|
325
393
|
const artifact = this.artifacts.setStatus(id, "done")!;
|
|
394
|
+
this.appendEvent({
|
|
395
|
+
taskId: id,
|
|
396
|
+
type: "completed",
|
|
397
|
+
fromStatus: "review",
|
|
398
|
+
toStatus: "done",
|
|
399
|
+
attemptId,
|
|
400
|
+
evidence: { gates, checklist, result: "completed" },
|
|
401
|
+
}, context);
|
|
326
402
|
this.focusStore.clear(id);
|
|
327
403
|
const blocked: TaskBlockage[] = [];
|
|
328
404
|
let focused: Artifact | null = null;
|
|
@@ -335,7 +411,7 @@ export class Tasks {
|
|
|
335
411
|
blocked.push({ artifact: successor, dependencyIds });
|
|
336
412
|
continue;
|
|
337
413
|
}
|
|
338
|
-
if (!focused) {
|
|
414
|
+
if (options.focusSuccessor !== false && !focused) {
|
|
339
415
|
this.focusStore.set(successor.id);
|
|
340
416
|
focused = successor;
|
|
341
417
|
}
|
|
@@ -343,6 +419,16 @@ export class Tasks {
|
|
|
343
419
|
return { artifact, gates, checklist, completed: true, focused, blocked };
|
|
344
420
|
}
|
|
345
421
|
|
|
422
|
+
private appendEvent(event: Omit<AppendTaskEvent, "actor" | "source">, context: TaskEventContext): void {
|
|
423
|
+
this.events.append({
|
|
424
|
+
...event,
|
|
425
|
+
actor: context.actor ?? "system",
|
|
426
|
+
source: context.source ?? "task-domain",
|
|
427
|
+
...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
|
|
428
|
+
...(context.reason === undefined ? {} : { reason: context.reason }),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
346
432
|
private requireReview(id: string): Artifact {
|
|
347
433
|
const task = this.require(id);
|
|
348
434
|
if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);
|