@danypops/papyrus 0.6.0 → 0.8.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 +26 -32
- package/extension/src/active-task-continuation.ts +9 -0
- package/extension/src/domain-tools.ts +35 -12
- package/extension/src/index.ts +39 -14
- package/extension/src/skills.ts +1 -0
- package/extension/src/task-widget.ts +4 -2
- package/extension/src/tasks.ts +62 -29
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +6 -1
- package/src/adapters/sqlite-task-focus-store.ts +27 -14
- package/src/adapters/sqlite-task-scope-store.ts +59 -0
- package/src/cli.ts +100 -47
- package/src/constants.ts +9 -14
- package/src/daemon.ts +2 -18
- package/src/db.ts +49 -2
- package/src/domain/artifact.ts +6 -0
- package/src/domain/task-event.ts +6 -2
- package/src/domain/task-scope.ts +39 -0
- package/src/ops.ts +17 -1
- package/src/ports/artifact-store.ts +2 -0
- package/src/ports/task-focus-store.ts +30 -8
- package/src/ports/task-scope-store.ts +40 -0
- package/src/service.ts +79 -27
- package/src/skill-execution.ts +16 -10
- package/src/task-context.ts +4 -2
- package/src/task-service.ts +197 -30
- package/src/task-automation.ts +0 -188
package/src/task-service.ts
CHANGED
|
@@ -1,18 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
TASK_BODY_MAX_LENGTH,
|
|
3
|
+
TASK_EXECUTION_MAX_DEGREE,
|
|
4
|
+
TASK_EXECUTION_MAX_EDGES,
|
|
5
|
+
TASK_EXECUTION_MAX_NODES,
|
|
6
|
+
TASK_LABEL_MAX_COUNT,
|
|
7
|
+
TASK_LABEL_MAX_LENGTH,
|
|
8
|
+
TASK_SCOPE_MAX_TASKS,
|
|
9
|
+
TASK_TITLE_MAX_LENGTH,
|
|
10
|
+
} from "./constants.ts";
|
|
2
11
|
import type { Artifact } from "./domain/artifact.ts";
|
|
3
12
|
import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
|
|
4
13
|
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
5
14
|
import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
|
|
15
|
+
import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
|
|
6
16
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
7
17
|
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
8
|
-
import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
|
|
18
|
+
import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "./ports/task-focus-store.ts";
|
|
9
19
|
import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
|
|
20
|
+
import { InMemoryTaskScopeStore, type TaskScopeStore } from "./ports/task-scope-store.ts";
|
|
10
21
|
import { assertDependencyEdgeAllowed } from "./task-execution.ts";
|
|
11
22
|
|
|
23
|
+
export interface UpdateTaskInput {
|
|
24
|
+
title?: string;
|
|
25
|
+
body?: string;
|
|
26
|
+
labels?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
export interface TaskFilter {
|
|
13
30
|
status?: string;
|
|
14
31
|
text?: string;
|
|
15
32
|
limit?: number;
|
|
33
|
+
projectRoot?: string;
|
|
34
|
+
scope?: TaskViewMode;
|
|
35
|
+
rootTaskId?: string;
|
|
16
36
|
}
|
|
17
37
|
|
|
18
38
|
export type TaskStatus = TaskLifecycleStatus;
|
|
@@ -30,6 +50,8 @@ export interface CreateTaskInput {
|
|
|
30
50
|
templateId?: string;
|
|
31
51
|
parentId?: string;
|
|
32
52
|
dependsOn?: string[];
|
|
53
|
+
projectRoot?: string;
|
|
54
|
+
projectSource?: TaskScopeSource;
|
|
33
55
|
}
|
|
34
56
|
|
|
35
57
|
export type TaskTransition = "start" | "submit" | "reject" | "retry" | "cancel";
|
|
@@ -46,6 +68,13 @@ export interface ChecklistReview {
|
|
|
46
68
|
reason?: string;
|
|
47
69
|
}
|
|
48
70
|
|
|
71
|
+
export interface TaskFocus {
|
|
72
|
+
artifact: Artifact;
|
|
73
|
+
status: TaskFocusStatus;
|
|
74
|
+
updatedAt: string;
|
|
75
|
+
pauseReason?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
export interface TaskCompletionOptions {
|
|
50
79
|
focusSuccessor?: boolean;
|
|
51
80
|
gateDeadlineMs?: number;
|
|
@@ -63,6 +92,7 @@ export interface TaskCompletion {
|
|
|
63
92
|
export interface TaskNode {
|
|
64
93
|
task: Artifact;
|
|
65
94
|
active?: boolean;
|
|
95
|
+
focusStatus?: TaskFocusStatus;
|
|
66
96
|
parentIds: string[];
|
|
67
97
|
childIds: string[];
|
|
68
98
|
dependencyIds: string[];
|
|
@@ -71,6 +101,7 @@ export interface TaskNode {
|
|
|
71
101
|
export interface TaskGraph {
|
|
72
102
|
nodes: TaskNode[];
|
|
73
103
|
rootIds: string[];
|
|
104
|
+
scope?: TaskViewSelection;
|
|
74
105
|
}
|
|
75
106
|
|
|
76
107
|
const TASK_TRANSITIONS: Record<TaskTransition, { from: TaskStatus[]; to: TaskStatus }> = {
|
|
@@ -87,6 +118,7 @@ export class Tasks {
|
|
|
87
118
|
private readonly gates: GateRunner,
|
|
88
119
|
private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
|
|
89
120
|
private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
|
|
121
|
+
private readonly scopes: TaskScopeStore = new InMemoryTaskScopeStore(),
|
|
90
122
|
) {}
|
|
91
123
|
|
|
92
124
|
private require(id: string): Artifact {
|
|
@@ -106,6 +138,10 @@ export class Tasks {
|
|
|
106
138
|
const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
|
|
107
139
|
if (input.gates !== undefined) extra["gates"] = input.gates;
|
|
108
140
|
if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
|
|
141
|
+
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
142
|
+
if (input.parentId && this.scopes.get(input.parentId)?.projectRoot !== projectRoot) {
|
|
143
|
+
throw new Error(`parent task "${input.parentId}" is outside project scope`);
|
|
144
|
+
}
|
|
109
145
|
const task = this.artifacts.create({
|
|
110
146
|
id: input.id,
|
|
111
147
|
kind: "task",
|
|
@@ -117,6 +153,7 @@ export class Tasks {
|
|
|
117
153
|
extra,
|
|
118
154
|
templateId: input.templateId,
|
|
119
155
|
});
|
|
156
|
+
this.scopes.assign(task.id, projectRoot, input.projectSource ?? (projectRoot ? "explicit" : "unscoped"));
|
|
120
157
|
if (input.parentId) this.contain(input.parentId, task.id);
|
|
121
158
|
for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
|
|
122
159
|
this.appendEvent({ taskId: task.id, type: "created", toStatus: task.status as TaskStatus }, context);
|
|
@@ -124,11 +161,85 @@ export class Tasks {
|
|
|
124
161
|
});
|
|
125
162
|
}
|
|
126
163
|
|
|
164
|
+
update(id: string, input: UpdateTaskInput, context: TaskEventContext = {}): Artifact {
|
|
165
|
+
const fields = (["title", "body", "labels"] as const).filter((field) => input[field] !== undefined);
|
|
166
|
+
if (fields.length === 0) throw new Error("task update requires title, body, or labels");
|
|
167
|
+
if (input.title !== undefined && (input.title.trim().length === 0 || input.title.length > TASK_TITLE_MAX_LENGTH)) {
|
|
168
|
+
throw new Error(`title must be between 1 and ${TASK_TITLE_MAX_LENGTH} characters`);
|
|
169
|
+
}
|
|
170
|
+
if (input.body !== undefined && input.body.length > TASK_BODY_MAX_LENGTH) throw new Error(`body cannot exceed ${TASK_BODY_MAX_LENGTH} characters`);
|
|
171
|
+
if (input.labels !== undefined) {
|
|
172
|
+
if (input.labels.length > TASK_LABEL_MAX_COUNT) throw new Error(`labels cannot exceed ${TASK_LABEL_MAX_COUNT} entries`);
|
|
173
|
+
if (input.labels.some((label) => label.length === 0 || label.length > TASK_LABEL_MAX_LENGTH)) {
|
|
174
|
+
throw new Error(`each label must be between 1 and ${TASK_LABEL_MAX_LENGTH} characters`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return this.events.atomic(() => {
|
|
178
|
+
this.require(id);
|
|
179
|
+
const updated = this.artifacts.updateContent(id, input);
|
|
180
|
+
if (!updated) throw new Error(`task "${id}" not found`);
|
|
181
|
+
this.appendEvent({ taskId: id, type: "updated", evidence: { result: `fields:${fields.sort().join(",")}` } }, context);
|
|
182
|
+
return updated;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
127
186
|
list(filter: TaskFilter = {}): Artifact[] {
|
|
128
|
-
|
|
187
|
+
const selection = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
|
|
188
|
+
const limit = filter.limit ?? TASK_SCOPE_MAX_TASKS;
|
|
189
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > TASK_SCOPE_MAX_TASKS + 1) {
|
|
190
|
+
throw new Error(`task list limit must be between 1 and ${TASK_SCOPE_MAX_TASKS + 1}`);
|
|
191
|
+
}
|
|
192
|
+
if (selection.mode === "all") {
|
|
193
|
+
return this.artifacts.query({ kind: "task", status: filter.status, text: filter.text, limit });
|
|
194
|
+
}
|
|
195
|
+
const ids = this.scopes.taskIds(selection.projectRoot, TASK_SCOPE_MAX_TASKS + 1);
|
|
196
|
+
if (ids.length > TASK_SCOPE_MAX_TASKS) throw new Error(`task project scope exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
|
|
197
|
+
const selectedIds = selection.mode === "graph" ? this.descendantIds(selection.rootTaskId!, ids) : new Set(ids);
|
|
198
|
+
const text = filter.text?.toLowerCase();
|
|
199
|
+
return [...selectedIds]
|
|
200
|
+
.map((id) => this.artifacts.get(id))
|
|
201
|
+
.filter((task): task is Artifact => task?.kind === "task")
|
|
202
|
+
.filter((task) => filter.status === undefined || task.status === filter.status)
|
|
203
|
+
.filter((task) => text === undefined || task.title.toLowerCase().includes(text) || task.body.toLowerCase().includes(text))
|
|
204
|
+
.sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
|
|
205
|
+
.slice(0, limit);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
|
|
209
|
+
if (mode !== undefined && mode !== "project" && mode !== "graph" && mode !== "all") throw new Error("task scope must be project, graph, or all");
|
|
210
|
+
if (projectRoot === undefined) return { mode: "all", label: taskScopeLabel("all") };
|
|
211
|
+
const normalized = normalizeProjectRoot(projectRoot);
|
|
212
|
+
const persisted = this.scopes.view(normalized);
|
|
213
|
+
const selectedMode = mode ?? persisted.mode;
|
|
214
|
+
const selectedRoot = rootTaskId ?? (selectedMode === "graph" ? persisted.rootTaskId : undefined);
|
|
215
|
+
if (selectedMode === "graph" && !selectedRoot) throw new Error("graph scope requires root_task_id");
|
|
216
|
+
const root = selectedRoot ? this.require(selectedRoot) : undefined;
|
|
217
|
+
if (root && this.scopes.get(root.id)?.projectRoot !== normalized) throw new Error(`task "${root.id}" is outside project scope`);
|
|
218
|
+
return {
|
|
219
|
+
mode: selectedMode,
|
|
220
|
+
label: taskScopeLabel(selectedMode, normalized, root?.title),
|
|
221
|
+
projectRoot: normalized,
|
|
222
|
+
...(selectedRoot === undefined ? {} : { rootTaskId: selectedRoot }),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewSelection {
|
|
227
|
+
const selection = this.scopeSelection(projectRoot, mode, rootTaskId);
|
|
228
|
+
this.scopes.setView(selection.projectRoot!, selection.mode, selection.rootTaskId);
|
|
229
|
+
return selection;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
assignProject(id: string, projectRoot: string, context: TaskEventContext = {}): Artifact {
|
|
233
|
+
return this.events.atomic(() => {
|
|
234
|
+
const task = this.require(id);
|
|
235
|
+
this.scopes.assign(id, normalizeProjectRoot(projectRoot), "explicit");
|
|
236
|
+
this.appendEvent({ taskId: id, type: "project_assigned", reason: context.reason }, context);
|
|
237
|
+
return task;
|
|
238
|
+
});
|
|
129
239
|
}
|
|
130
240
|
|
|
131
241
|
graph(filter: TaskFilter = {}): TaskGraph {
|
|
242
|
+
const scope = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
|
|
132
243
|
const requestedLimit = filter.limit ?? TASK_EXECUTION_MAX_NODES + 1;
|
|
133
244
|
if (!Number.isInteger(requestedLimit) || requestedLimit < 1 || requestedLimit > TASK_EXECUTION_MAX_NODES + 1) {
|
|
134
245
|
throw new Error(`task graph limit must be between 1 and ${TASK_EXECUTION_MAX_NODES + 1}`);
|
|
@@ -138,10 +249,12 @@ export class Tasks {
|
|
|
138
249
|
throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
|
|
139
250
|
}
|
|
140
251
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
141
|
-
const
|
|
252
|
+
const focus = this.focusStore.get();
|
|
253
|
+
const focusedId = focus?.taskId;
|
|
142
254
|
const nodes = new Map(tasks.map((task) => [task.id, {
|
|
143
255
|
task,
|
|
144
256
|
active: task.id === focusedId,
|
|
257
|
+
...(task.id === focusedId ? { focusStatus: focus!.status } : {}),
|
|
145
258
|
parentIds: [] as string[],
|
|
146
259
|
childIds: [] as string[],
|
|
147
260
|
dependencyIds: [] as string[],
|
|
@@ -172,6 +285,7 @@ export class Tasks {
|
|
|
172
285
|
return {
|
|
173
286
|
nodes: tasks.map((task) => nodes.get(task.id)!),
|
|
174
287
|
rootIds: tasks.filter((task) => nodes.get(task.id)!.parentIds.length === 0).map((task) => task.id),
|
|
288
|
+
scope,
|
|
175
289
|
};
|
|
176
290
|
}
|
|
177
291
|
|
|
@@ -180,24 +294,60 @@ export class Tasks {
|
|
|
180
294
|
return this.artifacts.get(id, { tree: true })!;
|
|
181
295
|
}
|
|
182
296
|
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
if (!
|
|
186
|
-
const task = this.artifacts.get(
|
|
297
|
+
focused(filter?: TaskFilter): TaskFocus | null {
|
|
298
|
+
const focus = this.focusStore.get();
|
|
299
|
+
if (!focus) return null;
|
|
300
|
+
const task = this.artifacts.get(focus.taskId);
|
|
187
301
|
if (!task || task.kind !== "task" || task.status === "done" || task.status === "canceled") {
|
|
188
|
-
this.focusStore.clear(
|
|
302
|
+
this.focusStore.clear(focus.taskId);
|
|
189
303
|
return null;
|
|
190
304
|
}
|
|
191
|
-
return
|
|
305
|
+
if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
|
|
306
|
+
return { artifact: task, status: focus.status, updatedAt: focus.updatedAt, ...(focus.pauseReason ? { pauseReason: focus.pauseReason } : {}) };
|
|
192
307
|
}
|
|
193
308
|
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return
|
|
309
|
+
active(filter?: TaskFilter): Artifact | null {
|
|
310
|
+
const focus = this.focused(filter);
|
|
311
|
+
return focus?.status === "active" ? focus.artifact : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
focus(id: string, context: TaskEventContext = {}): Artifact {
|
|
315
|
+
return this.events.atomic(() => {
|
|
316
|
+
const task = this.require(id);
|
|
317
|
+
if (task.status === "done" || task.status === "canceled") throw new Error(`cannot focus task from ${task.status}`);
|
|
318
|
+
this.focusStore.set(id);
|
|
319
|
+
this.appendEvent({ taskId: id, type: "focus_set" }, context);
|
|
320
|
+
return task;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
pauseFocus(context: TaskEventContext = {}): TaskFocus {
|
|
325
|
+
return this.events.atomic(() => {
|
|
326
|
+
const focus = this.focused();
|
|
327
|
+
if (!focus) throw new Error("no focused task");
|
|
328
|
+
const state = this.focusStore.pause(focus.artifact.id, context.reason);
|
|
329
|
+
this.appendEvent({ taskId: focus.artifact.id, type: "focus_paused" }, context);
|
|
330
|
+
return { artifact: focus.artifact, status: state.status, updatedAt: state.updatedAt, ...(state.pauseReason ? { pauseReason: state.pauseReason } : {}) };
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
unpauseFocus(context: TaskEventContext = {}): TaskFocus {
|
|
335
|
+
return this.events.atomic(() => {
|
|
336
|
+
const focus = this.focused();
|
|
337
|
+
if (!focus) throw new Error("no focused task");
|
|
338
|
+
const state = this.focusStore.unpause(focus.artifact.id);
|
|
339
|
+
this.appendEvent({ taskId: focus.artifact.id, type: "focus_unpaused" }, context);
|
|
340
|
+
return { artifact: focus.artifact, status: state.status, updatedAt: state.updatedAt };
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
clearFocus(context: TaskEventContext = {}): { cleared: boolean } {
|
|
345
|
+
return this.events.atomic(() => {
|
|
346
|
+
const focus = this.focusStore.get();
|
|
347
|
+
if (focus) this.appendEvent({ taskId: focus.taskId, type: "focus_cleared" }, context);
|
|
348
|
+
this.focusStore.clear();
|
|
349
|
+
return { cleared: focus !== undefined };
|
|
350
|
+
});
|
|
201
351
|
}
|
|
202
352
|
|
|
203
353
|
transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
|
|
@@ -256,19 +406,6 @@ export class Tasks {
|
|
|
256
406
|
return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
|
|
257
407
|
}
|
|
258
408
|
|
|
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
|
-
|
|
272
409
|
depend(id: string, dependencyId: string): Artifact {
|
|
273
410
|
this.require(id);
|
|
274
411
|
this.require(dependencyId);
|
|
@@ -295,6 +432,36 @@ export class Tasks {
|
|
|
295
432
|
return this.show(parentId);
|
|
296
433
|
}
|
|
297
434
|
|
|
435
|
+
private descendantIds(rootTaskId: string, projectTaskIds: string[]): Set<string> {
|
|
436
|
+
const allowed = new Set(projectTaskIds);
|
|
437
|
+
if (!allowed.has(rootTaskId)) throw new Error(`task "${rootTaskId}" is outside project scope`);
|
|
438
|
+
const relationships = this.artifacts.relationships({
|
|
439
|
+
kind: "task",
|
|
440
|
+
artifactIds: projectTaskIds,
|
|
441
|
+
limit: TASK_EXECUTION_MAX_EDGES + 1,
|
|
442
|
+
});
|
|
443
|
+
if (relationships.length > TASK_EXECUTION_MAX_EDGES) throw new Error(`task project scope exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
|
|
444
|
+
const children = new Map<string, string[]>();
|
|
445
|
+
for (const edge of relationships) {
|
|
446
|
+
const parentId = edge.relation === "contains" ? edge.from : edge.relation === "part_of" ? edge.to : undefined;
|
|
447
|
+
const childId = edge.relation === "contains" ? edge.to : edge.relation === "part_of" ? edge.from : undefined;
|
|
448
|
+
if (!parentId || !childId || !allowed.has(parentId) || !allowed.has(childId)) continue;
|
|
449
|
+
const values = children.get(parentId) ?? [];
|
|
450
|
+
if (!values.includes(childId)) values.push(childId);
|
|
451
|
+
children.set(parentId, values);
|
|
452
|
+
}
|
|
453
|
+
const selected = new Set<string>();
|
|
454
|
+
const pending = [rootTaskId];
|
|
455
|
+
while (pending.length > 0) {
|
|
456
|
+
const id = pending.shift()!;
|
|
457
|
+
if (selected.has(id)) continue;
|
|
458
|
+
if (selected.size >= TASK_SCOPE_MAX_TASKS) throw new Error(`focused task graph exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
|
|
459
|
+
selected.add(id);
|
|
460
|
+
pending.push(...(children.get(id) ?? []));
|
|
461
|
+
}
|
|
462
|
+
return selected;
|
|
463
|
+
}
|
|
464
|
+
|
|
298
465
|
private relationships(id: string) {
|
|
299
466
|
const relationships = this.artifacts.relationships({
|
|
300
467
|
kind: "task",
|
package/src/task-automation.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
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
|
-
}
|