@danypops/papyrus 0.2.1 → 0.3.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 +35 -11
- package/extension/src/active-task-continuation.ts +12 -18
- package/extension/src/artifact-format.ts +5 -3
- package/extension/src/domain-tools.ts +18 -4
- package/extension/src/index.ts +16 -22
- package/extension/src/task-detail-format.ts +5 -3
- package/extension/src/task-graph.ts +11 -1
- 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-task-focus-store.ts +31 -0
- package/src/cli.ts +54 -5
- package/src/client.ts +2 -2
- package/src/constants.ts +11 -10
- package/src/db.ts +73 -20
- package/src/ports/task-focus-store.ts +21 -0
- package/src/service.ts +44 -11
- 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/package.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Db } from "../db.ts";
|
|
2
|
+
import { inTransaction } from "../db.ts";
|
|
3
|
+
import type { TaskFocusStore } from "../ports/task-focus-store.ts";
|
|
4
|
+
|
|
5
|
+
export class SQLiteTaskFocusStore implements TaskFocusStore {
|
|
6
|
+
constructor(private readonly db: Db) {}
|
|
7
|
+
|
|
8
|
+
get(): string | undefined {
|
|
9
|
+
const row = this.db.prepare("SELECT task_id FROM task_focus WHERE scope = 'global'").get() as
|
|
10
|
+
| { task_id: string }
|
|
11
|
+
| null;
|
|
12
|
+
return row?.task_id;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
set(taskId: string): void {
|
|
16
|
+
inTransaction(this.db, () => {
|
|
17
|
+
this.db.prepare(`
|
|
18
|
+
INSERT INTO task_focus (scope, task_id, updated_at)
|
|
19
|
+
VALUES ('global', ?, ?)
|
|
20
|
+
ON CONFLICT(scope) DO UPDATE SET task_id = excluded.task_id, updated_at = excluded.updated_at
|
|
21
|
+
`).run(taskId, new Date().toISOString());
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
clear(taskId?: string): void {
|
|
26
|
+
inTransaction(this.db, () => {
|
|
27
|
+
if (taskId === undefined) this.db.prepare("DELETE FROM task_focus WHERE scope = 'global'").run();
|
|
28
|
+
else this.db.prepare("DELETE FROM task_focus WHERE scope = 'global' AND task_id = ?").run(taskId);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -56,9 +56,16 @@ 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-lifecycle [--json]
|
|
59
60
|
papyrus tasks plan [--json]
|
|
61
|
+
papyrus tasks active [--json]
|
|
62
|
+
papyrus tasks focus <id> [--json]
|
|
60
63
|
papyrus tasks complete <id> [--json]
|
|
61
64
|
papyrus tasks start <id> [--json]
|
|
65
|
+
papyrus tasks submit <id> [--json]
|
|
66
|
+
papyrus tasks reject <id> [--json]
|
|
67
|
+
papyrus tasks retry <id> [--json]
|
|
68
|
+
papyrus tasks cancel <id> [--json]
|
|
62
69
|
papyrus tasks depend <id> <prerequisite-id> [--json]`;
|
|
63
70
|
|
|
64
71
|
function usage(): never {
|
|
@@ -67,10 +74,10 @@ function usage(): never {
|
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
type TaskCliClient = Pick<PapyrusClient, "call">;
|
|
77
|
+
type MigrationResult = { from: number; to: number; applied: string[] };
|
|
70
78
|
type CliArtifact = { id: string; title: string; status: string };
|
|
71
|
-
type CliCompletion = Omit<TaskCompletion, "artifact" | "
|
|
79
|
+
type CliCompletion = Omit<TaskCompletion, "artifact" | "blocked"> & {
|
|
72
80
|
artifact: CliArtifact;
|
|
73
|
-
started: CliArtifact[];
|
|
74
81
|
blocked: Array<Omit<TaskBlockage, "artifact"> & { artifact: CliArtifact }>;
|
|
75
82
|
gates: GateResult[];
|
|
76
83
|
};
|
|
@@ -94,6 +101,18 @@ function planText(plan: TaskExecutionPlan): string {
|
|
|
94
101
|
return lines.join("\n");
|
|
95
102
|
}
|
|
96
103
|
|
|
104
|
+
export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
105
|
+
const json = args.includes("--json");
|
|
106
|
+
const positional = args.filter((arg) => arg !== "--json");
|
|
107
|
+
if (positional.length !== 1 || positional[0] !== "task-lifecycle") {
|
|
108
|
+
throw new Error("migrate requires exactly `task-lifecycle`");
|
|
109
|
+
}
|
|
110
|
+
const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
|
|
111
|
+
if (json) return JSON.stringify(result);
|
|
112
|
+
if (result.applied.length === 0) return `Schema already current at version ${result.to}.`;
|
|
113
|
+
return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
97
116
|
export async function runTaskCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
98
117
|
const json = args.includes("--json");
|
|
99
118
|
const positional = args.filter((arg) => arg !== "--json");
|
|
@@ -101,6 +120,20 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
101
120
|
let result: unknown;
|
|
102
121
|
let human: string;
|
|
103
122
|
switch (action) {
|
|
123
|
+
case "active": {
|
|
124
|
+
if (id) throw new Error("tasks active accepts no positional arguments");
|
|
125
|
+
const active = await client.call<Record<string, never>, CliArtifact | null>("tasks.active", {});
|
|
126
|
+
result = active;
|
|
127
|
+
human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case "focus": {
|
|
131
|
+
if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
|
|
132
|
+
const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
|
|
133
|
+
result = active;
|
|
134
|
+
human = `Active: ${artifactLabel(active)}`;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
104
137
|
case "plan": {
|
|
105
138
|
if (id) throw new Error("tasks plan accepts no positional arguments");
|
|
106
139
|
const plan = await client.call<Record<string, never>, TaskExecutionPlan>("tasks.plan", {});
|
|
@@ -112,8 +145,8 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
112
145
|
if (!id || dependencyId) throw new Error("tasks complete requires exactly one task id");
|
|
113
146
|
const completion = await client.call<{ id: string }, CliCompletion>("tasks.complete", { id });
|
|
114
147
|
result = completion;
|
|
115
|
-
const lines = [`${completion.completed ? "Completed" : "
|
|
116
|
-
if (completion.
|
|
148
|
+
const lines = [`${completion.completed ? "Completed" : "Rejected"}: ${artifactLabel(completion.artifact)}`];
|
|
149
|
+
if (completion.focused) lines.push(`Active: ${artifactLabel(completion.focused)}`);
|
|
117
150
|
if (completion.blocked.length > 0) {
|
|
118
151
|
lines.push(`Blocked: ${completion.blocked.map((entry) => `${artifactLabel(entry.artifact)} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`);
|
|
119
152
|
}
|
|
@@ -128,6 +161,17 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
128
161
|
human = `Started: ${artifactLabel(artifact)}`;
|
|
129
162
|
break;
|
|
130
163
|
}
|
|
164
|
+
case "submit":
|
|
165
|
+
case "reject":
|
|
166
|
+
case "retry":
|
|
167
|
+
case "cancel": {
|
|
168
|
+
if (!id || dependencyId) throw new Error(`tasks ${action} requires exactly one task id`);
|
|
169
|
+
const operation = `tasks.${action}` as "tasks.submit" | "tasks.reject" | "tasks.retry" | "tasks.cancel";
|
|
170
|
+
const artifact = await client.call<{ id: string }, CliArtifact>(operation, { id });
|
|
171
|
+
result = artifact;
|
|
172
|
+
human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
131
175
|
case "depend": {
|
|
132
176
|
if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
|
|
133
177
|
const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
|
|
@@ -139,7 +183,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
139
183
|
break;
|
|
140
184
|
}
|
|
141
185
|
default:
|
|
142
|
-
throw new Error("tasks action must be plan, complete, start, or depend");
|
|
186
|
+
throw new Error("tasks action must be active, focus, plan, complete, start, submit, reject, retry, cancel, or depend");
|
|
143
187
|
}
|
|
144
188
|
return json ? JSON.stringify(result) : human;
|
|
145
189
|
}
|
|
@@ -152,6 +196,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
152
196
|
console.log(await runTaskCli(args.slice(1), client));
|
|
153
197
|
return;
|
|
154
198
|
}
|
|
199
|
+
if (command === "migrate") {
|
|
200
|
+
const client = await connectPapyrusClient();
|
|
201
|
+
console.log(await runMigrationCli(args.slice(1), client));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
155
204
|
if (command !== "service") usage();
|
|
156
205
|
switch (action) {
|
|
157
206
|
case "install": installService(); break;
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
|
|
2
2
|
import { daemonStateDir, readDaemonHandle } from "./daemon-state.ts";
|
|
3
|
-
import type { OperationName } from "./service.ts";
|
|
3
|
+
import type { OperationName, SchemaState } from "./service.ts";
|
|
4
4
|
|
|
5
5
|
export type FetchAdapter = (request: Request) => Promise<Response>;
|
|
6
6
|
|
|
@@ -28,7 +28,7 @@ export class PapyrusClient {
|
|
|
28
28
|
return body;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
health(): Promise<{ ok: true; version: string }> {
|
|
31
|
+
health(): Promise<{ ok: true; version: string; schema: SchemaState }> {
|
|
32
32
|
return this.request("/health");
|
|
33
33
|
}
|
|
34
34
|
|
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 = 2;
|
|
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;
|
|
@@ -17,9 +17,9 @@ export const GATE_OUTPUT_LIMIT = 200;
|
|
|
17
17
|
export const GATE_MAX_BUFFER_BYTES = 1_048_576;
|
|
18
18
|
|
|
19
19
|
/** Compact task-context limits keep recurring prompt injection bounded. */
|
|
20
|
-
export const
|
|
21
|
-
export const
|
|
22
|
-
export const
|
|
20
|
+
export const TASK_CONTEXT_CURRENT_LIMIT = 3;
|
|
21
|
+
export const TASK_CONTEXT_REJECTED_LIMIT = 3;
|
|
22
|
+
export const TASK_WIDGET_OPEN_LIMIT = 3;
|
|
23
23
|
export const TASK_DETAIL_MIN_VISIBLE_LINES = 8;
|
|
24
24
|
export const TASK_DETAIL_MAX_VISIBLE_LINES = 24;
|
|
25
25
|
export const TASK_DETAIL_RESERVED_ROWS = 8;
|
|
@@ -32,8 +32,7 @@ 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 automatic Pi continuations while
|
|
36
|
-
export const TASK_DRIVER_ACTIVE_LIMIT = 4;
|
|
35
|
+
/** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
|
|
37
36
|
export const TASK_DRIVER_MAX_TURNS = 20;
|
|
38
37
|
export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
|
|
39
38
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
@@ -58,7 +57,7 @@ export const TASK_RECONCILIATION_INSTRUCTION = [
|
|
|
58
57
|
'• For each current task, ask: "Did we accomplish this task?"',
|
|
59
58
|
"• If yes, run its gates before marking it done; a claim is not verification.",
|
|
60
59
|
"• If no, continue with the next concrete action toward its desired state.",
|
|
61
|
-
"• Address blocked work or explicitly
|
|
60
|
+
"• Address blocked work or explicitly move failed review to rejected with the reason.",
|
|
62
61
|
].join("\n");
|
|
63
62
|
|
|
64
63
|
/** $XDG_DATA_HOME/papyrus/papyrus.db */
|
|
@@ -88,10 +87,12 @@ export const SEED_STATUSES = [
|
|
|
88
87
|
{ name: "draft", kind: "doc" },
|
|
89
88
|
{ name: "active", kind: "doc" },
|
|
90
89
|
{ name: "archived", kind: "doc" },
|
|
91
|
-
{ name: "
|
|
92
|
-
{ name: "
|
|
90
|
+
{ name: "todo", kind: "task" },
|
|
91
|
+
{ name: "in-progress", kind: "task" },
|
|
92
|
+
{ name: "review", kind: "task" },
|
|
93
|
+
{ name: "rejected", kind: "task" },
|
|
93
94
|
{ name: "done", kind: "task" },
|
|
94
|
-
{ name: "
|
|
95
|
+
{ name: "canceled", kind: "task" },
|
|
95
96
|
{ name: "active", kind: "rule" },
|
|
96
97
|
{ name: "deprecated", kind: "rule" },
|
|
97
98
|
{ name: "active", kind: "skill" },
|
package/src/db.ts
CHANGED
|
@@ -74,6 +74,11 @@ CREATE TABLE IF NOT EXISTS relation_names (
|
|
|
74
74
|
name TEXT PRIMARY KEY,
|
|
75
75
|
description TEXT
|
|
76
76
|
);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS task_focus (
|
|
78
|
+
scope TEXT PRIMARY KEY CHECK (scope = 'global'),
|
|
79
|
+
task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
80
|
+
updated_at TEXT NOT NULL
|
|
81
|
+
);
|
|
77
82
|
`;
|
|
78
83
|
|
|
79
84
|
const SEED_SQL = `
|
|
@@ -84,10 +89,12 @@ INSERT OR IGNORE INTO kinds VALUES ('skill','Parameterized workflow bundle — i
|
|
|
84
89
|
INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
|
|
85
90
|
INSERT OR IGNORE INTO statuses VALUES ('active','doc');
|
|
86
91
|
INSERT OR IGNORE INTO statuses VALUES ('archived','doc');
|
|
87
|
-
INSERT OR IGNORE INTO statuses VALUES ('
|
|
88
|
-
INSERT OR IGNORE INTO statuses VALUES ('
|
|
92
|
+
INSERT OR IGNORE INTO statuses VALUES ('todo','task');
|
|
93
|
+
INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
|
|
94
|
+
INSERT OR IGNORE INTO statuses VALUES ('review','task');
|
|
95
|
+
INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
|
|
89
96
|
INSERT OR IGNORE INTO statuses VALUES ('done','task');
|
|
90
|
-
INSERT OR IGNORE INTO statuses VALUES ('
|
|
97
|
+
INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
|
|
91
98
|
INSERT OR IGNORE INTO statuses VALUES ('active','rule');
|
|
92
99
|
INSERT OR IGNORE INTO statuses VALUES ('deprecated','rule');
|
|
93
100
|
INSERT OR IGNORE INTO statuses VALUES ('active','skill');
|
|
@@ -107,23 +114,64 @@ INSERT OR IGNORE INTO relation_names VALUES ('part_of','Artifact belongs to a pa
|
|
|
107
114
|
CREATE INDEX IF NOT EXISTS edges_to_id_idx ON edges(to_id);
|
|
108
115
|
`;
|
|
109
116
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
117
|
+
export interface MigrationResult {
|
|
118
|
+
from: number;
|
|
119
|
+
to: number;
|
|
120
|
+
applied: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function schemaVersion(db: Db): number {
|
|
124
|
+
return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function bootstrapEmptyDatabase(db: Db): void {
|
|
128
|
+
const existing = db
|
|
129
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1")
|
|
130
|
+
.get();
|
|
131
|
+
if (existing) throw new Error("database schema is unversioned; refusing to migrate existing data during boot");
|
|
132
|
+
inTransaction(db, () => {
|
|
133
|
+
db.exec(SCHEMA);
|
|
134
|
+
db.exec(SEED_SQL);
|
|
135
|
+
db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function migrateDb(db: Db): MigrationResult {
|
|
140
|
+
const from = schemaVersion(db);
|
|
141
|
+
if (from > SQLITE_SCHEMA_VERSION) {
|
|
142
|
+
throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
126
143
|
}
|
|
144
|
+
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}`);
|
|
146
|
+
|
|
147
|
+
inTransaction(db, () => {
|
|
148
|
+
db.exec(`
|
|
149
|
+
INSERT OR IGNORE INTO statuses VALUES ('todo','task');
|
|
150
|
+
INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
|
|
151
|
+
INSERT OR IGNORE INTO statuses VALUES ('review','task');
|
|
152
|
+
INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
|
|
153
|
+
INSERT OR IGNORE INTO statuses VALUES ('done','task');
|
|
154
|
+
INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
|
|
155
|
+
CREATE TABLE task_focus (
|
|
156
|
+
scope TEXT PRIMARY KEY CHECK (scope = 'global'),
|
|
157
|
+
task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
158
|
+
updated_at TEXT NOT NULL
|
|
159
|
+
);
|
|
160
|
+
INSERT INTO task_focus (scope, task_id, updated_at)
|
|
161
|
+
SELECT 'global', id, strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
162
|
+
FROM artifacts WHERE kind = 'task' AND status = 'active'
|
|
163
|
+
ORDER BY updated_at DESC, id ASC LIMIT 1;
|
|
164
|
+
UPDATE artifacts SET status = CASE status
|
|
165
|
+
WHEN 'pending' THEN 'todo'
|
|
166
|
+
WHEN 'active' THEN 'in-progress'
|
|
167
|
+
WHEN 'failed' THEN 'rejected'
|
|
168
|
+
ELSE status END
|
|
169
|
+
WHERE kind = 'task';
|
|
170
|
+
DELETE FROM statuses WHERE kind = 'task' AND name IN ('pending', 'active', 'failed');
|
|
171
|
+
PRAGMA user_version = 2;
|
|
172
|
+
`);
|
|
173
|
+
});
|
|
174
|
+
return { from, to: SQLITE_SCHEMA_VERSION, applied: ["task-lifecycle-and-focus"] };
|
|
127
175
|
}
|
|
128
176
|
|
|
129
177
|
export function openDb(path: string): Db {
|
|
@@ -132,7 +180,12 @@ export function openDb(path: string): Db {
|
|
|
132
180
|
db.exec("PRAGMA foreign_keys = ON");
|
|
133
181
|
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
134
182
|
if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
|
|
135
|
-
|
|
183
|
+
const current = schemaVersion(db);
|
|
184
|
+
if (current > SQLITE_SCHEMA_VERSION) {
|
|
185
|
+
db.close();
|
|
186
|
+
throw new Error(`database schema ${current} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
187
|
+
}
|
|
188
|
+
if (current === 0) bootstrapEmptyDatabase(db);
|
|
136
189
|
db.exec("PRAGMA optimize=0x10002");
|
|
137
190
|
return db;
|
|
138
191
|
}
|
|
@@ -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,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
import { taskContext } from "./task-context.ts";
|
|
34
35
|
|
|
35
36
|
export const EXPECTED_OPERATION_NAMES = [
|
|
37
|
+
"system.migrate",
|
|
36
38
|
"artifact.create",
|
|
37
39
|
"artifact.query",
|
|
38
40
|
"artifact.show",
|
|
@@ -46,13 +48,17 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
46
48
|
"tasks.graph",
|
|
47
49
|
"tasks.plan",
|
|
48
50
|
"tasks.show",
|
|
51
|
+
"tasks.active",
|
|
52
|
+
"tasks.focus",
|
|
49
53
|
"tasks.start",
|
|
54
|
+
"tasks.submit",
|
|
50
55
|
"tasks.complete",
|
|
51
56
|
"tasks.run_gates",
|
|
52
57
|
"tasks.set_checklist",
|
|
53
58
|
"tasks.context",
|
|
54
|
-
"tasks.
|
|
59
|
+
"tasks.reject",
|
|
55
60
|
"tasks.retry",
|
|
61
|
+
"tasks.cancel",
|
|
56
62
|
"tasks.depend",
|
|
57
63
|
"tasks.contain",
|
|
58
64
|
"docs.create",
|
|
@@ -84,6 +90,7 @@ type OperationInput = Record<string, unknown>;
|
|
|
84
90
|
type OperationHandler = (input: OperationInput) => unknown;
|
|
85
91
|
|
|
86
92
|
export class UnknownOperationError extends Error {}
|
|
93
|
+
export class MigrationRequiredError extends Error {}
|
|
87
94
|
export class PayloadTooLargeError extends Error {}
|
|
88
95
|
|
|
89
96
|
function string(input: OperationInput, key: string): string {
|
|
@@ -111,21 +118,34 @@ function normalizeCreateInput(input: OperationInput): CreateArtifactInput {
|
|
|
111
118
|
return { ...rest, templateId: typeof template_id === "string" ? template_id : undefined } as CreateArtifactInput;
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
export interface SchemaState {
|
|
122
|
+
current: number;
|
|
123
|
+
required: number;
|
|
124
|
+
migrationRequired: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
114
127
|
export interface PapyrusService {
|
|
115
128
|
operationNames(): OperationName[];
|
|
129
|
+
schemaState(): SchemaState;
|
|
116
130
|
execute(operation: string, input?: OperationInput): Promise<unknown>;
|
|
117
131
|
checkpoint(): void;
|
|
118
132
|
optimize(): void;
|
|
119
133
|
close(): void;
|
|
120
134
|
}
|
|
121
135
|
|
|
122
|
-
function handlers(
|
|
136
|
+
function handlers(
|
|
137
|
+
artifacts: ArtifactStore,
|
|
138
|
+
gates: GateRunner,
|
|
139
|
+
tasks: Tasks,
|
|
140
|
+
migrate: () => unknown,
|
|
141
|
+
): Record<OperationName, OperationHandler> {
|
|
123
142
|
const taskFilter = (input: OperationInput) => ({
|
|
124
143
|
status: optionalString(input, "status"),
|
|
125
144
|
text: optionalString(input, "text"),
|
|
126
145
|
limit: optionalNumber(input, "limit"),
|
|
127
146
|
});
|
|
128
147
|
return {
|
|
148
|
+
"system.migrate": () => migrate(),
|
|
129
149
|
"artifact.create": (input) => artifacts.create(normalizeCreateInput(input)),
|
|
130
150
|
"artifact.query": (input) => artifacts.query(input),
|
|
131
151
|
"artifact.show": (input) => artifacts.get(string(input, "id"), {
|
|
@@ -156,7 +176,7 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
|
|
|
156
176
|
"tasks.create": (input) => tasks.create({
|
|
157
177
|
title: string(input, "title"),
|
|
158
178
|
body: optionalString(input, "body"),
|
|
159
|
-
status: optionalString(input, "status") as
|
|
179
|
+
status: optionalString(input, "status") as TaskStatus | undefined,
|
|
160
180
|
labels: input["labels"] as string[] | undefined,
|
|
161
181
|
extra: input["extra"] as Record<string, unknown> | undefined,
|
|
162
182
|
gates: input["gates"] as Parameters<Tasks["create"]>[0]["gates"],
|
|
@@ -169,13 +189,17 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
|
|
|
169
189
|
"tasks.graph": (input) => tasks.graph(taskFilter(input)),
|
|
170
190
|
"tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
|
|
171
191
|
"tasks.show": (input) => tasks.show(string(input, "id")),
|
|
192
|
+
"tasks.active": () => tasks.active(),
|
|
193
|
+
"tasks.focus": (input) => tasks.focus(string(input, "id")),
|
|
172
194
|
"tasks.start": (input) => tasks.transition(string(input, "id"), "start"),
|
|
195
|
+
"tasks.submit": (input) => tasks.transition(string(input, "id"), "submit"),
|
|
173
196
|
"tasks.complete": (input) => tasks.completeAsync(string(input, "id")),
|
|
174
197
|
"tasks.run_gates": (input) => tasks.runGates(string(input, "id")),
|
|
175
198
|
"tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
|
|
176
|
-
"tasks.context": () => taskContext(artifacts),
|
|
177
|
-
"tasks.
|
|
199
|
+
"tasks.context": () => taskContext(artifacts, tasks.active()?.id),
|
|
200
|
+
"tasks.reject": (input) => tasks.transition(string(input, "id"), "reject"),
|
|
178
201
|
"tasks.retry": (input) => tasks.transition(string(input, "id"), "retry"),
|
|
202
|
+
"tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel"),
|
|
179
203
|
"tasks.depend": (input) => tasks.depend(string(input, "id"), string(input, "dependency_id")),
|
|
180
204
|
"tasks.contain": (input) => tasks.contain(string(input, "parent_id"), string(input, "child_id")),
|
|
181
205
|
"docs.create": (input) => createDocument(artifacts, {
|
|
@@ -223,13 +247,22 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
223
247
|
const db = openDb(path);
|
|
224
248
|
const artifacts = new SQLiteArtifactStore(db);
|
|
225
249
|
const gates = new SQLiteGateRunner(db);
|
|
226
|
-
const
|
|
227
|
-
const
|
|
250
|
+
const focus = new SQLiteTaskFocusStore(db);
|
|
251
|
+
const tasks = new Tasks(artifacts, gates, focus);
|
|
252
|
+
const registry = handlers(artifacts, gates, tasks, () => migrateDb(db));
|
|
253
|
+
const state = (): SchemaState => {
|
|
254
|
+
const current = schemaVersion(db);
|
|
255
|
+
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|
|
256
|
+
};
|
|
228
257
|
return {
|
|
229
258
|
operationNames: () => [...EXPECTED_OPERATION_NAMES],
|
|
259
|
+
schemaState: state,
|
|
230
260
|
async execute(operation, input = {}) {
|
|
231
261
|
const handler = registry[operation as OperationName];
|
|
232
262
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
263
|
+
if (operation !== "system.migrate" && state().migrationRequired) {
|
|
264
|
+
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-lifecycle`");
|
|
265
|
+
}
|
|
233
266
|
return handler(input);
|
|
234
267
|
},
|
|
235
268
|
checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
|
|
@@ -278,7 +311,7 @@ export function createApp(deps: { service: PapyrusService; token: string }): { f
|
|
|
278
311
|
}
|
|
279
312
|
const url = new URL(request.url);
|
|
280
313
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
281
|
-
return json({ ok: true, version: VERSION });
|
|
314
|
+
return json({ ok: true, version: VERSION, schema: deps.service.schemaState() });
|
|
282
315
|
}
|
|
283
316
|
if (request.method === "GET" && url.pathname === "/api/v1/ops") {
|
|
284
317
|
return json({ operations: deps.service.operationNames() });
|
package/src/task-context.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Artifact } from "./domain/artifact.ts";
|
|
2
2
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
TASK_CONTEXT_CURRENT_LIMIT,
|
|
5
|
+
TASK_CONTEXT_REJECTED_LIMIT,
|
|
6
6
|
TASK_RECONCILIATION_INSTRUCTION,
|
|
7
7
|
} from "./constants.ts";
|
|
8
8
|
|
|
@@ -34,19 +34,20 @@ function renderCurrent(task: Artifact): string[] {
|
|
|
34
34
|
];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function taskContext(artifacts: ArtifactStore): string | null {
|
|
37
|
+
export function taskContext(artifacts: ArtifactStore, activeTaskId?: string): string | null {
|
|
38
38
|
const tasks = artifacts.query({ kind: "task" }).sort((left, right) => left.updated_at.localeCompare(right.updated_at));
|
|
39
|
-
const open = tasks.filter((task) => task.status !== "done");
|
|
39
|
+
const open = tasks.filter((task) => task.status !== "done" && task.status !== "canceled");
|
|
40
40
|
if (open.length === 0) return null;
|
|
41
41
|
|
|
42
42
|
const done = tasks.length - open.length;
|
|
43
|
-
const active = open.
|
|
44
|
-
const
|
|
45
|
-
const
|
|
43
|
+
const active = activeTaskId ? open.find((task) => task.id === activeTaskId) : undefined;
|
|
44
|
+
const current = active ? [active] : open.filter((task) => task.status === "in-progress" || task.status === "review").slice(0, TASK_CONTEXT_CURRENT_LIMIT);
|
|
45
|
+
const next = open.find((task) => task.status === "todo");
|
|
46
|
+
const rejected = open.filter((task) => task.status === "rejected").slice(0, TASK_CONTEXT_REJECTED_LIMIT);
|
|
46
47
|
const lines = [`Progress: ${done}/${tasks.length} done`];
|
|
47
|
-
for (const task of
|
|
48
|
+
for (const task of current) lines.push(...renderCurrent(task));
|
|
48
49
|
if (next) lines.push(`Next: ${next.title} (${next.id})`);
|
|
49
|
-
if (
|
|
50
|
+
if (rejected.length > 0) lines.push(`Rejected: ${rejected.map((task) => `${task.title} (${task.id})`).join(", ")}`);
|
|
50
51
|
lines.push("", TASK_RECONCILIATION_INSTRUCTION);
|
|
51
52
|
return lines.join("\n");
|
|
52
53
|
}
|
package/src/task-execution.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
|
|
2
2
|
import type { TaskGraph } from "./task-service.ts";
|
|
3
3
|
|
|
4
|
-
export type TaskExecutionState =
|
|
4
|
+
export type TaskExecutionState =
|
|
5
|
+
| "todo"
|
|
6
|
+
| "in-progress"
|
|
7
|
+
| "review"
|
|
8
|
+
| "rejected"
|
|
9
|
+
| "done"
|
|
10
|
+
| "canceled"
|
|
11
|
+
| "ready"
|
|
12
|
+
| "blocked"
|
|
13
|
+
| "invalid";
|
|
5
14
|
|
|
6
15
|
export interface TaskExecutionNode {
|
|
7
16
|
id: string;
|
|
8
17
|
title: string;
|
|
9
18
|
status: string;
|
|
19
|
+
active: boolean;
|
|
10
20
|
state: TaskExecutionState;
|
|
11
21
|
layer: number | null;
|
|
12
22
|
prerequisiteIds: string[];
|
|
@@ -21,8 +31,10 @@ export interface TaskExecutionPlan {
|
|
|
21
31
|
|
|
22
32
|
function executionState(status: string, invalid: boolean, prerequisitesDone: boolean): TaskExecutionState {
|
|
23
33
|
if (invalid) return "invalid";
|
|
24
|
-
if (status === "
|
|
25
|
-
if (
|
|
34
|
+
if (status === "todo") return prerequisitesDone ? "ready" : "blocked";
|
|
35
|
+
if (["in-progress", "review", "rejected", "done", "canceled"].includes(status)) {
|
|
36
|
+
return status as TaskExecutionState;
|
|
37
|
+
}
|
|
26
38
|
return "blocked";
|
|
27
39
|
}
|
|
28
40
|
|
|
@@ -96,6 +108,7 @@ export function projectTaskExecution(graph: TaskGraph): TaskExecutionPlan {
|
|
|
96
108
|
id: node.task.id,
|
|
97
109
|
title: node.task.title,
|
|
98
110
|
status: node.task.status,
|
|
111
|
+
active: node.active === true,
|
|
99
112
|
state,
|
|
100
113
|
layer: layerById.get(node.task.id) ?? null,
|
|
101
114
|
prerequisiteIds,
|
package/src/task-graph-view.ts
CHANGED
|
@@ -5,11 +5,14 @@ import type { TaskGraph } from "./task-service.ts";
|
|
|
5
5
|
export type TaskGraphView = "execution" | "dependencies" | "composition";
|
|
6
6
|
|
|
7
7
|
const EXECUTION_GLYPHS: Record<TaskExecutionState, string> = {
|
|
8
|
+
todo: "○",
|
|
9
|
+
"in-progress": "●",
|
|
10
|
+
review: "◆",
|
|
11
|
+
rejected: "▲",
|
|
8
12
|
done: "■",
|
|
9
|
-
|
|
13
|
+
canceled: "×",
|
|
10
14
|
ready: "◇",
|
|
11
15
|
blocked: "○",
|
|
12
|
-
failed: "▲",
|
|
13
16
|
invalid: "!",
|
|
14
17
|
};
|
|
15
18
|
|
|
@@ -39,7 +42,7 @@ export function projectTaskGraph(graph: TaskGraph, view: TaskGraphView): Display
|
|
|
39
42
|
const nodes = view === "execution"
|
|
40
43
|
? projectTaskExecution(graph).nodes.map((node) => ({
|
|
41
44
|
id: node.id,
|
|
42
|
-
label: `${EXECUTION_GLYPHS[node.state]} ${node.title} · ${node.layer === null ? "no layer" : `layer ${node.layer + 1}`} · ${node.state}`,
|
|
45
|
+
label: `${node.active ? "▶ " : ""}${EXECUTION_GLYPHS[node.state]} ${node.title} · ${node.layer === null ? "no layer" : `layer ${node.layer + 1}`} · ${node.state}`,
|
|
43
46
|
status: node.state,
|
|
44
47
|
}))
|
|
45
48
|
: graph.nodes
|