@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/src/daemon.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, WAL_CHECKPOINT_INTERVAL_MS, dbPath } from "./constants.ts";
2
2
  import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
3
3
  import { createApp, createPapyrusService } from "./service.ts";
4
+ import { scheduleTaskAutomation, taskAutomationSettings, type TaskAutomationResult } from "./task-automation.ts";
5
+ import { logEvent } from "./log.ts";
4
6
 
5
7
  /** Start the supervised, long-running Papyrus service. */
6
8
  export function serveMain(): void {
7
9
  const stateDir = daemonStateDir();
8
10
  const token = loadOrCreateToken(stateDir);
9
- const service = createPapyrusService(dbPath());
11
+ const automation = taskAutomationSettings(process.env);
12
+ const service = createPapyrusService(dbPath(), { automation });
10
13
  const app = createApp({ service, token });
11
14
  const server = Bun.serve({
12
15
  hostname: DAEMON_HOST,
@@ -19,11 +22,23 @@ export function serveMain(): void {
19
22
  }
20
23
  writeDaemonPort(stateDir, server.port);
21
24
  const checkpointTimer = setInterval(() => {
22
- try { service.checkpoint(); } catch (error) { console.error("[papyrus] checkpoint failed", error); }
25
+ try { service.checkpoint(); } catch (error) { logEvent("error", "checkpoint_failed", { message: error instanceof Error ? error.message : String(error) }); }
23
26
  }, WAL_CHECKPOINT_INTERVAL_MS);
24
27
  const optimizeTimer = setInterval(() => {
25
- try { service.optimize(); } catch (error) { console.error("[papyrus] optimize failed", error); }
28
+ try { service.optimize(); } catch (error) { logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) }); }
26
29
  }, DB_OPTIMIZE_INTERVAL_MS);
30
+ const stopAutomation = scheduleTaskAutomation(automation, async () => {
31
+ const result = await service.execute("automation.reconcile", {}) as TaskAutomationResult;
32
+ logEvent(result.errors.length > 0 ? "warn" : "info", "automation_sweep", {
33
+ examined: result.examined,
34
+ completed: result.completed,
35
+ rejected: result.rejected,
36
+ started: result.started,
37
+ errors: result.errors.length,
38
+ timedOut: result.timedOut,
39
+ skipped: result.skipped,
40
+ });
41
+ }, (error) => logEvent("error", "automation_sweep_failed", { message: error instanceof Error ? error.message : String(error) }));
27
42
 
28
43
  let stopping = false;
29
44
  const shutdown = () => {
@@ -31,11 +46,12 @@ export function serveMain(): void {
31
46
  stopping = true;
32
47
  clearInterval(checkpointTimer);
33
48
  clearInterval(optimizeTimer);
49
+ stopAutomation();
34
50
  clearDaemonPort(stateDir);
35
51
  service.close();
36
52
  void server.stop(true).finally(() => process.exit(0));
37
53
  };
38
54
  process.on("SIGINT", shutdown);
39
55
  process.on("SIGTERM", shutdown);
40
- console.error(`[papyrus] listening on ${DAEMON_HOST}:${server.port}`);
56
+ logEvent("info", "listening", { host: DAEMON_HOST, port: server.port, automationEnabled: automation.enabled });
41
57
  }
package/src/db.ts CHANGED
@@ -102,6 +102,26 @@ CREATE TABLE IF NOT EXISTS task_focus (
102
102
  task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
103
103
  updated_at TEXT NOT NULL
104
104
  );
105
+ CREATE TABLE IF NOT EXISTS task_events (
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ task_id TEXT NOT NULL REFERENCES artifacts(id),
108
+ occurred_at TEXT NOT NULL,
109
+ event_type TEXT NOT NULL,
110
+ actor TEXT NOT NULL,
111
+ source TEXT NOT NULL,
112
+ session_id TEXT,
113
+ reason TEXT,
114
+ from_status TEXT,
115
+ to_status TEXT,
116
+ attempt_id TEXT,
117
+ evidence_json TEXT,
118
+ event_schema_version INTEGER NOT NULL DEFAULT 1
119
+ );
120
+ CREATE INDEX IF NOT EXISTS task_events_history_idx ON task_events(task_id, occurred_at, id);
121
+ CREATE TRIGGER IF NOT EXISTS task_events_no_update BEFORE UPDATE ON task_events
122
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
123
+ CREATE TRIGGER IF NOT EXISTS task_events_no_delete BEFORE DELETE ON task_events
124
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
105
125
  `;
106
126
 
107
127
  const SEED_SQL = `
@@ -165,36 +185,66 @@ export function migrateDb(db: Db): MigrationResult {
165
185
  throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
166
186
  }
167
187
  if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
168
- if (from !== 1) throw new Error(`no explicit migration path from database schema ${from}`);
188
+ if (from !== 1 && from !== 2) throw new Error(`no explicit migration path from database schema ${from}`);
189
+ const applied: string[] = [];
169
190
 
170
191
  inTransaction(db, () => {
171
- db.exec(`
172
- INSERT OR IGNORE INTO statuses VALUES ('todo','task');
173
- INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
174
- INSERT OR IGNORE INTO statuses VALUES ('review','task');
175
- INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
176
- INSERT OR IGNORE INTO statuses VALUES ('done','task');
177
- INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
178
- CREATE TABLE task_focus (
179
- scope TEXT PRIMARY KEY CHECK (scope = 'global'),
180
- task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
181
- updated_at TEXT NOT NULL
182
- );
183
- INSERT INTO task_focus (scope, task_id, updated_at)
184
- SELECT 'global', id, strftime('%Y-%m-%dT%H:%M:%fZ','now')
185
- FROM artifacts WHERE kind = 'task' AND status = 'active'
186
- ORDER BY updated_at DESC, id ASC LIMIT 1;
187
- UPDATE artifacts SET status = CASE status
188
- WHEN 'pending' THEN 'todo'
189
- WHEN 'active' THEN 'in-progress'
190
- WHEN 'failed' THEN 'rejected'
191
- ELSE status END
192
- WHERE kind = 'task';
193
- DELETE FROM statuses WHERE kind = 'task' AND name IN ('pending', 'active', 'failed');
194
- PRAGMA user_version = 2;
195
- `);
192
+ if (schemaVersion(db) === 1) {
193
+ db.exec(`
194
+ INSERT OR IGNORE INTO statuses VALUES ('todo','task');
195
+ INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
196
+ INSERT OR IGNORE INTO statuses VALUES ('review','task');
197
+ INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
198
+ INSERT OR IGNORE INTO statuses VALUES ('done','task');
199
+ INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
200
+ CREATE TABLE task_focus (
201
+ scope TEXT PRIMARY KEY CHECK (scope = 'global'),
202
+ task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
203
+ updated_at TEXT NOT NULL
204
+ );
205
+ INSERT INTO task_focus (scope, task_id, updated_at)
206
+ SELECT 'global', id, strftime('%Y-%m-%dT%H:%M:%fZ','now')
207
+ FROM artifacts WHERE kind = 'task' AND status = 'active'
208
+ ORDER BY updated_at DESC, id ASC LIMIT 1;
209
+ UPDATE artifacts SET status = CASE status
210
+ WHEN 'pending' THEN 'todo'
211
+ WHEN 'active' THEN 'in-progress'
212
+ WHEN 'failed' THEN 'rejected'
213
+ ELSE status END
214
+ WHERE kind = 'task';
215
+ DELETE FROM statuses WHERE kind = 'task' AND name IN ('pending', 'active', 'failed');
216
+ PRAGMA user_version = 2;
217
+ `);
218
+ applied.push("task-lifecycle-and-focus");
219
+ }
220
+ if (schemaVersion(db) === 2) {
221
+ db.exec(`
222
+ CREATE TABLE task_events (
223
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
224
+ task_id TEXT NOT NULL REFERENCES artifacts(id),
225
+ occurred_at TEXT NOT NULL,
226
+ event_type TEXT NOT NULL,
227
+ actor TEXT NOT NULL,
228
+ source TEXT NOT NULL,
229
+ session_id TEXT,
230
+ reason TEXT,
231
+ from_status TEXT,
232
+ to_status TEXT,
233
+ attempt_id TEXT,
234
+ evidence_json TEXT,
235
+ event_schema_version INTEGER NOT NULL DEFAULT 1
236
+ );
237
+ CREATE INDEX task_events_history_idx ON task_events(task_id, occurred_at, id);
238
+ CREATE TRIGGER task_events_no_update BEFORE UPDATE ON task_events
239
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
240
+ CREATE TRIGGER task_events_no_delete BEFORE DELETE ON task_events
241
+ BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
242
+ PRAGMA user_version = 3;
243
+ `);
244
+ applied.push("task-history");
245
+ }
196
246
  });
197
- return { from, to: SQLITE_SCHEMA_VERSION, applied: ["task-lifecycle-and-focus"] };
247
+ return { from, to: schemaVersion(db), applied };
198
248
  }
199
249
 
200
250
  export function openDb(path: string): Db {
@@ -4,6 +4,11 @@ export interface Gate {
4
4
  expect?: string;
5
5
  }
6
6
 
7
+ export interface GateRunOptions {
8
+ /** Absolute Unix epoch deadline for the full gate sequence. */
9
+ deadlineMs?: number;
10
+ }
11
+
7
12
  export interface GateResult {
8
13
  gate: Gate;
9
14
  passed: boolean;
@@ -0,0 +1,104 @@
1
+ import {
2
+ TASK_EVENT_ACTOR_MAX_LENGTH,
3
+ TASK_EVENT_MAX_EVIDENCE_BYTES,
4
+ TASK_EVENT_REASON_MAX_LENGTH,
5
+ TASK_HISTORY_DEFAULT_LIMIT,
6
+ TASK_HISTORY_MAX_LIMIT,
7
+ } from "../constants.ts";
8
+ export type TaskLifecycleStatus = "todo" | "in-progress" | "review" | "rejected" | "done" | "canceled";
9
+
10
+ export const TASK_EVENT_TYPES = [
11
+ "created",
12
+ "started",
13
+ "submitted",
14
+ "completion_attempted",
15
+ "gates_evaluated",
16
+ "automation_enabled",
17
+ "automation_disabled",
18
+ "review_rejected",
19
+ "retried",
20
+ "completed",
21
+ "canceled",
22
+ ] as const;
23
+
24
+ export type TaskEventType = typeof TASK_EVENT_TYPES[number];
25
+ export type TaskEventDirection = "asc" | "desc";
26
+
27
+ export interface TaskEventContext {
28
+ actor?: string;
29
+ source?: string;
30
+ sessionId?: string;
31
+ reason?: string;
32
+ }
33
+
34
+ export interface TaskEventEvidence {
35
+ gates?: unknown;
36
+ checklist?: unknown;
37
+ result?: string;
38
+ }
39
+
40
+ export interface TaskEvent {
41
+ id: number;
42
+ taskId: string;
43
+ occurredAt: string;
44
+ type: TaskEventType;
45
+ actor: string;
46
+ source: string;
47
+ sessionId?: string;
48
+ reason?: string;
49
+ fromStatus?: TaskLifecycleStatus;
50
+ toStatus?: TaskLifecycleStatus;
51
+ attemptId?: string;
52
+ evidence?: TaskEventEvidence;
53
+ schemaVersion: 1;
54
+ }
55
+
56
+ export interface AppendTaskEvent {
57
+ taskId: string;
58
+ type: TaskEventType;
59
+ actor: string;
60
+ source: string;
61
+ sessionId?: string;
62
+ reason?: string;
63
+ fromStatus?: TaskLifecycleStatus;
64
+ toStatus?: TaskLifecycleStatus;
65
+ attemptId?: string;
66
+ evidence?: TaskEventEvidence;
67
+ }
68
+
69
+ export interface TaskHistoryQuery {
70
+ limit?: number;
71
+ cursor?: number;
72
+ direction?: TaskEventDirection;
73
+ }
74
+
75
+ export interface TaskHistoryPage {
76
+ events: TaskEvent[];
77
+ nextCursor?: number;
78
+ }
79
+
80
+ export function normalizeTaskHistoryQuery(query: TaskHistoryQuery = {}): Required<Pick<TaskHistoryQuery, "limit" | "direction">> & Pick<TaskHistoryQuery, "cursor"> {
81
+ const limit = query.limit ?? TASK_HISTORY_DEFAULT_LIMIT;
82
+ if (!Number.isInteger(limit) || limit < 1 || limit > TASK_HISTORY_MAX_LIMIT) {
83
+ throw new Error(`task history limit must be between 1 and ${TASK_HISTORY_MAX_LIMIT}`);
84
+ }
85
+ if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 1)) {
86
+ throw new Error("task history cursor must be a positive integer");
87
+ }
88
+ if (query.direction !== undefined && query.direction !== "asc" && query.direction !== "desc") {
89
+ throw new Error("task history direction must be asc or desc");
90
+ }
91
+ return { limit, direction: query.direction ?? "desc", ...(query.cursor === undefined ? {} : { cursor: query.cursor }) };
92
+ }
93
+
94
+ export function validateTaskEvent(event: AppendTaskEvent): AppendTaskEvent {
95
+ for (const [field, value] of [["actor", event.actor], ["source", event.source]] as const) {
96
+ if (!value || value.length > TASK_EVENT_ACTOR_MAX_LENGTH) throw new Error(`${field} must be between 1 and ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
97
+ }
98
+ if (event.sessionId !== undefined && event.sessionId.length > TASK_EVENT_ACTOR_MAX_LENGTH) throw new Error(`sessionId cannot exceed ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
99
+ if (event.reason !== undefined && event.reason.length > TASK_EVENT_REASON_MAX_LENGTH) throw new Error(`reason cannot exceed ${TASK_EVENT_REASON_MAX_LENGTH} characters`);
100
+ if (event.evidence !== undefined && new TextEncoder().encode(JSON.stringify(event.evidence)).byteLength > TASK_EVENT_MAX_EVIDENCE_BYTES) {
101
+ throw new Error(`task event evidence cannot exceed ${TASK_EVENT_MAX_EVIDENCE_BYTES} bytes`);
102
+ }
103
+ return event;
104
+ }
package/src/log.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type LogLevel = "info" | "warn" | "error";
2
+
3
+ /** Credential-safe structured daemon event. Callers must pass bounded, non-sensitive fields. */
4
+ export function logEvent(level: LogLevel, event: string, fields: Record<string, unknown> = {}): void {
5
+ console.error(JSON.stringify({ timestamp: new Date().toISOString(), level, component: "papyrus-daemon", event, ...fields }));
6
+ }
package/src/ops.ts CHANGED
@@ -7,7 +7,7 @@ import { exec } from "node:child_process";
7
7
  import type { Db } from "./db.ts";
8
8
  import { inTransaction } from "./db.ts";
9
9
  import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
10
- import type { Gate, GateResult } from "./domain/gate.ts";
10
+ import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
11
11
  export type { Artifact } from "./domain/artifact.ts";
12
12
  export type { Gate, GateResult } from "./domain/gate.ts";
13
13
  export type CreateInput = CreateArtifactInput;
@@ -20,6 +20,7 @@ import {
20
20
  GATE_TEST_TIMEOUT_MS,
21
21
  GATE_OUTPUT_LIMIT,
22
22
  GATE_MAX_BUFFER_BYTES,
23
+ GATE_FILE_MAX_BYTES,
23
24
  } from "./constants.ts";
24
25
 
25
26
  const require_ = createRequire(import.meta.url);
@@ -234,6 +235,12 @@ export function injectableRules(db: Db): Array<{ id: string; title: string; body
234
235
  });
235
236
  }
236
237
 
238
+ function readBoundedGateFile(path: string): string {
239
+ const { readFileSync, statSync } = require_("node:fs");
240
+ if (statSync(path).size > GATE_FILE_MAX_BYTES) throw new Error(`file exceeds ${GATE_FILE_MAX_BYTES} bytes`);
241
+ return readFileSync(path, "utf-8") as string;
242
+ }
243
+
237
244
  export function runGates(db: Db, artifactId: string): GateResult[] {
238
245
  const art = getArtifact(db, artifactId);
239
246
  if (!art) throw new Error("artifact not found");
@@ -246,9 +253,8 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
246
253
  return { gate, passed: exists, output: exists ? "exists" : "not found" };
247
254
  }
248
255
  case "contains": {
249
- const { readFileSync } = require_("node:fs");
250
256
  try {
251
- const content = readFileSync(gate.target, "utf-8");
257
+ const content = readBoundedGateFile(gate.target);
252
258
  const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
253
259
  return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
254
260
  } catch {
@@ -299,9 +305,8 @@ function runNonProcessGate(gate: Gate): GateResult {
299
305
  return { gate, passed: exists, output: exists ? "exists" : "not found" };
300
306
  }
301
307
  if (gate.type === "contains") {
302
- const { readFileSync } = require_("node:fs");
303
308
  try {
304
- const content = readFileSync(gate.target, "utf-8");
309
+ const content = readBoundedGateFile(gate.target);
305
310
  const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
306
311
  return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
307
312
  } catch {
@@ -312,15 +317,21 @@ function runNonProcessGate(gate: Gate): GateResult {
312
317
  }
313
318
 
314
319
  /** Gate runner for daemon request paths; subprocess gates never block the event loop. */
315
- export async function runGatesAsync(db: Db, artifactId: string): Promise<GateResult[]> {
320
+ export async function runGatesAsync(db: Db, artifactId: string, options: GateRunOptions = {}): Promise<GateResult[]> {
316
321
  const art = getArtifact(db, artifactId);
317
322
  if (!art) throw new Error("artifact not found");
318
323
  const gates = (art.extra["gates"] as Gate[]) ?? [];
319
324
  const results: GateResult[] = [];
320
325
  for (const gate of gates) {
326
+ const remainingMs = options.deadlineMs === undefined ? undefined : options.deadlineMs - Date.now();
327
+ if (remainingMs !== undefined && remainingMs <= 0) {
328
+ results.push({ gate, passed: false, output: "gate runtime deadline exceeded" });
329
+ continue;
330
+ }
321
331
  if (gate.type === "command" || gate.type === "test") {
322
332
  const command = gate.type === "test" ? `npx vitest run ${gate.target} --reporter=dot` : gate.target;
323
- const timeout = gate.type === "test" ? GATE_TEST_TIMEOUT_MS : GATE_COMMAND_TIMEOUT_MS;
333
+ const configuredTimeout = gate.type === "test" ? GATE_TEST_TIMEOUT_MS : GATE_COMMAND_TIMEOUT_MS;
334
+ const timeout = remainingMs === undefined ? configuredTimeout : Math.max(1, Math.min(configuredTimeout, remainingMs));
324
335
  const executed = await executeGateCommand(command, timeout);
325
336
  results.push({
326
337
  gate,
@@ -1,6 +1,6 @@
1
- import type { GateResult } from "../domain/gate.ts";
1
+ import type { GateResult, GateRunOptions } from "../domain/gate.ts";
2
2
 
3
3
  export interface GateRunner {
4
4
  run(artifactId: string): GateResult[];
5
- runAsync(artifactId: string): Promise<GateResult[]>;
5
+ runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]>;
6
6
  }
@@ -0,0 +1,43 @@
1
+ import { normalizeTaskHistoryQuery, validateTaskEvent, type AppendTaskEvent, type TaskEvent, type TaskHistoryPage, type TaskHistoryQuery } from "../domain/task-event.ts";
2
+
3
+ export interface TaskEventStore {
4
+ atomic<T>(operation: () => T): T;
5
+ append(event: AppendTaskEvent): TaskEvent;
6
+ history(taskId: string, query?: TaskHistoryQuery): TaskHistoryPage;
7
+ }
8
+
9
+ export class InMemoryTaskEventStore implements TaskEventStore {
10
+ private events: TaskEvent[] = [];
11
+ private nextId = 1;
12
+
13
+ atomic<T>(operation: () => T): T {
14
+ const length = this.events.length;
15
+ const nextId = this.nextId;
16
+ try { return operation(); }
17
+ catch (error) {
18
+ this.events.length = length;
19
+ this.nextId = nextId;
20
+ throw error;
21
+ }
22
+ }
23
+
24
+ append(event: AppendTaskEvent): TaskEvent {
25
+ const stored: TaskEvent = {
26
+ ...validateTaskEvent(event),
27
+ id: this.nextId++,
28
+ occurredAt: new Date().toISOString(),
29
+ schemaVersion: 1,
30
+ };
31
+ this.events.push(stored);
32
+ return stored;
33
+ }
34
+
35
+ history(taskId: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
36
+ const { direction, limit, cursor } = normalizeTaskHistoryQuery(query);
37
+ const ordered = this.events
38
+ .filter((event) => event.taskId === taskId && (cursor === undefined || (direction === "desc" ? event.id < cursor : event.id > cursor)))
39
+ .sort((left, right) => direction === "desc" ? right.id - left.id : left.id - right.id);
40
+ const events = ordered.slice(0, limit);
41
+ return { events, ...(ordered.length > limit ? { nextCursor: events.at(-1)!.id } : {}) };
42
+ }
43
+ }
package/src/service.ts CHANGED
@@ -4,12 +4,16 @@ import { migrateDb, openDb, schemaVersion } from "./db.ts";
4
4
  import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
5
5
  import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
6
6
  import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
7
+ import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
7
8
  import type { CreateArtifactInput } from "./domain/artifact.ts";
8
9
  import type { Checklist } from "./domain/checklist.ts";
10
+ import type { TaskEventContext, TaskEventDirection } from "./domain/task-event.ts";
9
11
  import type { ArtifactStore } from "./ports/artifact-store.ts";
10
12
  import type { GateRunner } from "./ports/gate-runner.ts";
13
+ import type { TaskEventStore } from "./ports/task-event-store.ts";
11
14
  import { projectTaskExecution } from "./task-execution.ts";
12
15
  import { Tasks, type TaskStatus } from "./task-service.ts";
16
+ import { TaskAutomationReconciler, taskAutomationSettings, type TaskAutomationSettings } from "./task-automation.ts";
13
17
  import {
14
18
  createArtifactTemplate,
15
19
  createDocument,
@@ -37,6 +41,8 @@ import { instantiateSkillWorkflow } from "./skill-execution.ts";
37
41
 
38
42
  export const EXPECTED_OPERATION_NAMES = [
39
43
  "system.migrate",
44
+ "automation.status",
45
+ "automation.reconcile",
40
46
  "artifact.create",
41
47
  "artifact.query",
42
48
  "artifact.show",
@@ -50,6 +56,7 @@ export const EXPECTED_OPERATION_NAMES = [
50
56
  "tasks.graph",
51
57
  "tasks.plan",
52
58
  "tasks.show",
59
+ "tasks.history",
53
60
  "tasks.active",
54
61
  "tasks.focus",
55
62
  "tasks.start",
@@ -57,6 +64,7 @@ export const EXPECTED_OPERATION_NAMES = [
57
64
  "tasks.complete",
58
65
  "tasks.run_gates",
59
66
  "tasks.set_checklist",
67
+ "tasks.set_automation",
60
68
  "tasks.context",
61
69
  "tasks.reject",
62
70
  "tasks.retry",
@@ -140,8 +148,20 @@ function handlers(
140
148
  artifacts: ArtifactStore,
141
149
  gates: GateRunner,
142
150
  tasks: Tasks,
151
+ automation: TaskAutomationReconciler,
152
+ events: TaskEventStore,
143
153
  migrate: () => unknown,
144
154
  ): Record<OperationName, OperationHandler> {
155
+ const eventContext = (input: OperationInput): TaskEventContext => ({
156
+ actor: optionalString(input, "actor"),
157
+ source: optionalString(input, "source"),
158
+ sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
159
+ reason: optionalString(input, "reason"),
160
+ });
161
+ const eventContextFor = (input: OperationInput, source: string): TaskEventContext => {
162
+ const context = eventContext(input);
163
+ return { ...context, source: context.source ?? source };
164
+ };
145
165
  const taskFilter = (input: OperationInput) => ({
146
166
  status: optionalString(input, "status"),
147
167
  text: optionalString(input, "text"),
@@ -149,7 +169,22 @@ function handlers(
149
169
  });
150
170
  return {
151
171
  "system.migrate": () => migrate(),
152
- "artifact.create": (input) => artifacts.create(normalizeCreateInput(input)),
172
+ "automation.status": () => automation.status(),
173
+ "automation.reconcile": () => automation.reconcile(),
174
+ "artifact.create": (input) => {
175
+ const normalized = normalizeCreateInput(input);
176
+ if (normalized.kind !== "task") return artifacts.create(normalized);
177
+ return tasks.create({
178
+ id: normalized.id,
179
+ title: string(input, "title"),
180
+ body: normalized.body,
181
+ subtype: normalized.subtype,
182
+ status: normalized.status as TaskStatus | undefined,
183
+ labels: normalized.labels,
184
+ extra: normalized.extra,
185
+ templateId: normalized.templateId,
186
+ }, eventContextFor(input, "artifact-api"));
187
+ },
153
188
  "artifact.query": (input) => artifacts.query(input),
154
189
  "artifact.show": (input) => artifacts.get(string(input, "id"), {
155
190
  tree: input["tree"] === true,
@@ -172,8 +207,17 @@ function handlers(
172
207
  depth: optionalNumber(input, "depth"),
173
208
  maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
174
209
  }),
175
- "graph.status": (input) => artifacts.setStatus(string(input, "id"), string(input, "status")),
176
- "gates.run": (input) => gates.runAsync(string(input, "id")),
210
+ "graph.status": (input) => {
211
+ const id = string(input, "id");
212
+ if (artifacts.get(id)?.kind === "task") throw new Error("task lifecycle changes require a tasks.* operation so history and review invariants are preserved");
213
+ return artifacts.setStatus(id, string(input, "status"));
214
+ },
215
+ "gates.run": (input) => {
216
+ const id = string(input, "id");
217
+ return artifacts.get(id)?.kind === "task"
218
+ ? tasks.runGates(id, eventContextFor(input, "gates-api"))
219
+ : gates.runAsync(id);
220
+ },
177
221
  "rules.injectable": () => listInjectableRules(artifacts, tasks.active()?.id)
178
222
  .map(({ id, title, body, extra }) => ({ id, title, body, extra })),
179
223
  "tasks.create": (input) => tasks.create({
@@ -187,22 +231,31 @@ function handlers(
187
231
  templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
188
232
  parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"),
189
233
  dependsOn: (input["depends_on"] ?? input["dependsOn"]) as string[] | undefined,
190
- }),
234
+ }, eventContext(input)),
191
235
  "tasks.list": (input) => tasks.list(taskFilter(input)),
192
236
  "tasks.graph": (input) => tasks.graph(taskFilter(input)),
193
237
  "tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
194
238
  "tasks.show": (input) => tasks.show(string(input, "id")),
239
+ "tasks.history": (input) => tasks.history(string(input, "id"), {
240
+ limit: optionalNumber(input, "limit"),
241
+ cursor: optionalNumber(input, "cursor"),
242
+ direction: optionalString(input, "direction") as TaskEventDirection | undefined,
243
+ }),
195
244
  "tasks.active": () => tasks.active(),
196
245
  "tasks.focus": (input) => tasks.focus(string(input, "id")),
197
- "tasks.start": (input) => tasks.transition(string(input, "id"), "start"),
198
- "tasks.submit": (input) => tasks.transition(string(input, "id"), "submit"),
199
- "tasks.complete": (input) => tasks.completeAsync(string(input, "id")),
200
- "tasks.run_gates": (input) => tasks.runGates(string(input, "id")),
246
+ "tasks.start": (input) => tasks.transition(string(input, "id"), "start", eventContext(input)),
247
+ "tasks.submit": (input) => tasks.transition(string(input, "id"), "submit", eventContext(input)),
248
+ "tasks.complete": (input) => tasks.completeAsync(string(input, "id"), eventContext(input)),
249
+ "tasks.run_gates": (input) => tasks.runGates(string(input, "id"), eventContext(input)),
201
250
  "tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
251
+ "tasks.set_automation": (input) => {
252
+ if (typeof input["enabled"] !== "boolean") throw new Error("enabled must be a boolean");
253
+ return tasks.setAutomation(string(input, "id"), input["enabled"], eventContext(input));
254
+ },
202
255
  "tasks.context": () => taskContext(artifacts, tasks.active()?.id),
203
- "tasks.reject": (input) => tasks.transition(string(input, "id"), "reject"),
204
- "tasks.retry": (input) => tasks.transition(string(input, "id"), "retry"),
205
- "tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel"),
256
+ "tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
257
+ "tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
258
+ "tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel", eventContext(input)),
206
259
  "tasks.depend": (input) => tasks.depend(string(input, "id"), string(input, "dependency_id")),
207
260
  "tasks.contain": (input) => tasks.contain(string(input, "parent_id"), string(input, "child_id")),
208
261
  "docs.create": (input) => createDocument(artifacts, {
@@ -244,20 +297,22 @@ function handlers(
244
297
  "skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
245
298
  runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
246
299
  arguments: input["arguments"] as Record<string, unknown> | undefined,
247
- }),
300
+ }, { events, context: eventContextFor(input, "skill-run") }),
248
301
  "skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
249
302
  "skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
250
303
  "skills.instantiate": (input) => instantiateTemplate(artifacts, string(input, "template_id"), normalizeCreateInput(input)),
251
304
  };
252
305
  }
253
306
 
254
- export function createPapyrusService(path: string): PapyrusService {
307
+ export function createPapyrusService(path: string, options: { automation?: TaskAutomationSettings } = {}): PapyrusService {
255
308
  const db = openDb(path);
256
309
  const artifacts = new SQLiteArtifactStore(db);
257
310
  const gates = new SQLiteGateRunner(db);
258
311
  const focus = new SQLiteTaskFocusStore(db);
259
- const tasks = new Tasks(artifacts, gates, focus);
260
- const registry = handlers(artifacts, gates, tasks, () => migrateDb(db));
312
+ const events = new SQLiteTaskEventStore(db);
313
+ const tasks = new Tasks(artifacts, gates, focus, events);
314
+ const automation = new TaskAutomationReconciler(tasks, options.automation ?? taskAutomationSettings({}));
315
+ const registry = handlers(artifacts, gates, tasks, automation, events, () => migrateDb(db));
261
316
  const state = (): SchemaState => {
262
317
  const current = schemaVersion(db);
263
318
  return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
@@ -268,8 +323,8 @@ export function createPapyrusService(path: string): PapyrusService {
268
323
  async execute(operation, input = {}) {
269
324
  const handler = registry[operation as OperationName];
270
325
  if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
271
- if (operation !== "system.migrate" && state().migrationRequired) {
272
- throw new MigrationRequiredError("database migration required; run `papyrus migrate task-lifecycle`");
326
+ if (operation !== "system.migrate" && operation !== "automation.status" && state().migrationRequired) {
327
+ throw new MigrationRequiredError("database migration required; run `papyrus migrate task-history`");
273
328
  }
274
329
  return handler(input);
275
330
  },