@danypops/papyrus 0.7.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,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
- }