@danypops/papyrus 0.2.1 → 0.4.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/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 (e) {
59
+ } catch (error) {
39
60
  db.exec("ROLLBACK");
40
- throw e;
61
+ throw error;
62
+ } finally {
63
+ TRANSACTION_DEPTH.delete(db as object);
41
64
  }
42
65
  }
43
66
 
@@ -74,6 +97,11 @@ CREATE TABLE IF NOT EXISTS relation_names (
74
97
  name TEXT PRIMARY KEY,
75
98
  description TEXT
76
99
  );
100
+ CREATE TABLE IF NOT EXISTS task_focus (
101
+ scope TEXT PRIMARY KEY CHECK (scope = 'global'),
102
+ task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
103
+ updated_at TEXT NOT NULL
104
+ );
77
105
  `;
78
106
 
79
107
  const SEED_SQL = `
@@ -84,10 +112,12 @@ INSERT OR IGNORE INTO kinds VALUES ('skill','Parameterized workflow bundle — i
84
112
  INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
85
113
  INSERT OR IGNORE INTO statuses VALUES ('active','doc');
86
114
  INSERT OR IGNORE INTO statuses VALUES ('archived','doc');
87
- INSERT OR IGNORE INTO statuses VALUES ('pending','task');
88
- INSERT OR IGNORE INTO statuses VALUES ('active','task');
115
+ INSERT OR IGNORE INTO statuses VALUES ('todo','task');
116
+ INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
117
+ INSERT OR IGNORE INTO statuses VALUES ('review','task');
118
+ INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
89
119
  INSERT OR IGNORE INTO statuses VALUES ('done','task');
90
- INSERT OR IGNORE INTO statuses VALUES ('failed','task');
120
+ INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
91
121
  INSERT OR IGNORE INTO statuses VALUES ('active','rule');
92
122
  INSERT OR IGNORE INTO statuses VALUES ('deprecated','rule');
93
123
  INSERT OR IGNORE INTO statuses VALUES ('active','skill');
@@ -107,23 +137,64 @@ INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a pa
107
137
  CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
108
138
  `;
109
139
 
110
- function migrate(db: Db): void {
111
- const row = db.prepare("PRAGMA user_version").get() as { user_version: number };
112
- let version = row.user_version;
113
- if (version > SQLITE_SCHEMA_VERSION) {
114
- throw new Error(`database schema ${version} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
115
- }
116
- if (version < 1) {
117
- inTransaction(db, () => {
118
- db.exec(SCHEMA);
119
- db.exec(SEED_SQL);
120
- db.exec("PRAGMA user_version = 1");
121
- });
122
- version = 1;
123
- }
124
- if (version !== SQLITE_SCHEMA_VERSION) {
125
- throw new Error(`missing migration from schema ${version} to ${SQLITE_SCHEMA_VERSION}`);
140
+ export interface MigrationResult {
141
+ from: number;
142
+ to: number;
143
+ applied: string[];
144
+ }
145
+
146
+ export function schemaVersion(db: Db): number {
147
+ return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
148
+ }
149
+
150
+ function bootstrapEmptyDatabase(db: Db): void {
151
+ const existing = db
152
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
153
+ .get();
154
+ if (existing) throw new Error("database schema is unversioned; refusing to migrate existing data during boot");
155
+ inTransaction(db, () => {
156
+ db.exec(SCHEMA);
157
+ db.exec(SEED_SQL);
158
+ db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
159
+ });
160
+ }
161
+
162
+ export function migrateDb(db: Db): MigrationResult {
163
+ const from = schemaVersion(db);
164
+ if (from > SQLITE_SCHEMA_VERSION) {
165
+ throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
126
166
  }
167
+ 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}`);
169
+
170
+ 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
+ `);
196
+ });
197
+ return { from, to: SQLITE_SCHEMA_VERSION, applied: ["task-lifecycle-and-focus"] };
127
198
  }
128
199
 
129
200
  export function openDb(path: string): Db {
@@ -132,7 +203,12 @@ export function openDb(path: string): Db {
132
203
  db.exec("PRAGMA foreign_keys = ON");
133
204
  db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
134
205
  if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
135
- migrate(db);
206
+ const current = schemaVersion(db);
207
+ if (current > SQLITE_SCHEMA_VERSION) {
208
+ db.close();
209
+ throw new Error(`database schema ${current} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
210
+ }
211
+ if (current === 0) bootstrapEmptyDatabase(db);
136
212
  db.exec("PRAGMA optimize=0x10002");
137
213
  return db;
138
214
  }
@@ -1,4 +1,10 @@
1
- import { SEED_RELATIONS } from "../constants.ts";
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 > MAX_INPUTS) throw new Error(`skill inputs exceed ${MAX_INPUTS}`);
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 > MAX_ENUM_VALUES) throw new Error(`skill input "${name}" enum must contain 1-${MAX_ENUM_VALUES} values`);
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 > MAX_BLUEPRINTS) throw new Error(`skill blueprints must contain 1-${MAX_BLUEPRINTS} artifacts`);
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 in inputs)) throw new Error(`unknown skill input placeholder "${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 > MAX_LINKS) throw new Error(`skill links exceed ${MAX_LINKS}`);
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 (!(name in definition.inputs)) throw new Error(`unknown skill argument "${name}"`);
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)) {
@@ -1,4 +1,5 @@
1
1
  import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
2
+ import { validateSkillDefinition } from "./domain/skill-definition.ts";
2
3
  import type { ArtifactStore } from "./ports/artifact-store.ts";
3
4
 
4
5
  export interface ListFilter {
@@ -98,6 +99,18 @@ export function listRules(artifacts: ArtifactStore, filter: ListFilter): Artifac
98
99
  return artifacts.query({ kind: "rule", ...filter });
99
100
  }
100
101
 
102
+ /** Global rules always apply; scoped workflow rules apply only while their run owns active focus. */
103
+ export function listInjectableRules(artifacts: ArtifactStore, activeTaskId?: string): Artifact[] {
104
+ return artifacts.query({ kind: "rule", status: "active" }).filter((rule) => {
105
+ const scope = rule.extra["scope"];
106
+ if (scope === undefined) return true;
107
+ if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
108
+ const value = scope as Record<string, unknown>;
109
+ if (value["type"] !== "skill-run" || !Array.isArray(value["taskIds"])) return false;
110
+ return activeTaskId !== undefined && value["taskIds"].some((id) => id === activeTaskId);
111
+ });
112
+ }
113
+
101
114
  export function showRule(artifacts: ArtifactStore, id: string): Artifact {
102
115
  requireKind(artifacts, id, "rule");
103
116
  return artifacts.get(id, { tree: true })!;
@@ -131,6 +144,7 @@ export interface CreateSkillInput {
131
144
  trigger?: string;
132
145
  steps?: string[];
133
146
  tools?: string[];
147
+ definition?: unknown;
134
148
  labels?: string[];
135
149
  extra?: Record<string, unknown>;
136
150
  }
@@ -147,13 +161,19 @@ export interface CreateArtifactTemplateInput {
147
161
  export type SkillTransition = "enable" | "disable";
148
162
 
149
163
  export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput): Artifact {
164
+ if (input.definition !== undefined && (input.trigger !== undefined || input.steps !== undefined || input.tools !== undefined)) {
165
+ throw new Error("workflow Skill definition cannot be mixed with legacy trigger, steps, or tools");
166
+ }
167
+ const definition = input.definition === undefined ? undefined : validateSkillDefinition(input.definition);
150
168
  return artifacts.create({
151
169
  kind: "skill",
170
+ subtype: definition ? "workflow" : undefined,
152
171
  title: input.title,
153
172
  body: input.body,
154
173
  labels: input.labels,
155
174
  extra: {
156
175
  ...(input.extra ?? {}),
176
+ ...(definition ? { definition } : {}),
157
177
  ...(input.trigger ? { trigger: input.trigger } : {}),
158
178
  ...(input.steps ? { steps: input.steps } : {}),
159
179
  ...(input.tools ? { tools: input.tools } : {}),
@@ -194,6 +214,17 @@ export function skillInvocation(artifacts: ArtifactStore, id: string): string {
194
214
  if (skill.subtype === "artifact-template") {
195
215
  return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
196
216
  }
217
+ if (skill.subtype === "workflow") {
218
+ const definition = validateSkillDefinition(skill.extra["definition"]);
219
+ const required = Object.entries(definition.inputs)
220
+ .filter(([, input]) => input.required && input.default === undefined)
221
+ .map(([name]) => name);
222
+ return [
223
+ `Run Papyrus workflow Skill "${skill.title}" (${skill.id}).`,
224
+ `Required arguments: ${required.length > 0 ? required.join(", ") : "none"}.`,
225
+ "Call the skills domain tool with action=run and arguments after collecting required values.",
226
+ ].join("\n");
227
+ }
197
228
  const trigger = typeof skill.extra["trigger"] === "string" ? skill.extra["trigger"] : "manual invocation";
198
229
  const steps = Array.isArray(skill.extra["steps"]) ? skill.extra["steps"].filter((step): step is string => typeof step === "string") : [];
199
230
  const tools = Array.isArray(skill.extra["tools"]) ? skill.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
@@ -0,0 +1,13 @@
1
+ import type { ArtifactStore } from "./artifact-store.ts";
2
+
3
+ /** Artifact store boundary for domain operations that must commit as one graph mutation. */
4
+ export interface AtomicArtifactStore extends ArtifactStore {
5
+ atomic<T>(operation: () => T): T;
6
+ }
7
+
8
+ export function requireAtomicArtifactStore(store: ArtifactStore): AtomicArtifactStore {
9
+ if (!("atomic" in store) || typeof store.atomic !== "function") {
10
+ throw new Error("artifact store does not support atomic workflow runs");
11
+ }
12
+ return store as AtomicArtifactStore;
13
+ }
@@ -0,0 +1,21 @@
1
+ export interface TaskFocusStore {
2
+ get(): string | undefined;
3
+ set(taskId: string): void;
4
+ clear(taskId?: string): void;
5
+ }
6
+
7
+ export class InMemoryTaskFocusStore implements TaskFocusStore {
8
+ private taskId: string | undefined;
9
+
10
+ get(): string | undefined {
11
+ return this.taskId;
12
+ }
13
+
14
+ set(taskId: string): void {
15
+ this.taskId = taskId;
16
+ }
17
+
18
+ clear(taskId?: string): void {
19
+ if (taskId === undefined || taskId === this.taskId) this.taskId = undefined;
20
+ }
21
+ }
package/src/service.ts CHANGED
@@ -1,14 +1,15 @@
1
- import { SERVICE_MAX_BODY_BYTES } from "./constants.ts";
1
+ import { SERVICE_MAX_BODY_BYTES, SQLITE_SCHEMA_VERSION } from "./constants.ts";
2
2
  import { VERSION } from "./version.ts";
3
- import { openDb } from "./db.ts";
3
+ 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
+ import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
6
7
  import type { CreateArtifactInput } from "./domain/artifact.ts";
7
8
  import type { Checklist } from "./domain/checklist.ts";
8
9
  import type { ArtifactStore } from "./ports/artifact-store.ts";
9
10
  import type { GateRunner } from "./ports/gate-runner.ts";
10
11
  import { projectTaskExecution } from "./task-execution.ts";
11
- import { Tasks } from "./task-service.ts";
12
+ import { Tasks, type TaskStatus } from "./task-service.ts";
12
13
  import {
13
14
  createArtifactTemplate,
14
15
  createDocument,
@@ -19,6 +20,7 @@ import {
19
20
  instantiateTemplate,
20
21
  listDocuments,
21
22
  listRules,
23
+ listInjectableRules,
22
24
  listSkills,
23
25
  previewRule,
24
26
  showDocument,
@@ -31,8 +33,10 @@ import {
31
33
  type DocumentRelation,
32
34
  } from "./domain-services.ts";
33
35
  import { taskContext } from "./task-context.ts";
36
+ import { instantiateSkillWorkflow } from "./skill-execution.ts";
34
37
 
35
38
  export const EXPECTED_OPERATION_NAMES = [
39
+ "system.migrate",
36
40
  "artifact.create",
37
41
  "artifact.query",
38
42
  "artifact.show",
@@ -46,13 +50,17 @@ export const EXPECTED_OPERATION_NAMES = [
46
50
  "tasks.graph",
47
51
  "tasks.plan",
48
52
  "tasks.show",
53
+ "tasks.active",
54
+ "tasks.focus",
49
55
  "tasks.start",
56
+ "tasks.submit",
50
57
  "tasks.complete",
51
58
  "tasks.run_gates",
52
59
  "tasks.set_checklist",
53
60
  "tasks.context",
54
- "tasks.fail",
61
+ "tasks.reject",
55
62
  "tasks.retry",
63
+ "tasks.cancel",
56
64
  "tasks.depend",
57
65
  "tasks.contain",
58
66
  "docs.create",
@@ -74,6 +82,7 @@ export const EXPECTED_OPERATION_NAMES = [
74
82
  "skills.list",
75
83
  "skills.show",
76
84
  "skills.invoke",
85
+ "skills.run",
77
86
  "skills.enable",
78
87
  "skills.disable",
79
88
  "skills.instantiate",
@@ -84,6 +93,7 @@ type OperationInput = Record<string, unknown>;
84
93
  type OperationHandler = (input: OperationInput) => unknown;
85
94
 
86
95
  export class UnknownOperationError extends Error {}
96
+ export class MigrationRequiredError extends Error {}
87
97
  export class PayloadTooLargeError extends Error {}
88
98
 
89
99
  function string(input: OperationInput, key: string): string {
@@ -111,21 +121,34 @@ function normalizeCreateInput(input: OperationInput): CreateArtifactInput {
111
121
  return { ...rest, templateId: typeof template_id === "string" ? template_id : undefined } as CreateArtifactInput;
112
122
  }
113
123
 
124
+ export interface SchemaState {
125
+ current: number;
126
+ required: number;
127
+ migrationRequired: boolean;
128
+ }
129
+
114
130
  export interface PapyrusService {
115
131
  operationNames(): OperationName[];
132
+ schemaState(): SchemaState;
116
133
  execute(operation: string, input?: OperationInput): Promise<unknown>;
117
134
  checkpoint(): void;
118
135
  optimize(): void;
119
136
  close(): void;
120
137
  }
121
138
 
122
- function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Record<OperationName, OperationHandler> {
139
+ function handlers(
140
+ artifacts: ArtifactStore,
141
+ gates: GateRunner,
142
+ tasks: Tasks,
143
+ migrate: () => unknown,
144
+ ): Record<OperationName, OperationHandler> {
123
145
  const taskFilter = (input: OperationInput) => ({
124
146
  status: optionalString(input, "status"),
125
147
  text: optionalString(input, "text"),
126
148
  limit: optionalNumber(input, "limit"),
127
149
  });
128
150
  return {
151
+ "system.migrate": () => migrate(),
129
152
  "artifact.create": (input) => artifacts.create(normalizeCreateInput(input)),
130
153
  "artifact.query": (input) => artifacts.query(input),
131
154
  "artifact.show": (input) => artifacts.get(string(input, "id"), {
@@ -151,12 +174,12 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
151
174
  }),
152
175
  "graph.status": (input) => artifacts.setStatus(string(input, "id"), string(input, "status")),
153
176
  "gates.run": (input) => gates.runAsync(string(input, "id")),
154
- "rules.injectable": () => artifacts.query({ kind: "rule", status: "active" })
177
+ "rules.injectable": () => listInjectableRules(artifacts, tasks.active()?.id)
155
178
  .map(({ id, title, body, extra }) => ({ id, title, body, extra })),
156
179
  "tasks.create": (input) => tasks.create({
157
180
  title: string(input, "title"),
158
181
  body: optionalString(input, "body"),
159
- status: optionalString(input, "status") as "pending" | "active" | "done" | "failed" | undefined,
182
+ status: optionalString(input, "status") as TaskStatus | undefined,
160
183
  labels: input["labels"] as string[] | undefined,
161
184
  extra: input["extra"] as Record<string, unknown> | undefined,
162
185
  gates: input["gates"] as Parameters<Tasks["create"]>[0]["gates"],
@@ -169,13 +192,17 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
169
192
  "tasks.graph": (input) => tasks.graph(taskFilter(input)),
170
193
  "tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
171
194
  "tasks.show": (input) => tasks.show(string(input, "id")),
195
+ "tasks.active": () => tasks.active(),
196
+ "tasks.focus": (input) => tasks.focus(string(input, "id")),
172
197
  "tasks.start": (input) => tasks.transition(string(input, "id"), "start"),
198
+ "tasks.submit": (input) => tasks.transition(string(input, "id"), "submit"),
173
199
  "tasks.complete": (input) => tasks.completeAsync(string(input, "id")),
174
200
  "tasks.run_gates": (input) => tasks.runGates(string(input, "id")),
175
201
  "tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
176
- "tasks.context": () => taskContext(artifacts),
177
- "tasks.fail": (input) => tasks.transition(string(input, "id"), "fail"),
202
+ "tasks.context": () => taskContext(artifacts, tasks.active()?.id),
203
+ "tasks.reject": (input) => tasks.transition(string(input, "id"), "reject"),
178
204
  "tasks.retry": (input) => tasks.transition(string(input, "id"), "retry"),
205
+ "tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel"),
179
206
  "tasks.depend": (input) => tasks.depend(string(input, "id"), string(input, "dependency_id")),
180
207
  "tasks.contain": (input) => tasks.contain(string(input, "parent_id"), string(input, "child_id")),
181
208
  "docs.create": (input) => createDocument(artifacts, {
@@ -204,6 +231,7 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
204
231
  "skills.create": (input) => createSkill(artifacts, {
205
232
  title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
206
233
  steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
234
+ definition: input["definition"],
207
235
  labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
208
236
  }),
209
237
  "skills.create_template": (input) => createArtifactTemplate(artifacts, {
@@ -213,6 +241,10 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
213
241
  "skills.list": (input) => listSkills(artifacts, taskFilter(input)),
214
242
  "skills.show": (input) => showSkill(artifacts, string(input, "id")),
215
243
  "skills.invoke": (input) => skillInvocation(artifacts, string(input, "id")),
244
+ "skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
245
+ runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
246
+ arguments: input["arguments"] as Record<string, unknown> | undefined,
247
+ }),
216
248
  "skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
217
249
  "skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
218
250
  "skills.instantiate": (input) => instantiateTemplate(artifacts, string(input, "template_id"), normalizeCreateInput(input)),
@@ -223,13 +255,22 @@ export function createPapyrusService(path: string): PapyrusService {
223
255
  const db = openDb(path);
224
256
  const artifacts = new SQLiteArtifactStore(db);
225
257
  const gates = new SQLiteGateRunner(db);
226
- const tasks = new Tasks(artifacts, gates);
227
- const registry = handlers(artifacts, gates, tasks);
258
+ const focus = new SQLiteTaskFocusStore(db);
259
+ const tasks = new Tasks(artifacts, gates, focus);
260
+ const registry = handlers(artifacts, gates, tasks, () => migrateDb(db));
261
+ const state = (): SchemaState => {
262
+ const current = schemaVersion(db);
263
+ return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
264
+ };
228
265
  return {
229
266
  operationNames: () => [...EXPECTED_OPERATION_NAMES],
267
+ schemaState: state,
230
268
  async execute(operation, input = {}) {
231
269
  const handler = registry[operation as OperationName];
232
270
  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`");
273
+ }
233
274
  return handler(input);
234
275
  },
235
276
  checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
@@ -278,7 +319,7 @@ export function createApp(deps: { service: PapyrusService; token: string }): { f
278
319
  }
279
320
  const url = new URL(request.url);
280
321
  if (request.method === "GET" && url.pathname === "/health") {
281
- return json({ ok: true, version: VERSION });
322
+ return json({ ok: true, version: VERSION, schema: deps.service.schemaState() });
282
323
  }
283
324
  if (request.method === "GET" && url.pathname === "/api/v1/ops") {
284
325
  return json({ operations: deps.service.operationNames() });