@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/README.md +49 -14
- package/extension/src/active-task-continuation.ts +12 -18
- package/extension/src/artifact-format.ts +5 -3
- package/extension/src/beautiful-mermaid-renderer.ts +23 -0
- package/extension/src/domain-tools.ts +41 -7
- package/extension/src/index.ts +21 -36
- package/extension/src/skills.ts +61 -2
- package/extension/src/task-detail-format.ts +5 -3
- package/extension/src/task-graph.ts +22 -3
- package/extension/src/task-presentation.ts +26 -0
- package/extension/src/task-widget.ts +27 -20
- package/extension/src/tasks.ts +68 -31
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +7 -2
- package/src/adapters/sqlite-task-focus-store.ts +31 -0
- package/src/cli.ts +119 -6
- package/src/client.ts +2 -2
- package/src/constants.ts +22 -10
- package/src/db.ts +98 -22
- package/src/domain/skill-definition.ts +15 -11
- package/src/domain-services.ts +31 -0
- package/src/ports/atomic-artifact-store.ts +13 -0
- package/src/ports/task-focus-store.ts +21 -0
- package/src/service.ts +53 -12
- package/src/skill-execution.ts +204 -0
- package/src/task-context.ts +10 -9
- package/src/task-execution.ts +16 -3
- package/src/task-graph-view.ts +6 -3
- package/src/task-service.ts +110 -27
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
|
|
|
@@ -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 ('
|
|
88
|
-
INSERT OR IGNORE INTO statuses VALUES ('
|
|
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 ('
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
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
|
-
|
|
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 {
|
|
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)) {
|
package/src/domain-services.ts
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
227
|
-
const
|
|
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() });
|