@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/README.md +30 -4
- package/extension/src/domain-tools.ts +18 -5
- package/extension/src/task-detail-format.ts +24 -1
- package/extension/src/task-detail-view.ts +6 -3
- package/extension/src/tasks.ts +29 -5
- package/package.json +1 -1
- package/src/adapters/sqlite-gate-runner.ts +3 -3
- package/src/adapters/sqlite-task-event-store.ts +92 -0
- package/src/cli.ts +51 -7
- package/src/constants.ts +21 -1
- package/src/daemon.ts +20 -4
- package/src/db.ts +77 -27
- package/src/domain/gate.ts +5 -0
- package/src/domain/task-event.ts +104 -0
- package/src/log.ts +6 -0
- package/src/ops.ts +18 -7
- package/src/ports/gate-runner.ts +2 -2
- package/src/ports/task-event-store.ts +43 -0
- package/src/service.ts +72 -17
- package/src/skill-execution.ts +26 -10
- package/src/task-automation.ts +188 -0
- package/src/task-service.ts +140 -54
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
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
247
|
+
return { from, to: schemaVersion(db), applied };
|
|
198
248
|
}
|
|
199
249
|
|
|
200
250
|
export function openDb(path: string): Db {
|
package/src/domain/gate.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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,
|
package/src/ports/gate-runner.ts
CHANGED
|
@@ -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
|
-
"
|
|
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) =>
|
|
176
|
-
|
|
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
|
|
260
|
-
const
|
|
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-
|
|
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
|
},
|