@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.
@@ -1,18 +1,38 @@
1
- import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
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
- return this.artifacts.query({ kind: "task", ...filter });
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 focusedId = this.focusStore.get();
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
- active(): Artifact | null {
184
- const id = this.focusStore.get();
185
- if (!id) return null;
186
- const task = this.artifacts.get(id);
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(id);
302
+ this.focusStore.clear(focus.taskId);
189
303
  return null;
190
304
  }
191
- return task;
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
- focus(id: string): Artifact {
195
- const task = this.require(id);
196
- if (task.status === "done" || task.status === "canceled") {
197
- throw new Error(`cannot focus task from ${task.status}`);
198
- }
199
- this.focusStore.set(id);
200
- return task;
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",
@@ -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
- }