@danypops/papyrus 0.7.0 → 0.9.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,4 +1,13 @@
1
- import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES, TASK_SCOPE_MAX_TASKS } 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";
@@ -6,11 +15,17 @@ import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQue
6
15
  import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
7
16
  import type { ArtifactStore } from "./ports/artifact-store.ts";
8
17
  import type { GateRunner } from "./ports/gate-runner.ts";
9
- import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
18
+ import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "./ports/task-focus-store.ts";
10
19
  import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
11
20
  import { InMemoryTaskScopeStore, type TaskScopeStore } from "./ports/task-scope-store.ts";
12
21
  import { assertDependencyEdgeAllowed } from "./task-execution.ts";
13
22
 
23
+ export interface UpdateTaskInput {
24
+ title?: string;
25
+ body?: string;
26
+ labels?: string[];
27
+ }
28
+
14
29
  export interface TaskFilter {
15
30
  status?: string;
16
31
  text?: string;
@@ -53,6 +68,13 @@ export interface ChecklistReview {
53
68
  reason?: string;
54
69
  }
55
70
 
71
+ export interface TaskFocus {
72
+ artifact: Artifact;
73
+ status: TaskFocusStatus;
74
+ updatedAt: string;
75
+ pauseReason?: string;
76
+ }
77
+
56
78
  export interface TaskCompletionOptions {
57
79
  focusSuccessor?: boolean;
58
80
  gateDeadlineMs?: number;
@@ -70,6 +92,7 @@ export interface TaskCompletion {
70
92
  export interface TaskNode {
71
93
  task: Artifact;
72
94
  active?: boolean;
95
+ focusStatus?: TaskFocusStatus;
73
96
  parentIds: string[];
74
97
  childIds: string[];
75
98
  dependencyIds: string[];
@@ -138,6 +161,28 @@ export class Tasks {
138
161
  });
139
162
  }
140
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
+
141
186
  list(filter: TaskFilter = {}): Artifact[] {
142
187
  const selection = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
143
188
  const limit = filter.limit ?? TASK_SCOPE_MAX_TASKS;
@@ -204,10 +249,12 @@ export class Tasks {
204
249
  throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
205
250
  }
206
251
  const byId = new Map(tasks.map((task) => [task.id, task]));
207
- const focusedId = this.focusStore.get();
252
+ const focus = this.focusStore.get();
253
+ const focusedId = focus?.taskId;
208
254
  const nodes = new Map(tasks.map((task) => [task.id, {
209
255
  task,
210
256
  active: task.id === focusedId,
257
+ ...(task.id === focusedId ? { focusStatus: focus!.status } : {}),
211
258
  parentIds: [] as string[],
212
259
  childIds: [] as string[],
213
260
  dependencyIds: [] as string[],
@@ -247,25 +294,60 @@ export class Tasks {
247
294
  return this.artifacts.get(id, { tree: true })!;
248
295
  }
249
296
 
250
- active(filter?: TaskFilter): Artifact | null {
251
- const id = this.focusStore.get();
252
- if (!id) return null;
253
- 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);
254
301
  if (!task || task.kind !== "task" || task.status === "done" || task.status === "canceled") {
255
- this.focusStore.clear(id);
302
+ this.focusStore.clear(focus.taskId);
256
303
  return null;
257
304
  }
258
305
  if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
259
- return task;
306
+ return { artifact: task, status: focus.status, updatedAt: focus.updatedAt, ...(focus.pauseReason ? { pauseReason: focus.pauseReason } : {}) };
260
307
  }
261
308
 
262
- focus(id: string): Artifact {
263
- const task = this.require(id);
264
- if (task.status === "done" || task.status === "canceled") {
265
- throw new Error(`cannot focus task from ${task.status}`);
266
- }
267
- this.focusStore.set(id);
268
- 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
+ });
269
351
  }
270
352
 
271
353
  transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
@@ -324,19 +406,6 @@ export class Tasks {
324
406
  return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
325
407
  }
326
408
 
327
- setAutomation(id: string, enabled: boolean, context: TaskEventContext = {}): Artifact {
328
- return this.events.atomic(() => {
329
- const task = this.require(id);
330
- const current = task.extra["automation"];
331
- const automation = typeof current === "object" && current !== null && !Array.isArray(current)
332
- ? current as Record<string, unknown>
333
- : {};
334
- const updated = this.artifacts.setExtra(id, { ...task.extra, automation: { ...automation, enabled } })!;
335
- this.appendEvent({ taskId: id, type: enabled ? "automation_enabled" : "automation_disabled" }, context);
336
- return updated;
337
- });
338
- }
339
-
340
409
  depend(id: string, dependencyId: string): Artifact {
341
410
  this.require(id);
342
411
  this.require(dependencyId);
@@ -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
- }