@danypops/papyrus 0.7.0 → 0.8.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 +18 -29
- package/extension/src/active-task-continuation.ts +9 -0
- package/extension/src/domain-tools.ts +15 -3
- package/extension/src/index.ts +20 -6
- package/extension/src/task-widget.ts +2 -1
- package/extension/src/tasks.ts +28 -20
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +6 -1
- package/src/adapters/sqlite-task-focus-store.ts +27 -14
- package/src/cli.ts +60 -40
- package/src/constants.ts +6 -14
- package/src/daemon.ts +2 -18
- package/src/db.ts +12 -2
- package/src/domain/artifact.ts +6 -0
- package/src/domain/task-event.ts +5 -2
- package/src/ops.ts +17 -1
- package/src/ports/artifact-store.ts +2 -0
- package/src/ports/task-focus-store.ts +30 -8
- package/src/service.ts +33 -21
- package/src/task-service.ts +98 -29
- package/src/task-automation.ts +0 -188
package/src/daemon.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, WAL_CHECKPOINT_INTERVAL_MS, dbPath } from "./constants.ts";
|
|
2
2
|
import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
|
|
3
3
|
import { createApp, createPapyrusService } from "./service.ts";
|
|
4
|
-
import { scheduleTaskAutomation, taskAutomationSettings, type TaskAutomationResult } from "./task-automation.ts";
|
|
5
4
|
import { logEvent } from "./log.ts";
|
|
6
5
|
|
|
7
6
|
/** Start the supervised, long-running Papyrus service. */
|
|
8
7
|
export function serveMain(): void {
|
|
9
8
|
const stateDir = daemonStateDir();
|
|
10
9
|
const token = loadOrCreateToken(stateDir);
|
|
11
|
-
const
|
|
12
|
-
const service = createPapyrusService(dbPath(), { automation });
|
|
10
|
+
const service = createPapyrusService(dbPath());
|
|
13
11
|
const app = createApp({ service, token });
|
|
14
12
|
const server = Bun.serve({
|
|
15
13
|
hostname: DAEMON_HOST,
|
|
@@ -27,31 +25,17 @@ export function serveMain(): void {
|
|
|
27
25
|
const optimizeTimer = setInterval(() => {
|
|
28
26
|
try { service.optimize(); } catch (error) { logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) }); }
|
|
29
27
|
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
30
|
-
const stopAutomation = scheduleTaskAutomation(automation, async () => {
|
|
31
|
-
const result = await service.execute("automation.reconcile", {}) as TaskAutomationResult;
|
|
32
|
-
logEvent(result.errors.length > 0 ? "warn" : "info", "automation_sweep", {
|
|
33
|
-
examined: result.examined,
|
|
34
|
-
completed: result.completed,
|
|
35
|
-
rejected: result.rejected,
|
|
36
|
-
started: result.started,
|
|
37
|
-
errors: result.errors.length,
|
|
38
|
-
timedOut: result.timedOut,
|
|
39
|
-
skipped: result.skipped,
|
|
40
|
-
});
|
|
41
|
-
}, (error) => logEvent("error", "automation_sweep_failed", { message: error instanceof Error ? error.message : String(error) }));
|
|
42
|
-
|
|
43
28
|
let stopping = false;
|
|
44
29
|
const shutdown = () => {
|
|
45
30
|
if (stopping) return;
|
|
46
31
|
stopping = true;
|
|
47
32
|
clearInterval(checkpointTimer);
|
|
48
33
|
clearInterval(optimizeTimer);
|
|
49
|
-
stopAutomation();
|
|
50
34
|
clearDaemonPort(stateDir);
|
|
51
35
|
service.close();
|
|
52
36
|
void server.stop(true).finally(() => process.exit(0));
|
|
53
37
|
};
|
|
54
38
|
process.on("SIGINT", shutdown);
|
|
55
39
|
process.on("SIGTERM", shutdown);
|
|
56
|
-
logEvent("info", "listening", { host: DAEMON_HOST, port: server.port
|
|
40
|
+
logEvent("info", "listening", { host: DAEMON_HOST, port: server.port });
|
|
57
41
|
}
|
package/src/db.ts
CHANGED
|
@@ -100,6 +100,8 @@ CREATE TABLE IF NOT EXISTS relation_names (
|
|
|
100
100
|
CREATE TABLE IF NOT EXISTS task_focus (
|
|
101
101
|
scope TEXT PRIMARY KEY CHECK (scope = 'global'),
|
|
102
102
|
task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
103
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'paused')),
|
|
104
|
+
pause_reason TEXT,
|
|
103
105
|
updated_at TEXT NOT NULL
|
|
104
106
|
);
|
|
105
107
|
CREATE TABLE IF NOT EXISTS task_events (
|
|
@@ -140,7 +142,7 @@ CREATE TABLE IF NOT EXISTS task_views (
|
|
|
140
142
|
|
|
141
143
|
const SEED_SQL = `
|
|
142
144
|
INSERT OR IGNORE INTO kinds VALUES ('doc','Knowledge — what we know (specs, decisions, research, designs)');
|
|
143
|
-
INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (
|
|
145
|
+
INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (objectives, steps, checklists)');
|
|
144
146
|
INSERT OR IGNORE INTO kinds VALUES ('rule','Governance — when doing X, follow Y');
|
|
145
147
|
INSERT OR IGNORE INTO kinds VALUES ('skill','Parameterized workflow bundle — inputs and templates load tasks, rules, and docs');
|
|
146
148
|
INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
|
|
@@ -199,7 +201,7 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
199
201
|
throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
200
202
|
}
|
|
201
203
|
if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
|
|
202
|
-
if (from !== 1 && from !== 2 && from !== 3) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
204
|
+
if (from !== 1 && from !== 2 && from !== 3 && from !== 4) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
203
205
|
const applied: string[] = [];
|
|
204
206
|
|
|
205
207
|
inTransaction(db, () => {
|
|
@@ -280,6 +282,14 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
280
282
|
`);
|
|
281
283
|
applied.push("task-project-scope");
|
|
282
284
|
}
|
|
285
|
+
if (schemaVersion(db) === 4) {
|
|
286
|
+
db.exec(`
|
|
287
|
+
ALTER TABLE task_focus ADD COLUMN status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused'));
|
|
288
|
+
ALTER TABLE task_focus ADD COLUMN pause_reason TEXT;
|
|
289
|
+
PRAGMA user_version = 5;
|
|
290
|
+
`);
|
|
291
|
+
applied.push("task-focus-continuation");
|
|
292
|
+
}
|
|
283
293
|
});
|
|
284
294
|
return { from, to: schemaVersion(db), applied };
|
|
285
295
|
}
|
package/src/domain/artifact.ts
CHANGED
|
@@ -30,6 +30,12 @@ export interface CreateArtifactInput {
|
|
|
30
30
|
templateId?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface UpdateArtifactInput {
|
|
34
|
+
title?: string;
|
|
35
|
+
body?: string;
|
|
36
|
+
labels?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
export interface ArtifactQuery {
|
|
34
40
|
kind?: string;
|
|
35
41
|
status?: string;
|
package/src/domain/task-event.ts
CHANGED
|
@@ -9,12 +9,15 @@ export type TaskLifecycleStatus = "todo" | "in-progress" | "review" | "rejected"
|
|
|
9
9
|
|
|
10
10
|
export const TASK_EVENT_TYPES = [
|
|
11
11
|
"created",
|
|
12
|
+
"updated",
|
|
12
13
|
"started",
|
|
13
14
|
"submitted",
|
|
14
15
|
"completion_attempted",
|
|
15
16
|
"gates_evaluated",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
17
|
+
"focus_set",
|
|
18
|
+
"focus_paused",
|
|
19
|
+
"focus_unpaused",
|
|
20
|
+
"focus_cleared",
|
|
18
21
|
"project_assigned",
|
|
19
22
|
"review_rejected",
|
|
20
23
|
"retried",
|
package/src/ops.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createRequire } from "node:module";
|
|
|
6
6
|
import { exec } from "node:child_process";
|
|
7
7
|
import type { Db } from "./db.ts";
|
|
8
8
|
import { inTransaction } from "./db.ts";
|
|
9
|
-
import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
|
|
9
|
+
import type { Artifact, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
|
|
10
10
|
import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
|
|
11
11
|
export type { Artifact } from "./domain/artifact.ts";
|
|
12
12
|
export type { Gate, GateResult } from "./domain/gate.ts";
|
|
@@ -204,6 +204,22 @@ export function linkArtifacts(db: Db, fromId: string, relation: string, toId: st
|
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
export function updateArtifactContent(db: Db, id: string, input: UpdateArtifactInput): Artifact | null {
|
|
208
|
+
const artifact = getArtifact(db, id);
|
|
209
|
+
if (!artifact) return null;
|
|
210
|
+
const now = new Date().toISOString();
|
|
211
|
+
inTransaction(db, () => {
|
|
212
|
+
db.prepare("UPDATE artifacts SET title = ?, body = ?, labels = ?, updated_at = ? WHERE id = ?").run(
|
|
213
|
+
input.title ?? artifact.title,
|
|
214
|
+
input.body ?? artifact.body,
|
|
215
|
+
JSON.stringify(input.labels ?? artifact.labels),
|
|
216
|
+
now,
|
|
217
|
+
id,
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
return getArtifact(db, id);
|
|
221
|
+
}
|
|
222
|
+
|
|
207
223
|
export function updateStatus(db: Db, id: string, status: string): Artifact | null {
|
|
208
224
|
const art = getArtifact(db, id);
|
|
209
225
|
if (!art) return null;
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ArtifactQuery,
|
|
7
7
|
CreateArtifactInput,
|
|
8
8
|
RelationshipQuery,
|
|
9
|
+
UpdateArtifactInput,
|
|
9
10
|
} from "../domain/artifact.ts";
|
|
10
11
|
|
|
11
12
|
export interface ArtifactStore {
|
|
@@ -15,5 +16,6 @@ export interface ArtifactStore {
|
|
|
15
16
|
link(link: ArtifactLink): void;
|
|
16
17
|
setStatus(id: string, status: string): Artifact | null;
|
|
17
18
|
setExtra(id: string, extra: Record<string, unknown>): Artifact | null;
|
|
19
|
+
updateContent(id: string, input: UpdateArtifactInput): Artifact | null;
|
|
18
20
|
relationships(filter?: RelationshipQuery): ArtifactEdge[];
|
|
19
21
|
}
|
|
@@ -1,21 +1,43 @@
|
|
|
1
|
+
export type TaskFocusStatus = "active" | "paused";
|
|
2
|
+
|
|
3
|
+
export interface TaskFocusState {
|
|
4
|
+
taskId: string;
|
|
5
|
+
status: TaskFocusStatus;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
pauseReason?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
1
10
|
export interface TaskFocusStore {
|
|
2
|
-
get():
|
|
3
|
-
set(taskId: string):
|
|
11
|
+
get(): TaskFocusState | undefined;
|
|
12
|
+
set(taskId: string): TaskFocusState;
|
|
13
|
+
pause(taskId: string, reason?: string): TaskFocusState;
|
|
14
|
+
unpause(taskId: string): TaskFocusState;
|
|
4
15
|
clear(taskId?: string): void;
|
|
5
16
|
}
|
|
6
17
|
|
|
7
18
|
export class InMemoryTaskFocusStore implements TaskFocusStore {
|
|
8
|
-
private
|
|
19
|
+
private state: TaskFocusState | undefined;
|
|
20
|
+
|
|
21
|
+
get(): TaskFocusState | undefined { return this.state; }
|
|
22
|
+
|
|
23
|
+
set(taskId: string): TaskFocusState {
|
|
24
|
+
this.state = { taskId, status: "active", updatedAt: new Date().toISOString() };
|
|
25
|
+
return this.state;
|
|
26
|
+
}
|
|
9
27
|
|
|
10
|
-
|
|
11
|
-
|
|
28
|
+
pause(taskId: string, reason?: string): TaskFocusState {
|
|
29
|
+
if (this.state?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
|
|
30
|
+
this.state = { ...this.state, status: "paused", updatedAt: new Date().toISOString(), ...(reason ? { pauseReason: reason } : {}) };
|
|
31
|
+
return this.state;
|
|
12
32
|
}
|
|
13
33
|
|
|
14
|
-
|
|
15
|
-
this.taskId
|
|
34
|
+
unpause(taskId: string): TaskFocusState {
|
|
35
|
+
if (this.state?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
|
|
36
|
+
this.state = { taskId, status: "active", updatedAt: new Date().toISOString() };
|
|
37
|
+
return this.state;
|
|
16
38
|
}
|
|
17
39
|
|
|
18
40
|
clear(taskId?: string): void {
|
|
19
|
-
if (taskId === undefined || taskId ===
|
|
41
|
+
if (taskId === undefined || this.state?.taskId === taskId) this.state = undefined;
|
|
20
42
|
}
|
|
21
43
|
}
|
package/src/service.ts
CHANGED
|
@@ -16,7 +16,6 @@ import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
|
16
16
|
import type { TaskScopeStore } from "./ports/task-scope-store.ts";
|
|
17
17
|
import { projectTaskExecution } from "./task-execution.ts";
|
|
18
18
|
import { Tasks, type TaskStatus } from "./task-service.ts";
|
|
19
|
-
import { TaskAutomationReconciler, taskAutomationSettings, type TaskAutomationSettings } from "./task-automation.ts";
|
|
20
19
|
import {
|
|
21
20
|
createArtifactTemplate,
|
|
22
21
|
createDocument,
|
|
@@ -44,8 +43,6 @@ import { instantiateSkillWorkflow } from "./skill-execution.ts";
|
|
|
44
43
|
|
|
45
44
|
export const EXPECTED_OPERATION_NAMES = [
|
|
46
45
|
"system.migrate",
|
|
47
|
-
"automation.status",
|
|
48
|
-
"automation.reconcile",
|
|
49
46
|
"artifact.create",
|
|
50
47
|
"artifact.query",
|
|
51
48
|
"artifact.show",
|
|
@@ -55,6 +52,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
55
52
|
"gates.run",
|
|
56
53
|
"rules.injectable",
|
|
57
54
|
"tasks.create",
|
|
55
|
+
"tasks.update",
|
|
58
56
|
"tasks.list",
|
|
59
57
|
"tasks.graph",
|
|
60
58
|
"tasks.plan",
|
|
@@ -64,13 +62,16 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
64
62
|
"tasks.set_scope",
|
|
65
63
|
"tasks.assign_project",
|
|
66
64
|
"tasks.active",
|
|
65
|
+
"tasks.focused",
|
|
67
66
|
"tasks.focus",
|
|
67
|
+
"tasks.pause",
|
|
68
|
+
"tasks.unpause",
|
|
69
|
+
"tasks.clear_focus",
|
|
68
70
|
"tasks.start",
|
|
69
71
|
"tasks.submit",
|
|
70
72
|
"tasks.complete",
|
|
71
73
|
"tasks.run_gates",
|
|
72
74
|
"tasks.set_checklist",
|
|
73
|
-
"tasks.set_automation",
|
|
74
75
|
"tasks.context",
|
|
75
76
|
"tasks.reject",
|
|
76
77
|
"tasks.retry",
|
|
@@ -123,6 +124,13 @@ function optionalString(input: OperationInput, key: string): string | undefined
|
|
|
123
124
|
return value;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
function optionalStringArray(input: OperationInput, key: string): string[] | undefined {
|
|
128
|
+
const value = input[key];
|
|
129
|
+
if (value === undefined) return undefined;
|
|
130
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) throw new Error(`${key} must be an array of strings`);
|
|
131
|
+
return value as string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
126
134
|
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
127
135
|
const value = input[key];
|
|
128
136
|
if (value === undefined) return undefined;
|
|
@@ -154,7 +162,6 @@ function handlers(
|
|
|
154
162
|
artifacts: ArtifactStore,
|
|
155
163
|
gates: GateRunner,
|
|
156
164
|
tasks: Tasks,
|
|
157
|
-
automation: TaskAutomationReconciler,
|
|
158
165
|
events: TaskEventStore,
|
|
159
166
|
scopes: TaskScopeStore,
|
|
160
167
|
migrate: () => unknown,
|
|
@@ -169,18 +176,19 @@ function handlers(
|
|
|
169
176
|
const context = eventContext(input);
|
|
170
177
|
return { ...context, source: context.source ?? source };
|
|
171
178
|
};
|
|
172
|
-
const
|
|
179
|
+
const artifactFilter = (input: OperationInput) => ({
|
|
173
180
|
status: optionalString(input, "status"),
|
|
174
181
|
text: optionalString(input, "text"),
|
|
175
182
|
limit: optionalNumber(input, "limit"),
|
|
183
|
+
});
|
|
184
|
+
const taskFilter = (input: OperationInput) => ({
|
|
185
|
+
...artifactFilter(input),
|
|
176
186
|
projectRoot: string(input, "project_root"),
|
|
177
187
|
scope: optionalString(input, "scope") as TaskViewMode | undefined,
|
|
178
188
|
rootTaskId: optionalString(input, "root_task_id"),
|
|
179
189
|
});
|
|
180
190
|
return {
|
|
181
191
|
"system.migrate": () => migrate(),
|
|
182
|
-
"automation.status": () => automation.status(),
|
|
183
|
-
"automation.reconcile": () => automation.reconcile(),
|
|
184
192
|
"artifact.create": (input) => {
|
|
185
193
|
const normalized = normalizeCreateInput(input);
|
|
186
194
|
if (normalized.kind !== "task") return artifacts.create(normalized);
|
|
@@ -246,6 +254,11 @@ function handlers(
|
|
|
246
254
|
projectRoot: string(input, "project_root"),
|
|
247
255
|
projectSource: "cwd",
|
|
248
256
|
}, eventContext(input)),
|
|
257
|
+
"tasks.update": (input) => tasks.update(string(input, "id"), {
|
|
258
|
+
...(input["title"] !== undefined ? { title: optionalString(input, "title")! } : {}),
|
|
259
|
+
...(input["body"] !== undefined ? { body: optionalString(input, "body")! } : {}),
|
|
260
|
+
...(input["labels"] !== undefined ? { labels: optionalStringArray(input, "labels")! } : {}),
|
|
261
|
+
}, eventContext(input)),
|
|
249
262
|
"tasks.list": (input) => tasks.list(taskFilter(input)),
|
|
250
263
|
"tasks.graph": (input) => tasks.graph(taskFilter(input)),
|
|
251
264
|
"tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
|
|
@@ -267,16 +280,16 @@ function handlers(
|
|
|
267
280
|
eventContext(input),
|
|
268
281
|
),
|
|
269
282
|
"tasks.active": (input) => tasks.active(taskFilter(input)),
|
|
270
|
-
"tasks.
|
|
283
|
+
"tasks.focused": (input) => tasks.focused(taskFilter(input)),
|
|
284
|
+
"tasks.focus": (input) => tasks.focus(string(input, "id"), eventContext(input)),
|
|
285
|
+
"tasks.pause": (input) => tasks.pauseFocus(eventContext(input)),
|
|
286
|
+
"tasks.unpause": (input) => tasks.unpauseFocus(eventContext(input)),
|
|
287
|
+
"tasks.clear_focus": (input) => tasks.clearFocus(eventContext(input)),
|
|
271
288
|
"tasks.start": (input) => tasks.transition(string(input, "id"), "start", eventContext(input)),
|
|
272
289
|
"tasks.submit": (input) => tasks.transition(string(input, "id"), "submit", eventContext(input)),
|
|
273
290
|
"tasks.complete": (input) => tasks.completeAsync(string(input, "id"), eventContext(input)),
|
|
274
291
|
"tasks.run_gates": (input) => tasks.runGates(string(input, "id"), eventContext(input)),
|
|
275
292
|
"tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
|
|
276
|
-
"tasks.set_automation": (input) => {
|
|
277
|
-
if (typeof input["enabled"] !== "boolean") throw new Error("enabled must be a boolean");
|
|
278
|
-
return tasks.setAutomation(string(input, "id"), input["enabled"], eventContext(input));
|
|
279
|
-
},
|
|
280
293
|
"tasks.context": (input) => taskContext(artifacts, tasks.active()?.id, new Set(tasks.list(taskFilter(input)).map((task) => task.id))),
|
|
281
294
|
"tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
|
|
282
295
|
"tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
|
|
@@ -288,7 +301,7 @@ function handlers(
|
|
|
288
301
|
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
289
302
|
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
290
303
|
}),
|
|
291
|
-
"docs.list": (input) => listDocuments(artifacts,
|
|
304
|
+
"docs.list": (input) => listDocuments(artifacts, artifactFilter(input)),
|
|
292
305
|
"docs.show": (input) => showDocument(artifacts, string(input, "id")),
|
|
293
306
|
"docs.activate": (input) => transitionDocument(artifacts, string(input, "id"), "activate"),
|
|
294
307
|
"docs.archive": (input) => transitionDocument(artifacts, string(input, "id"), "archive"),
|
|
@@ -300,7 +313,7 @@ function handlers(
|
|
|
300
313
|
severity: optionalString(input, "severity") as "block" | "warn" | "info" | undefined,
|
|
301
314
|
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
302
315
|
}),
|
|
303
|
-
"rules.list": (input) => listRules(artifacts,
|
|
316
|
+
"rules.list": (input) => listRules(artifacts, artifactFilter(input)),
|
|
304
317
|
"rules.show": (input) => showRule(artifacts, string(input, "id")),
|
|
305
318
|
"rules.preview": (input) => previewRule(artifacts, string(input, "id")),
|
|
306
319
|
"rules.enable": (input) => transitionRule(artifacts, string(input, "id"), "enable"),
|
|
@@ -316,7 +329,7 @@ function handlers(
|
|
|
316
329
|
title: string(input, "title"), targetKind: string(input, "target_kind"), defaults: input["defaults"] as Record<string, unknown> | undefined,
|
|
317
330
|
required: input["required"] as string[] | undefined, body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
|
|
318
331
|
}),
|
|
319
|
-
"skills.list": (input) => listSkills(artifacts,
|
|
332
|
+
"skills.list": (input) => listSkills(artifacts, artifactFilter(input)),
|
|
320
333
|
"skills.show": (input) => showSkill(artifacts, string(input, "id")),
|
|
321
334
|
"skills.invoke": (input) => skillInvocation(artifacts, string(input, "id")),
|
|
322
335
|
"skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
|
|
@@ -343,7 +356,7 @@ function handlers(
|
|
|
343
356
|
};
|
|
344
357
|
}
|
|
345
358
|
|
|
346
|
-
export function createPapyrusService(path: string
|
|
359
|
+
export function createPapyrusService(path: string): PapyrusService {
|
|
347
360
|
const db = openDb(path);
|
|
348
361
|
const artifacts = new SQLiteArtifactStore(db);
|
|
349
362
|
const gates = new SQLiteGateRunner(db);
|
|
@@ -351,8 +364,7 @@ export function createPapyrusService(path: string, options: { automation?: TaskA
|
|
|
351
364
|
const events = new SQLiteTaskEventStore(db);
|
|
352
365
|
const scopes = new SQLiteTaskScopeStore(db);
|
|
353
366
|
const tasks = new Tasks(artifacts, gates, focus, events, scopes);
|
|
354
|
-
const
|
|
355
|
-
const registry = handlers(artifacts, gates, tasks, automation, events, scopes, () => migrateDb(db));
|
|
367
|
+
const registry = handlers(artifacts, gates, tasks, events, scopes, () => migrateDb(db));
|
|
356
368
|
const state = (): SchemaState => {
|
|
357
369
|
const current = schemaVersion(db);
|
|
358
370
|
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|
|
@@ -363,8 +375,8 @@ export function createPapyrusService(path: string, options: { automation?: TaskA
|
|
|
363
375
|
async execute(operation, input = {}) {
|
|
364
376
|
const handler = registry[operation as OperationName];
|
|
365
377
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
366
|
-
if (operation !== "system.migrate" &&
|
|
367
|
-
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-
|
|
378
|
+
if (operation !== "system.migrate" && state().migrationRequired) {
|
|
379
|
+
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-focus`");
|
|
368
380
|
}
|
|
369
381
|
return handler(input);
|
|
370
382
|
},
|
package/src/task-service.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
TASK_BODY_MAX_LENGTH,
|
|
3
|
+
TASK_EXECUTION_MAX_DEGREE,
|
|
4
|
+
TASK_EXECUTION_MAX_EDGES,
|
|
5
|
+
TASK_EXECUTION_MAX_NODES,
|
|
6
|
+
TASK_LABEL_MAX_COUNT,
|
|
7
|
+
TASK_LABEL_MAX_LENGTH,
|
|
8
|
+
TASK_SCOPE_MAX_TASKS,
|
|
9
|
+
TASK_TITLE_MAX_LENGTH,
|
|
10
|
+
} from "./constants.ts";
|
|
2
11
|
import type { Artifact } from "./domain/artifact.ts";
|
|
3
12
|
import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
|
|
4
13
|
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
@@ -6,11 +15,17 @@ import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQue
|
|
|
6
15
|
import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
|
|
7
16
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
8
17
|
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
9
|
-
import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
|
|
18
|
+
import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "./ports/task-focus-store.ts";
|
|
10
19
|
import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
|
|
11
20
|
import { InMemoryTaskScopeStore, type TaskScopeStore } from "./ports/task-scope-store.ts";
|
|
12
21
|
import { assertDependencyEdgeAllowed } from "./task-execution.ts";
|
|
13
22
|
|
|
23
|
+
export interface UpdateTaskInput {
|
|
24
|
+
title?: string;
|
|
25
|
+
body?: string;
|
|
26
|
+
labels?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
export interface TaskFilter {
|
|
15
30
|
status?: string;
|
|
16
31
|
text?: string;
|
|
@@ -53,6 +68,13 @@ export interface ChecklistReview {
|
|
|
53
68
|
reason?: string;
|
|
54
69
|
}
|
|
55
70
|
|
|
71
|
+
export interface TaskFocus {
|
|
72
|
+
artifact: Artifact;
|
|
73
|
+
status: TaskFocusStatus;
|
|
74
|
+
updatedAt: string;
|
|
75
|
+
pauseReason?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
export interface TaskCompletionOptions {
|
|
57
79
|
focusSuccessor?: boolean;
|
|
58
80
|
gateDeadlineMs?: number;
|
|
@@ -70,6 +92,7 @@ export interface TaskCompletion {
|
|
|
70
92
|
export interface TaskNode {
|
|
71
93
|
task: Artifact;
|
|
72
94
|
active?: boolean;
|
|
95
|
+
focusStatus?: TaskFocusStatus;
|
|
73
96
|
parentIds: string[];
|
|
74
97
|
childIds: string[];
|
|
75
98
|
dependencyIds: string[];
|
|
@@ -138,6 +161,28 @@ export class Tasks {
|
|
|
138
161
|
});
|
|
139
162
|
}
|
|
140
163
|
|
|
164
|
+
update(id: string, input: UpdateTaskInput, context: TaskEventContext = {}): Artifact {
|
|
165
|
+
const fields = (["title", "body", "labels"] as const).filter((field) => input[field] !== undefined);
|
|
166
|
+
if (fields.length === 0) throw new Error("task update requires title, body, or labels");
|
|
167
|
+
if (input.title !== undefined && (input.title.trim().length === 0 || input.title.length > TASK_TITLE_MAX_LENGTH)) {
|
|
168
|
+
throw new Error(`title must be between 1 and ${TASK_TITLE_MAX_LENGTH} characters`);
|
|
169
|
+
}
|
|
170
|
+
if (input.body !== undefined && input.body.length > TASK_BODY_MAX_LENGTH) throw new Error(`body cannot exceed ${TASK_BODY_MAX_LENGTH} characters`);
|
|
171
|
+
if (input.labels !== undefined) {
|
|
172
|
+
if (input.labels.length > TASK_LABEL_MAX_COUNT) throw new Error(`labels cannot exceed ${TASK_LABEL_MAX_COUNT} entries`);
|
|
173
|
+
if (input.labels.some((label) => label.length === 0 || label.length > TASK_LABEL_MAX_LENGTH)) {
|
|
174
|
+
throw new Error(`each label must be between 1 and ${TASK_LABEL_MAX_LENGTH} characters`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return this.events.atomic(() => {
|
|
178
|
+
this.require(id);
|
|
179
|
+
const updated = this.artifacts.updateContent(id, input);
|
|
180
|
+
if (!updated) throw new Error(`task "${id}" not found`);
|
|
181
|
+
this.appendEvent({ taskId: id, type: "updated", evidence: { result: `fields:${fields.sort().join(",")}` } }, context);
|
|
182
|
+
return updated;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
141
186
|
list(filter: TaskFilter = {}): Artifact[] {
|
|
142
187
|
const selection = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
|
|
143
188
|
const limit = filter.limit ?? TASK_SCOPE_MAX_TASKS;
|
|
@@ -204,10 +249,12 @@ export class Tasks {
|
|
|
204
249
|
throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
|
|
205
250
|
}
|
|
206
251
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
207
|
-
const
|
|
252
|
+
const focus = this.focusStore.get();
|
|
253
|
+
const focusedId = focus?.taskId;
|
|
208
254
|
const nodes = new Map(tasks.map((task) => [task.id, {
|
|
209
255
|
task,
|
|
210
256
|
active: task.id === focusedId,
|
|
257
|
+
...(task.id === focusedId ? { focusStatus: focus!.status } : {}),
|
|
211
258
|
parentIds: [] as string[],
|
|
212
259
|
childIds: [] as string[],
|
|
213
260
|
dependencyIds: [] as string[],
|
|
@@ -247,25 +294,60 @@ export class Tasks {
|
|
|
247
294
|
return this.artifacts.get(id, { tree: true })!;
|
|
248
295
|
}
|
|
249
296
|
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
if (!
|
|
253
|
-
const task = this.artifacts.get(
|
|
297
|
+
focused(filter?: TaskFilter): TaskFocus | null {
|
|
298
|
+
const focus = this.focusStore.get();
|
|
299
|
+
if (!focus) return null;
|
|
300
|
+
const task = this.artifacts.get(focus.taskId);
|
|
254
301
|
if (!task || task.kind !== "task" || task.status === "done" || task.status === "canceled") {
|
|
255
|
-
this.focusStore.clear(
|
|
302
|
+
this.focusStore.clear(focus.taskId);
|
|
256
303
|
return null;
|
|
257
304
|
}
|
|
258
305
|
if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
|
|
259
|
-
return task;
|
|
306
|
+
return { artifact: task, status: focus.status, updatedAt: focus.updatedAt, ...(focus.pauseReason ? { pauseReason: focus.pauseReason } : {}) };
|
|
260
307
|
}
|
|
261
308
|
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
return
|
|
309
|
+
active(filter?: TaskFilter): Artifact | null {
|
|
310
|
+
const focus = this.focused(filter);
|
|
311
|
+
return focus?.status === "active" ? focus.artifact : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
focus(id: string, context: TaskEventContext = {}): Artifact {
|
|
315
|
+
return this.events.atomic(() => {
|
|
316
|
+
const task = this.require(id);
|
|
317
|
+
if (task.status === "done" || task.status === "canceled") throw new Error(`cannot focus task from ${task.status}`);
|
|
318
|
+
this.focusStore.set(id);
|
|
319
|
+
this.appendEvent({ taskId: id, type: "focus_set" }, context);
|
|
320
|
+
return task;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
pauseFocus(context: TaskEventContext = {}): TaskFocus {
|
|
325
|
+
return this.events.atomic(() => {
|
|
326
|
+
const focus = this.focused();
|
|
327
|
+
if (!focus) throw new Error("no focused task");
|
|
328
|
+
const state = this.focusStore.pause(focus.artifact.id, context.reason);
|
|
329
|
+
this.appendEvent({ taskId: focus.artifact.id, type: "focus_paused" }, context);
|
|
330
|
+
return { artifact: focus.artifact, status: state.status, updatedAt: state.updatedAt, ...(state.pauseReason ? { pauseReason: state.pauseReason } : {}) };
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
unpauseFocus(context: TaskEventContext = {}): TaskFocus {
|
|
335
|
+
return this.events.atomic(() => {
|
|
336
|
+
const focus = this.focused();
|
|
337
|
+
if (!focus) throw new Error("no focused task");
|
|
338
|
+
const state = this.focusStore.unpause(focus.artifact.id);
|
|
339
|
+
this.appendEvent({ taskId: focus.artifact.id, type: "focus_unpaused" }, context);
|
|
340
|
+
return { artifact: focus.artifact, status: state.status, updatedAt: state.updatedAt };
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
clearFocus(context: TaskEventContext = {}): { cleared: boolean } {
|
|
345
|
+
return this.events.atomic(() => {
|
|
346
|
+
const focus = this.focusStore.get();
|
|
347
|
+
if (focus) this.appendEvent({ taskId: focus.taskId, type: "focus_cleared" }, context);
|
|
348
|
+
this.focusStore.clear();
|
|
349
|
+
return { cleared: focus !== undefined };
|
|
350
|
+
});
|
|
269
351
|
}
|
|
270
352
|
|
|
271
353
|
transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
|
|
@@ -324,19 +406,6 @@ export class Tasks {
|
|
|
324
406
|
return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
|
|
325
407
|
}
|
|
326
408
|
|
|
327
|
-
setAutomation(id: string, enabled: boolean, context: TaskEventContext = {}): Artifact {
|
|
328
|
-
return this.events.atomic(() => {
|
|
329
|
-
const task = this.require(id);
|
|
330
|
-
const current = task.extra["automation"];
|
|
331
|
-
const automation = typeof current === "object" && current !== null && !Array.isArray(current)
|
|
332
|
-
? current as Record<string, unknown>
|
|
333
|
-
: {};
|
|
334
|
-
const updated = this.artifacts.setExtra(id, { ...task.extra, automation: { ...automation, enabled } })!;
|
|
335
|
-
this.appendEvent({ taskId: id, type: enabled ? "automation_enabled" : "automation_disabled" }, context);
|
|
336
|
-
return updated;
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
|
|
340
409
|
depend(id: string, dependencyId: string): Artifact {
|
|
341
410
|
this.require(id);
|
|
342
411
|
this.require(dependencyId);
|