@danypops/papyrus 0.3.0 → 0.5.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 +19 -7
- package/extension/src/beautiful-mermaid-renderer.ts +23 -0
- package/extension/src/domain-tools.ts +39 -8
- package/extension/src/index.ts +17 -26
- package/extension/src/skills.ts +61 -2
- package/extension/src/task-detail-format.ts +24 -1
- package/extension/src/task-detail-view.ts +6 -3
- package/extension/src/task-graph.ts +11 -2
- package/extension/src/tasks.ts +7 -5
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +7 -2
- package/src/adapters/sqlite-task-event-store.ts +92 -0
- package/src/cli.ts +82 -8
- package/src/constants.ts +18 -1
- package/src/db.ts +102 -29
- package/src/domain/skill-definition.ts +15 -11
- package/src/domain/task-event.ts +102 -0
- package/src/domain-services.ts +31 -0
- package/src/ports/atomic-artifact-store.ts +13 -0
- package/src/ports/task-event-store.ts +43 -0
- package/src/service.ts +66 -15
- package/src/skill-execution.ts +220 -0
- package/src/task-service.ts +120 -53
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Db } from "../db.ts";
|
|
2
|
+
import { inTransaction } from "../db.ts";
|
|
3
|
+
import {
|
|
4
|
+
normalizeTaskHistoryQuery,
|
|
5
|
+
validateTaskEvent,
|
|
6
|
+
type AppendTaskEvent,
|
|
7
|
+
type TaskEvent,
|
|
8
|
+
type TaskEventEvidence,
|
|
9
|
+
type TaskEventType,
|
|
10
|
+
type TaskHistoryPage,
|
|
11
|
+
type TaskHistoryQuery,
|
|
12
|
+
type TaskLifecycleStatus,
|
|
13
|
+
} from "../domain/task-event.ts";
|
|
14
|
+
import type { TaskEventStore } from "../ports/task-event-store.ts";
|
|
15
|
+
|
|
16
|
+
interface TaskEventRow {
|
|
17
|
+
id: number;
|
|
18
|
+
task_id: string;
|
|
19
|
+
occurred_at: string;
|
|
20
|
+
event_type: TaskEventType;
|
|
21
|
+
actor: string;
|
|
22
|
+
source: string;
|
|
23
|
+
session_id: string | null;
|
|
24
|
+
reason: string | null;
|
|
25
|
+
from_status: TaskLifecycleStatus | null;
|
|
26
|
+
to_status: TaskLifecycleStatus | null;
|
|
27
|
+
attempt_id: string | null;
|
|
28
|
+
evidence_json: string | null;
|
|
29
|
+
event_schema_version: 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mapRow(row: TaskEventRow): TaskEvent {
|
|
33
|
+
return {
|
|
34
|
+
id: row.id,
|
|
35
|
+
taskId: row.task_id,
|
|
36
|
+
occurredAt: row.occurred_at,
|
|
37
|
+
type: row.event_type,
|
|
38
|
+
actor: row.actor,
|
|
39
|
+
source: row.source,
|
|
40
|
+
...(row.session_id === null ? {} : { sessionId: row.session_id }),
|
|
41
|
+
...(row.reason === null ? {} : { reason: row.reason }),
|
|
42
|
+
...(row.from_status === null ? {} : { fromStatus: row.from_status }),
|
|
43
|
+
...(row.to_status === null ? {} : { toStatus: row.to_status }),
|
|
44
|
+
...(row.attempt_id === null ? {} : { attemptId: row.attempt_id }),
|
|
45
|
+
...(row.evidence_json === null ? {} : { evidence: JSON.parse(row.evidence_json) as TaskEventEvidence }),
|
|
46
|
+
schemaVersion: row.event_schema_version,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class SQLiteTaskEventStore implements TaskEventStore {
|
|
51
|
+
constructor(private readonly db: Db) {}
|
|
52
|
+
|
|
53
|
+
atomic<T>(operation: () => T): T { return inTransaction(this.db, operation); }
|
|
54
|
+
|
|
55
|
+
append(input: AppendTaskEvent): TaskEvent {
|
|
56
|
+
const event = validateTaskEvent(input);
|
|
57
|
+
const result = this.db.prepare(`
|
|
58
|
+
INSERT INTO task_events (
|
|
59
|
+
task_id, occurred_at, event_type, actor, source, session_id, reason,
|
|
60
|
+
from_status, to_status, attempt_id, evidence_json, event_schema_version
|
|
61
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
62
|
+
`).run(
|
|
63
|
+
event.taskId,
|
|
64
|
+
new Date().toISOString(),
|
|
65
|
+
event.type,
|
|
66
|
+
event.actor,
|
|
67
|
+
event.source,
|
|
68
|
+
event.sessionId ?? null,
|
|
69
|
+
event.reason ?? null,
|
|
70
|
+
event.fromStatus ?? null,
|
|
71
|
+
event.toStatus ?? null,
|
|
72
|
+
event.attemptId ?? null,
|
|
73
|
+
event.evidence === undefined ? null : JSON.stringify(event.evidence),
|
|
74
|
+
);
|
|
75
|
+
return mapRow(this.db.prepare("SELECT * FROM task_events WHERE id = ?").get(result.lastInsertRowid) as TaskEventRow);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
history(taskId: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
|
|
79
|
+
const { limit, direction, cursor } = normalizeTaskHistoryQuery(query);
|
|
80
|
+
const comparator = direction === "desc" ? "<" : ">";
|
|
81
|
+
const order = direction === "desc" ? "DESC" : "ASC";
|
|
82
|
+
const rows = this.db.prepare(`
|
|
83
|
+
SELECT * FROM task_events
|
|
84
|
+
WHERE task_id = ? ${cursor === undefined ? "" : `AND id ${comparator} ?`}
|
|
85
|
+
ORDER BY occurred_at ${order}, id ${order}
|
|
86
|
+
LIMIT ?
|
|
87
|
+
`).all(...(cursor === undefined ? [taskId, limit + 1] : [taskId, cursor, limit + 1])) as TaskEventRow[];
|
|
88
|
+
const hasMore = rows.length > limit;
|
|
89
|
+
const events = rows.slice(0, limit).map(mapRow);
|
|
90
|
+
return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { connectPapyrusClient, type PapyrusClient } from "./client.ts";
|
|
8
|
-
import { DAEMON_UNIT_NAME } from "./constants.ts";
|
|
8
|
+
import { DAEMON_UNIT_NAME, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
|
|
9
9
|
import { serveMain } from "./daemon.ts";
|
|
10
10
|
import type { GateResult } from "./domain/gate.ts";
|
|
11
11
|
import type { TaskExecutionPlan } from "./task-execution.ts";
|
|
@@ -56,9 +56,12 @@ function installService(): void {
|
|
|
56
56
|
const USAGE = `Usage:
|
|
57
57
|
papyrus serve
|
|
58
58
|
papyrus service <install|start|stop|restart|status>
|
|
59
|
-
papyrus migrate task-
|
|
59
|
+
papyrus migrate task-history [--json]
|
|
60
|
+
papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
|
|
60
61
|
papyrus tasks plan [--json]
|
|
62
|
+
papyrus tasks graph [--json]
|
|
61
63
|
papyrus tasks active [--json]
|
|
64
|
+
papyrus tasks history <id> [--json]
|
|
62
65
|
papyrus tasks focus <id> [--json]
|
|
63
66
|
papyrus tasks complete <id> [--json]
|
|
64
67
|
papyrus tasks start <id> [--json]
|
|
@@ -104,8 +107,8 @@ function planText(plan: TaskExecutionPlan): string {
|
|
|
104
107
|
export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
105
108
|
const json = args.includes("--json");
|
|
106
109
|
const positional = args.filter((arg) => arg !== "--json");
|
|
107
|
-
if (positional.length !== 1 || positional[0] !== "task-
|
|
108
|
-
throw new Error("migrate requires exactly `task-
|
|
110
|
+
if (positional.length !== 1 || positional[0] !== "task-history") {
|
|
111
|
+
throw new Error("migrate requires exactly `task-history`");
|
|
109
112
|
}
|
|
110
113
|
const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
|
|
111
114
|
if (json) return JSON.stringify(result);
|
|
@@ -113,6 +116,51 @@ export async function runMigrationCli(args: string[], client: TaskCliClient): Pr
|
|
|
113
116
|
return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
export async function runSkillCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
120
|
+
const json = args.includes("--json");
|
|
121
|
+
const positional: string[] = [];
|
|
122
|
+
let runId: string | undefined;
|
|
123
|
+
let arguments_: Record<string, unknown> = {};
|
|
124
|
+
for (let index = 0; index < args.length; index++) {
|
|
125
|
+
const argument = args[index]!;
|
|
126
|
+
if (argument === "--json") continue;
|
|
127
|
+
if (argument === "--run-id") {
|
|
128
|
+
runId = args[++index];
|
|
129
|
+
if (!runId) throw new Error("--run-id requires a value");
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (argument === "--arguments-json") {
|
|
133
|
+
const source = args[++index];
|
|
134
|
+
if (!source) throw new Error("--arguments-json requires a JSON object");
|
|
135
|
+
const parsed = JSON.parse(source) as unknown;
|
|
136
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
137
|
+
throw new Error("--arguments-json must be a JSON object");
|
|
138
|
+
}
|
|
139
|
+
arguments_ = parsed as Record<string, unknown>;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (argument.startsWith("--")) throw new Error(`unknown skills option ${argument}`);
|
|
143
|
+
positional.push(argument);
|
|
144
|
+
}
|
|
145
|
+
if (positional.length !== 2 || positional[0] !== "run") throw new Error("skills requires `run <id>`");
|
|
146
|
+
const input: Record<string, unknown> = { id: positional[1], arguments: arguments_ };
|
|
147
|
+
if (runId) input["run_id"] = runId;
|
|
148
|
+
const result = await client.call<Record<string, unknown>, {
|
|
149
|
+
runId: string;
|
|
150
|
+
created: { tasks: string[]; rules: string[]; docs: string[] };
|
|
151
|
+
rootTaskIds: string[];
|
|
152
|
+
execution: TaskExecutionPlan;
|
|
153
|
+
}>("skills.run", input);
|
|
154
|
+
if (json) return JSON.stringify(result);
|
|
155
|
+
return [
|
|
156
|
+
`Created Skill run ${result.runId}: ${result.created.tasks.length} tasks, ${result.created.rules.length} rules, ${result.created.docs.length} docs`,
|
|
157
|
+
`Ready roots: ${result.rootTaskIds.join(", ") || "none"}`,
|
|
158
|
+
`Context docs: ${result.created.docs.join(", ") || "none"}`,
|
|
159
|
+
`Scoped rules: ${result.created.rules.join(", ") || "none"}`,
|
|
160
|
+
...result.execution.nodes.map((node) => `[${node.state}] ${node.id} ${node.title}`),
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
116
164
|
export async function runTaskCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
117
165
|
const json = args.includes("--json");
|
|
118
166
|
const positional = args.filter((arg) => arg !== "--json");
|
|
@@ -127,6 +175,15 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
127
175
|
human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
|
|
128
176
|
break;
|
|
129
177
|
}
|
|
178
|
+
case "history": {
|
|
179
|
+
if (!id || dependencyId) throw new Error("tasks history requires exactly one task id");
|
|
180
|
+
const page = await client.call<{ id: string; direction: "desc" }, import("./domain/task-event.ts").TaskHistoryPage>("tasks.history", { id, direction: "desc" });
|
|
181
|
+
result = page;
|
|
182
|
+
human = page.events.length === 0
|
|
183
|
+
? `No recorded history for ${id}.`
|
|
184
|
+
: [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
130
187
|
case "focus": {
|
|
131
188
|
if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
|
|
132
189
|
const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
|
|
@@ -134,6 +191,18 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
134
191
|
human = `Active: ${artifactLabel(active)}`;
|
|
135
192
|
break;
|
|
136
193
|
}
|
|
194
|
+
case "graph": {
|
|
195
|
+
if (id) throw new Error("tasks graph accepts no positional arguments");
|
|
196
|
+
const graph = await client.call<{ limit: number }, {
|
|
197
|
+
nodes: Array<{ dependencyIds: string[]; childIds: string[] }>;
|
|
198
|
+
rootIds: string[];
|
|
199
|
+
}>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1 });
|
|
200
|
+
result = graph;
|
|
201
|
+
const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
|
|
202
|
+
const children = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
|
|
203
|
+
human = `Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${children} containment edges`;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
137
206
|
case "plan": {
|
|
138
207
|
if (id) throw new Error("tasks plan accepts no positional arguments");
|
|
139
208
|
const plan = await client.call<Record<string, never>, TaskExecutionPlan>("tasks.plan", {});
|
|
@@ -143,7 +212,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
143
212
|
}
|
|
144
213
|
case "complete": {
|
|
145
214
|
if (!id || dependencyId) throw new Error("tasks complete requires exactly one task id");
|
|
146
|
-
const completion = await client.call<
|
|
215
|
+
const completion = await client.call<Record<string, string>, CliCompletion>("tasks.complete", { id, actor: "user", source: "cli" });
|
|
147
216
|
result = completion;
|
|
148
217
|
const lines = [`${completion.completed ? "Completed" : "Rejected"}: ${artifactLabel(completion.artifact)}`];
|
|
149
218
|
if (completion.focused) lines.push(`Active: ${artifactLabel(completion.focused)}`);
|
|
@@ -156,7 +225,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
156
225
|
}
|
|
157
226
|
case "start": {
|
|
158
227
|
if (!id || dependencyId) throw new Error("tasks start requires exactly one task id");
|
|
159
|
-
const artifact = await client.call<
|
|
228
|
+
const artifact = await client.call<Record<string, string>, CliArtifact>("tasks.start", { id, actor: "user", source: "cli" });
|
|
160
229
|
result = artifact;
|
|
161
230
|
human = `Started: ${artifactLabel(artifact)}`;
|
|
162
231
|
break;
|
|
@@ -167,7 +236,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
167
236
|
case "cancel": {
|
|
168
237
|
if (!id || dependencyId) throw new Error(`tasks ${action} requires exactly one task id`);
|
|
169
238
|
const operation = `tasks.${action}` as "tasks.submit" | "tasks.reject" | "tasks.retry" | "tasks.cancel";
|
|
170
|
-
const artifact = await client.call<
|
|
239
|
+
const artifact = await client.call<Record<string, string>, CliArtifact>(operation, { id, actor: "user", source: "cli" });
|
|
171
240
|
result = artifact;
|
|
172
241
|
human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
|
|
173
242
|
break;
|
|
@@ -183,7 +252,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
183
252
|
break;
|
|
184
253
|
}
|
|
185
254
|
default:
|
|
186
|
-
throw new Error("tasks action must be active, focus, plan, complete, start, submit, reject, retry, cancel, or depend");
|
|
255
|
+
throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, or depend");
|
|
187
256
|
}
|
|
188
257
|
return json ? JSON.stringify(result) : human;
|
|
189
258
|
}
|
|
@@ -196,6 +265,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
196
265
|
console.log(await runTaskCli(args.slice(1), client));
|
|
197
266
|
return;
|
|
198
267
|
}
|
|
268
|
+
if (command === "skills") {
|
|
269
|
+
const client = await connectPapyrusClient();
|
|
270
|
+
console.log(await runSkillCli(args.slice(1), client));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
199
273
|
if (command === "migrate") {
|
|
200
274
|
const client = await connectPapyrusClient();
|
|
201
275
|
console.log(await runMigrationCli(args.slice(1), client));
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 3;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
13
13
|
export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
|
|
@@ -32,12 +32,29 @@ export const TASK_GRAPH_HORIZONTAL_PAN_COLUMNS = 4;
|
|
|
32
32
|
export const TASK_EXECUTION_MAX_NODES = 1_000;
|
|
33
33
|
export const TASK_EXECUTION_MAX_EDGES = 10_000;
|
|
34
34
|
export const TASK_EXECUTION_MAX_DEGREE = 100;
|
|
35
|
+
/** Bounded parameterized Skill definitions and rendered workflow runs. */
|
|
36
|
+
export const SKILL_MAX_INPUTS = 32;
|
|
37
|
+
export const SKILL_MAX_ENUM_VALUES = 32;
|
|
38
|
+
export const SKILL_MAX_BLUEPRINTS = 100;
|
|
39
|
+
export const SKILL_MAX_LINKS = 500;
|
|
40
|
+
export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
|
|
41
|
+
export const SKILL_RUN_ID_MAX_LENGTH = 64;
|
|
35
42
|
/** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
|
|
36
43
|
export const TASK_DRIVER_MAX_TURNS = 20;
|
|
37
44
|
export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
|
|
45
|
+
/** Append-only Task chronology query and evidence bounds. */
|
|
46
|
+
export const TASK_HISTORY_DEFAULT_LIMIT = 25;
|
|
47
|
+
export const TASK_HISTORY_MAX_LIMIT = 100;
|
|
48
|
+
export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
|
|
49
|
+
export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
50
|
+
export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
|
|
38
51
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
39
52
|
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
40
53
|
export const GRAPH_RENDER_BOX_PADDING = 0;
|
|
54
|
+
/** beautiful-mermaid routed layouts become unsafe on larger task graphs; use bounded line fallback. */
|
|
55
|
+
export const GRAPH_RENDER_MAX_ROUTED_NODES = 48;
|
|
56
|
+
export const GRAPH_RENDER_MAX_ROUTED_EDGES = 96;
|
|
57
|
+
export const GRAPH_RENDER_MAX_FALLBACK_LINES = 200;
|
|
41
58
|
|
|
42
59
|
/** Safe defaults and hard ceilings for graph expansion. */
|
|
43
60
|
export const DEFAULT_GRAPH_DEPTH = 4;
|
package/src/db.ts
CHANGED
|
@@ -29,15 +29,38 @@ export interface Db {
|
|
|
29
29
|
close(): void;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const TRANSACTION_DEPTH = new WeakMap<object, number>();
|
|
33
|
+
|
|
32
34
|
export function inTransaction<T>(db: Db, fn: () => T): T {
|
|
35
|
+
const depth = TRANSACTION_DEPTH.get(db as object) ?? 0;
|
|
36
|
+
if (depth > 0) {
|
|
37
|
+
const savepoint = `papyrus_nested_${depth}`;
|
|
38
|
+
db.exec(`SAVEPOINT ${savepoint}`);
|
|
39
|
+
TRANSACTION_DEPTH.set(db as object, depth + 1);
|
|
40
|
+
try {
|
|
41
|
+
const result = fn();
|
|
42
|
+
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
43
|
+
return result;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
|
|
46
|
+
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
47
|
+
throw error;
|
|
48
|
+
} finally {
|
|
49
|
+
TRANSACTION_DEPTH.set(db as object, depth);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
db.exec("BEGIN IMMEDIATE");
|
|
54
|
+
TRANSACTION_DEPTH.set(db as object, 1);
|
|
34
55
|
try {
|
|
35
56
|
const result = fn();
|
|
36
57
|
db.exec("COMMIT");
|
|
37
58
|
return result;
|
|
38
|
-
} catch (
|
|
59
|
+
} catch (error) {
|
|
39
60
|
db.exec("ROLLBACK");
|
|
40
|
-
throw
|
|
61
|
+
throw error;
|
|
62
|
+
} finally {
|
|
63
|
+
TRANSACTION_DEPTH.delete(db as object);
|
|
41
64
|
}
|
|
42
65
|
}
|
|
43
66
|
|
|
@@ -79,6 +102,26 @@ CREATE TABLE IF NOT EXISTS task_focus (
|
|
|
79
102
|
task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
80
103
|
updated_at TEXT NOT NULL
|
|
81
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;
|
|
82
125
|
`;
|
|
83
126
|
|
|
84
127
|
const SEED_SQL = `
|
|
@@ -142,36 +185,66 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
142
185
|
throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
143
186
|
}
|
|
144
187
|
if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
|
|
145
|
-
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[] = [];
|
|
146
190
|
|
|
147
191
|
inTransaction(db, () => {
|
|
148
|
-
db
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
}
|
|
173
246
|
});
|
|
174
|
-
return { from, to:
|
|
247
|
+
return { from, to: schemaVersion(db), applied };
|
|
175
248
|
}
|
|
176
249
|
|
|
177
250
|
export function openDb(path: string): Db {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
SEED_RELATIONS,
|
|
3
|
+
SKILL_MAX_BLUEPRINTS,
|
|
4
|
+
SKILL_MAX_ENUM_VALUES,
|
|
5
|
+
SKILL_MAX_INPUTS,
|
|
6
|
+
SKILL_MAX_LINKS,
|
|
7
|
+
} from "../constants.ts";
|
|
2
8
|
|
|
3
9
|
export type SkillArgumentValue = string | number | boolean;
|
|
4
10
|
export type SkillInputType = "string" | "number" | "boolean";
|
|
@@ -59,13 +65,10 @@ export interface SkillDefinition {
|
|
|
59
65
|
links: SkillBlueprintLink[];
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
const MAX_INPUTS = 32;
|
|
63
|
-
const MAX_ENUM_VALUES = 32;
|
|
64
|
-
const MAX_BLUEPRINTS = 100;
|
|
65
|
-
const MAX_LINKS = 500;
|
|
66
68
|
const NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
67
69
|
const PLACEHOLDER_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}/g;
|
|
68
70
|
const INPUT_TYPES = new Set<SkillInputType>(["string", "number", "boolean"]);
|
|
71
|
+
const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
69
72
|
const RELATIONS = new Set<string>(SEED_RELATIONS);
|
|
70
73
|
|
|
71
74
|
function record(value: unknown, label: string): Record<string, unknown> {
|
|
@@ -93,9 +96,10 @@ function validateArgumentValue(name: string, type: SkillInputType, value: unknow
|
|
|
93
96
|
function validateInputs(value: unknown): Record<string, SkillInputDefinition> {
|
|
94
97
|
const source = record(value ?? {}, "skill inputs");
|
|
95
98
|
const entries = Object.entries(source);
|
|
96
|
-
if (entries.length >
|
|
99
|
+
if (entries.length > SKILL_MAX_INPUTS) throw new Error(`skill inputs exceed ${SKILL_MAX_INPUTS}`);
|
|
97
100
|
const result: Record<string, SkillInputDefinition> = {};
|
|
98
101
|
for (const [name, raw] of entries) {
|
|
102
|
+
if (RESERVED_KEYS.has(name)) throw new Error(`reserved skill input name "${name}"`);
|
|
99
103
|
if (!NAME_PATTERN.test(name)) throw new Error(`invalid skill input name "${name}"`);
|
|
100
104
|
const input = record(raw, `skill input "${name}"`);
|
|
101
105
|
if (!INPUT_TYPES.has(input["type"] as SkillInputType)) throw new Error(`skill input "${name}" has unsupported type`);
|
|
@@ -108,7 +112,7 @@ function validateInputs(value: unknown): Record<string, SkillInputDefinition> {
|
|
|
108
112
|
if (input["default"] !== undefined) normalized.default = validateArgumentValue(name, type, input["default"]);
|
|
109
113
|
if (input["enum"] !== undefined) {
|
|
110
114
|
const values = array(input["enum"], `skill input "${name}" enum`);
|
|
111
|
-
if (values.length === 0 || values.length >
|
|
115
|
+
if (values.length === 0 || values.length > SKILL_MAX_ENUM_VALUES) throw new Error(`skill input "${name}" enum must contain 1-${SKILL_MAX_ENUM_VALUES} values`);
|
|
112
116
|
normalized.enum = values.map((entry) => validateArgumentValue(name, type, entry));
|
|
113
117
|
if (normalized.default !== undefined && !normalized.enum.includes(normalized.default)) {
|
|
114
118
|
throw new Error(`skill input "${name}" default must be one of its enum values`);
|
|
@@ -162,7 +166,7 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
|
162
166
|
const rules = array(rawBlueprints["rules"] ?? [], "skill rule blueprints").map((entry) => validateBlueprint<SkillRuleBlueprint>(entry, "rule"));
|
|
163
167
|
const tasks = array(rawBlueprints["tasks"] ?? [], "skill task blueprints").map((entry) => validateBlueprint<SkillTaskBlueprint>(entry, "task"));
|
|
164
168
|
const all = [...docs, ...rules, ...tasks];
|
|
165
|
-
if (all.length === 0 || all.length >
|
|
169
|
+
if (all.length === 0 || all.length > SKILL_MAX_BLUEPRINTS) throw new Error(`skill blueprints must contain 1-${SKILL_MAX_BLUEPRINTS} artifacts`);
|
|
166
170
|
const refs = new Set<string>();
|
|
167
171
|
for (const blueprint of all) {
|
|
168
172
|
if (refs.has(blueprint.ref)) throw new Error(`duplicate skill blueprint ref "${blueprint.ref}"`);
|
|
@@ -179,7 +183,7 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
|
179
183
|
}
|
|
180
184
|
assertAcyclic(tasks);
|
|
181
185
|
for (const name of placeholders(all)) {
|
|
182
|
-
if (!(name
|
|
186
|
+
if (!Object.hasOwn(inputs, name)) throw new Error(`unknown skill input placeholder "${name}"`);
|
|
183
187
|
}
|
|
184
188
|
const links = array(source["links"] ?? [], "skill links").map((entry) => {
|
|
185
189
|
const link = record(entry, "skill link");
|
|
@@ -191,14 +195,14 @@ export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
|
191
195
|
if (!RELATIONS.has(relation)) throw new Error(`unknown skill link relation "${relation}"`);
|
|
192
196
|
return { from, relation, to };
|
|
193
197
|
});
|
|
194
|
-
if (links.length >
|
|
198
|
+
if (links.length > SKILL_MAX_LINKS) throw new Error(`skill links exceed ${SKILL_MAX_LINKS}`);
|
|
195
199
|
return { version: 1, inputs, blueprints: { docs, rules, tasks }, links };
|
|
196
200
|
}
|
|
197
201
|
|
|
198
202
|
export function resolveSkillArguments(definition: SkillDefinition, value: unknown): Record<string, SkillArgumentValue> {
|
|
199
203
|
const source = record(value ?? {}, "skill arguments");
|
|
200
204
|
for (const name of Object.keys(source)) {
|
|
201
|
-
if (!(
|
|
205
|
+
if (!Object.hasOwn(definition.inputs, name)) throw new Error(`unknown skill argument "${name}"`);
|
|
202
206
|
}
|
|
203
207
|
const result: Record<string, SkillArgumentValue> = {};
|
|
204
208
|
for (const [name, input] of Object.entries(definition.inputs)) {
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
"review_rejected",
|
|
17
|
+
"retried",
|
|
18
|
+
"completed",
|
|
19
|
+
"canceled",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type TaskEventType = typeof TASK_EVENT_TYPES[number];
|
|
23
|
+
export type TaskEventDirection = "asc" | "desc";
|
|
24
|
+
|
|
25
|
+
export interface TaskEventContext {
|
|
26
|
+
actor?: string;
|
|
27
|
+
source?: string;
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TaskEventEvidence {
|
|
33
|
+
gates?: unknown;
|
|
34
|
+
checklist?: unknown;
|
|
35
|
+
result?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TaskEvent {
|
|
39
|
+
id: number;
|
|
40
|
+
taskId: string;
|
|
41
|
+
occurredAt: string;
|
|
42
|
+
type: TaskEventType;
|
|
43
|
+
actor: string;
|
|
44
|
+
source: string;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
reason?: string;
|
|
47
|
+
fromStatus?: TaskLifecycleStatus;
|
|
48
|
+
toStatus?: TaskLifecycleStatus;
|
|
49
|
+
attemptId?: string;
|
|
50
|
+
evidence?: TaskEventEvidence;
|
|
51
|
+
schemaVersion: 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AppendTaskEvent {
|
|
55
|
+
taskId: string;
|
|
56
|
+
type: TaskEventType;
|
|
57
|
+
actor: string;
|
|
58
|
+
source: string;
|
|
59
|
+
sessionId?: string;
|
|
60
|
+
reason?: string;
|
|
61
|
+
fromStatus?: TaskLifecycleStatus;
|
|
62
|
+
toStatus?: TaskLifecycleStatus;
|
|
63
|
+
attemptId?: string;
|
|
64
|
+
evidence?: TaskEventEvidence;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TaskHistoryQuery {
|
|
68
|
+
limit?: number;
|
|
69
|
+
cursor?: number;
|
|
70
|
+
direction?: TaskEventDirection;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TaskHistoryPage {
|
|
74
|
+
events: TaskEvent[];
|
|
75
|
+
nextCursor?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeTaskHistoryQuery(query: TaskHistoryQuery = {}): Required<Pick<TaskHistoryQuery, "limit" | "direction">> & Pick<TaskHistoryQuery, "cursor"> {
|
|
79
|
+
const limit = query.limit ?? TASK_HISTORY_DEFAULT_LIMIT;
|
|
80
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > TASK_HISTORY_MAX_LIMIT) {
|
|
81
|
+
throw new Error(`task history limit must be between 1 and ${TASK_HISTORY_MAX_LIMIT}`);
|
|
82
|
+
}
|
|
83
|
+
if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 1)) {
|
|
84
|
+
throw new Error("task history cursor must be a positive integer");
|
|
85
|
+
}
|
|
86
|
+
if (query.direction !== undefined && query.direction !== "asc" && query.direction !== "desc") {
|
|
87
|
+
throw new Error("task history direction must be asc or desc");
|
|
88
|
+
}
|
|
89
|
+
return { limit, direction: query.direction ?? "desc", ...(query.cursor === undefined ? {} : { cursor: query.cursor }) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateTaskEvent(event: AppendTaskEvent): AppendTaskEvent {
|
|
93
|
+
for (const [field, value] of [["actor", event.actor], ["source", event.source]] as const) {
|
|
94
|
+
if (!value || value.length > TASK_EVENT_ACTOR_MAX_LENGTH) throw new Error(`${field} must be between 1 and ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
|
|
95
|
+
}
|
|
96
|
+
if (event.sessionId !== undefined && event.sessionId.length > TASK_EVENT_ACTOR_MAX_LENGTH) throw new Error(`sessionId cannot exceed ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
|
|
97
|
+
if (event.reason !== undefined && event.reason.length > TASK_EVENT_REASON_MAX_LENGTH) throw new Error(`reason cannot exceed ${TASK_EVENT_REASON_MAX_LENGTH} characters`);
|
|
98
|
+
if (event.evidence !== undefined && new TextEncoder().encode(JSON.stringify(event.evidence)).byteLength > TASK_EVENT_MAX_EVIDENCE_BYTES) {
|
|
99
|
+
throw new Error(`task event evidence cannot exceed ${TASK_EVENT_MAX_EVIDENCE_BYTES} bytes`);
|
|
100
|
+
}
|
|
101
|
+
return event;
|
|
102
|
+
}
|