@danypops/papyrus 0.5.0 → 0.7.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 -5
- package/extension/src/domain-tools.ts +23 -10
- package/extension/src/index.ts +19 -8
- package/extension/src/skills.ts +1 -0
- package/extension/src/task-widget.ts +2 -1
- package/extension/src/tasks.ts +56 -9
- package/package.json +1 -1
- package/src/adapters/sqlite-gate-runner.ts +3 -3
- package/src/adapters/sqlite-task-scope-store.ts +59 -0
- package/src/cli.ts +78 -11
- package/src/constants.ts +18 -1
- package/src/daemon.ts +20 -4
- package/src/db.ts +38 -1
- package/src/domain/gate.ts +5 -0
- package/src/domain/task-event.ts +3 -0
- package/src/domain/task-scope.ts +39 -0
- package/src/log.ts +6 -0
- package/src/ops.ts +18 -7
- package/src/ports/gate-runner.ts +2 -2
- package/src/ports/task-scope-store.ts +40 -0
- package/src/service.ts +62 -10
- package/src/skill-execution.ts +16 -10
- package/src/task-automation.ts +188 -0
- package/src/task-context.ts +4 -2
- package/src/task-service.ts +128 -11
package/src/cli.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { serveMain } from "./daemon.ts";
|
|
|
10
10
|
import type { GateResult } from "./domain/gate.ts";
|
|
11
11
|
import type { TaskExecutionPlan } from "./task-execution.ts";
|
|
12
12
|
import type { TaskBlockage, TaskCompletion } from "./task-service.ts";
|
|
13
|
+
import type { TaskAutomationResult, TaskAutomationSettings } from "./task-automation.ts";
|
|
13
14
|
|
|
14
15
|
export interface SystemdUnitOptions {
|
|
15
16
|
bunBin: string;
|
|
@@ -56,12 +57,15 @@ function installService(): void {
|
|
|
56
57
|
const USAGE = `Usage:
|
|
57
58
|
papyrus serve
|
|
58
59
|
papyrus service <install|start|stop|restart|status>
|
|
59
|
-
papyrus migrate task-
|
|
60
|
+
papyrus migrate task-scope [--json]
|
|
61
|
+
papyrus automation <status|run> [--json]
|
|
60
62
|
papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
|
|
61
63
|
papyrus tasks plan [--json]
|
|
62
64
|
papyrus tasks graph [--json]
|
|
63
65
|
papyrus tasks active [--json]
|
|
64
66
|
papyrus tasks history <id> [--json]
|
|
67
|
+
papyrus tasks scope [project|all|graph <root-id>] [--json]
|
|
68
|
+
papyrus tasks assign-project <id> [project-root] [--json]
|
|
65
69
|
papyrus tasks focus <id> [--json]
|
|
66
70
|
papyrus tasks complete <id> [--json]
|
|
67
71
|
papyrus tasks start <id> [--json]
|
|
@@ -69,6 +73,7 @@ const USAGE = `Usage:
|
|
|
69
73
|
papyrus tasks reject <id> [--json]
|
|
70
74
|
papyrus tasks retry <id> [--json]
|
|
71
75
|
papyrus tasks cancel <id> [--json]
|
|
76
|
+
papyrus tasks automate <id> <on|off> [--json]
|
|
72
77
|
papyrus tasks depend <id> <prerequisite-id> [--json]`;
|
|
73
78
|
|
|
74
79
|
function usage(): never {
|
|
@@ -107,8 +112,8 @@ function planText(plan: TaskExecutionPlan): string {
|
|
|
107
112
|
export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
108
113
|
const json = args.includes("--json");
|
|
109
114
|
const positional = args.filter((arg) => arg !== "--json");
|
|
110
|
-
if (positional.length !== 1 || positional[0] !== "task-
|
|
111
|
-
throw new Error("migrate requires exactly `task-
|
|
115
|
+
if (positional.length !== 1 || positional[0] !== "task-scope") {
|
|
116
|
+
throw new Error("migrate requires exactly `task-scope`");
|
|
112
117
|
}
|
|
113
118
|
const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
|
|
114
119
|
if (json) return JSON.stringify(result);
|
|
@@ -116,7 +121,21 @@ export async function runMigrationCli(args: string[], client: TaskCliClient): Pr
|
|
|
116
121
|
return `Migrated schema ${result.from} → ${result.to}: ${result.applied.join(", ")}`;
|
|
117
122
|
}
|
|
118
123
|
|
|
119
|
-
export async function
|
|
124
|
+
export async function runAutomationCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
125
|
+
const json = args.includes("--json");
|
|
126
|
+
const positional = args.filter((argument) => argument !== "--json");
|
|
127
|
+
if (positional.length !== 1 || (positional[0] !== "status" && positional[0] !== "run")) {
|
|
128
|
+
throw new Error("automation requires exactly `status` or `run`");
|
|
129
|
+
}
|
|
130
|
+
if (positional[0] === "status") {
|
|
131
|
+
const status = await client.call<Record<string, never>, TaskAutomationSettings & { inFlight: boolean }>("automation.status", {});
|
|
132
|
+
return json ? JSON.stringify(status) : `Automation: ${status.enabled ? "enabled" : "disabled"} · interval ${status.intervalMs}ms · max ${status.maxTasksPerSweep} tasks · concurrency ${status.gateConcurrency}`;
|
|
133
|
+
}
|
|
134
|
+
const result = await client.call<Record<string, never>, TaskAutomationResult>("automation.reconcile", {});
|
|
135
|
+
return json ? JSON.stringify(result) : `Automation sweep: ${result.examined} examined · ${result.completed} completed · ${result.rejected} rejected · ${result.started} started · ${result.errors.length} errors${result.skipped ? ` · skipped ${result.skipped}` : ""}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runSkillCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
|
|
120
139
|
const json = args.includes("--json");
|
|
121
140
|
const positional: string[] = [];
|
|
122
141
|
let runId: string | undefined;
|
|
@@ -143,7 +162,7 @@ export async function runSkillCli(args: string[], client: TaskCliClient): Promis
|
|
|
143
162
|
positional.push(argument);
|
|
144
163
|
}
|
|
145
164
|
if (positional.length !== 2 || positional[0] !== "run") throw new Error("skills requires `run <id>`");
|
|
146
|
-
const input: Record<string, unknown> = { id: positional[1], arguments: arguments_ };
|
|
165
|
+
const input: Record<string, unknown> = { id: positional[1], arguments: arguments_, project_root: projectRoot };
|
|
147
166
|
if (runId) input["run_id"] = runId;
|
|
148
167
|
const result = await client.call<Record<string, unknown>, {
|
|
149
168
|
runId: string;
|
|
@@ -161,7 +180,7 @@ export async function runSkillCli(args: string[], client: TaskCliClient): Promis
|
|
|
161
180
|
].join("\n");
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
export async function runTaskCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
183
|
+
export async function runTaskCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
|
|
165
184
|
const json = args.includes("--json");
|
|
166
185
|
const positional = args.filter((arg) => arg !== "--json");
|
|
167
186
|
const [action, id, dependencyId] = positional;
|
|
@@ -170,7 +189,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
170
189
|
switch (action) {
|
|
171
190
|
case "active": {
|
|
172
191
|
if (id) throw new Error("tasks active accepts no positional arguments");
|
|
173
|
-
const active = await client.call<Record<string,
|
|
192
|
+
const active = await client.call<Record<string, string>, CliArtifact | null>("tasks.active", { project_root: projectRoot });
|
|
174
193
|
result = active;
|
|
175
194
|
human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
|
|
176
195
|
break;
|
|
@@ -184,6 +203,37 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
184
203
|
: [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
|
|
185
204
|
break;
|
|
186
205
|
}
|
|
206
|
+
case "scope": {
|
|
207
|
+
if (!id) {
|
|
208
|
+
const selection = await client.call<Record<string, string>, import("./domain/task-scope.ts").TaskViewSelection>("tasks.scope", { project_root: projectRoot });
|
|
209
|
+
result = selection;
|
|
210
|
+
human = `Task scope: ${selection.label}`;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (id !== "project" && id !== "all" && id !== "graph") throw new Error("tasks scope mode must be project, all, or graph");
|
|
214
|
+
if (id === "graph" && !dependencyId) throw new Error("tasks scope graph requires a root task id");
|
|
215
|
+
if (id !== "graph" && dependencyId) throw new Error(`tasks scope ${id} accepts no root task id`);
|
|
216
|
+
const selection = await client.call<Record<string, unknown>, import("./domain/task-scope.ts").TaskViewSelection>("tasks.set_scope", {
|
|
217
|
+
project_root: projectRoot,
|
|
218
|
+
scope: id,
|
|
219
|
+
...(dependencyId ? { root_task_id: dependencyId } : {}),
|
|
220
|
+
});
|
|
221
|
+
result = selection;
|
|
222
|
+
human = `Task scope: ${selection.label}`;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "assign-project": {
|
|
226
|
+
if (!id || positional.length > 3) throw new Error("tasks assign-project requires a task id and optional project root");
|
|
227
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.assign_project", {
|
|
228
|
+
id,
|
|
229
|
+
project_root: dependencyId ?? projectRoot,
|
|
230
|
+
actor: "user",
|
|
231
|
+
source: "cli",
|
|
232
|
+
});
|
|
233
|
+
result = artifact;
|
|
234
|
+
human = `Project assigned: ${artifactLabel(artifact)}`;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
187
237
|
case "focus": {
|
|
188
238
|
if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
|
|
189
239
|
const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
|
|
@@ -193,10 +243,10 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
193
243
|
}
|
|
194
244
|
case "graph": {
|
|
195
245
|
if (id) throw new Error("tasks graph accepts no positional arguments");
|
|
196
|
-
const graph = await client.call<{ limit: number }, {
|
|
246
|
+
const graph = await client.call<{ limit: number; project_root: string }, {
|
|
197
247
|
nodes: Array<{ dependencyIds: string[]; childIds: string[] }>;
|
|
198
248
|
rootIds: string[];
|
|
199
|
-
}>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1 });
|
|
249
|
+
}>("tasks.graph", { limit: TASK_EXECUTION_MAX_NODES + 1, project_root: projectRoot });
|
|
200
250
|
result = graph;
|
|
201
251
|
const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
|
|
202
252
|
const children = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
|
|
@@ -205,7 +255,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
205
255
|
}
|
|
206
256
|
case "plan": {
|
|
207
257
|
if (id) throw new Error("tasks plan accepts no positional arguments");
|
|
208
|
-
const plan = await client.call<Record<string,
|
|
258
|
+
const plan = await client.call<Record<string, string>, TaskExecutionPlan>("tasks.plan", { project_root: projectRoot });
|
|
209
259
|
result = plan;
|
|
210
260
|
human = planText(plan);
|
|
211
261
|
break;
|
|
@@ -241,6 +291,18 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
241
291
|
human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
|
|
242
292
|
break;
|
|
243
293
|
}
|
|
294
|
+
case "automate": {
|
|
295
|
+
if (!id || (dependencyId !== "on" && dependencyId !== "off") || positional.length !== 3) throw new Error("tasks automate requires a task id and on or off");
|
|
296
|
+
const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.set_automation", {
|
|
297
|
+
id,
|
|
298
|
+
enabled: dependencyId === "on",
|
|
299
|
+
actor: "user",
|
|
300
|
+
source: "cli",
|
|
301
|
+
});
|
|
302
|
+
result = artifact;
|
|
303
|
+
human = `Automation ${dependencyId}: ${artifactLabel(artifact)}`;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
244
306
|
case "depend": {
|
|
245
307
|
if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
|
|
246
308
|
const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
|
|
@@ -252,7 +314,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
252
314
|
break;
|
|
253
315
|
}
|
|
254
316
|
default:
|
|
255
|
-
throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, or depend");
|
|
317
|
+
throw new Error("tasks action must be active, focus, graph, plan, history, scope, assign-project, complete, start, submit, reject, retry, cancel, automate, or depend");
|
|
256
318
|
}
|
|
257
319
|
return json ? JSON.stringify(result) : human;
|
|
258
320
|
}
|
|
@@ -265,6 +327,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
265
327
|
console.log(await runTaskCli(args.slice(1), client));
|
|
266
328
|
return;
|
|
267
329
|
}
|
|
330
|
+
if (command === "automation") {
|
|
331
|
+
const client = await connectPapyrusClient();
|
|
332
|
+
console.log(await runAutomationCli(args.slice(1), client));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
268
335
|
if (command === "skills") {
|
|
269
336
|
const client = await connectPapyrusClient();
|
|
270
337
|
console.log(await runSkillCli(args.slice(1), client));
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 4;
|
|
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;
|
|
@@ -15,6 +15,7 @@ export const GATE_COMMAND_TIMEOUT_MS = 30_000;
|
|
|
15
15
|
export const GATE_TEST_TIMEOUT_MS = 60_000;
|
|
16
16
|
export const GATE_OUTPUT_LIMIT = 200;
|
|
17
17
|
export const GATE_MAX_BUFFER_BYTES = 1_048_576;
|
|
18
|
+
export const GATE_FILE_MAX_BYTES = 1_048_576;
|
|
18
19
|
|
|
19
20
|
/** Compact task-context limits keep recurring prompt injection bounded. */
|
|
20
21
|
export const TASK_CONTEXT_CURRENT_LIMIT = 3;
|
|
@@ -48,6 +49,22 @@ export const TASK_HISTORY_MAX_LIMIT = 100;
|
|
|
48
49
|
export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
|
|
49
50
|
export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
50
51
|
export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
|
|
52
|
+
/** Explicitly opt-in supervised Task graph reconciliation bounds. */
|
|
53
|
+
export const TASK_AUTOMATION_INTERVAL_MS = 60_000;
|
|
54
|
+
export const TASK_AUTOMATION_MIN_INTERVAL_MS = 10_000;
|
|
55
|
+
export const TASK_AUTOMATION_MAX_INTERVAL_MS = 3_600_000;
|
|
56
|
+
export const TASK_AUTOMATION_MAX_TASKS_PER_SWEEP = 10;
|
|
57
|
+
export const TASK_AUTOMATION_HARD_MAX_TASKS_PER_SWEEP = 100;
|
|
58
|
+
export const TASK_AUTOMATION_GATE_CONCURRENCY = 1;
|
|
59
|
+
export const TASK_AUTOMATION_MAX_GATE_CONCURRENCY = 4;
|
|
60
|
+
export const TASK_AUTOMATION_MAX_RUNTIME_MS = 120_000;
|
|
61
|
+
export const TASK_AUTOMATION_HARD_MAX_RUNTIME_MS = 600_000;
|
|
62
|
+
export const TASK_AUTOMATION_MAX_CANDIDATE_SCAN = 1_000;
|
|
63
|
+
export const TASK_AUTOMATION_ERROR_ID_MAX_LENGTH = 128;
|
|
64
|
+
export const TASK_AUTOMATION_ERROR_MESSAGE_MAX_LENGTH = 500;
|
|
65
|
+
/** Persisted project and focused-graph Task view bounds. */
|
|
66
|
+
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
67
|
+
export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
|
|
51
68
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
52
69
|
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
53
70
|
export const GRAPH_RENDER_BOX_PADDING = 0;
|
package/src/daemon.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
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
|
+
import { logEvent } from "./log.ts";
|
|
4
6
|
|
|
5
7
|
/** Start the supervised, long-running Papyrus service. */
|
|
6
8
|
export function serveMain(): void {
|
|
7
9
|
const stateDir = daemonStateDir();
|
|
8
10
|
const token = loadOrCreateToken(stateDir);
|
|
9
|
-
const
|
|
11
|
+
const automation = taskAutomationSettings(process.env);
|
|
12
|
+
const service = createPapyrusService(dbPath(), { automation });
|
|
10
13
|
const app = createApp({ service, token });
|
|
11
14
|
const server = Bun.serve({
|
|
12
15
|
hostname: DAEMON_HOST,
|
|
@@ -19,11 +22,23 @@ export function serveMain(): void {
|
|
|
19
22
|
}
|
|
20
23
|
writeDaemonPort(stateDir, server.port);
|
|
21
24
|
const checkpointTimer = setInterval(() => {
|
|
22
|
-
try { service.checkpoint(); } catch (error) {
|
|
25
|
+
try { service.checkpoint(); } catch (error) { logEvent("error", "checkpoint_failed", { message: error instanceof Error ? error.message : String(error) }); }
|
|
23
26
|
}, WAL_CHECKPOINT_INTERVAL_MS);
|
|
24
27
|
const optimizeTimer = setInterval(() => {
|
|
25
|
-
try { service.optimize(); } catch (error) {
|
|
28
|
+
try { service.optimize(); } catch (error) { logEvent("error", "optimize_failed", { message: error instanceof Error ? error.message : String(error) }); }
|
|
26
29
|
}, 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) }));
|
|
27
42
|
|
|
28
43
|
let stopping = false;
|
|
29
44
|
const shutdown = () => {
|
|
@@ -31,11 +46,12 @@ export function serveMain(): void {
|
|
|
31
46
|
stopping = true;
|
|
32
47
|
clearInterval(checkpointTimer);
|
|
33
48
|
clearInterval(optimizeTimer);
|
|
49
|
+
stopAutomation();
|
|
34
50
|
clearDaemonPort(stateDir);
|
|
35
51
|
service.close();
|
|
36
52
|
void server.stop(true).finally(() => process.exit(0));
|
|
37
53
|
};
|
|
38
54
|
process.on("SIGINT", shutdown);
|
|
39
55
|
process.on("SIGTERM", shutdown);
|
|
40
|
-
|
|
56
|
+
logEvent("info", "listening", { host: DAEMON_HOST, port: server.port, automationEnabled: automation.enabled });
|
|
41
57
|
}
|
package/src/db.ts
CHANGED
|
@@ -122,6 +122,20 @@ CREATE TRIGGER IF NOT EXISTS task_events_no_update BEFORE UPDATE ON task_events
|
|
|
122
122
|
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
123
123
|
CREATE TRIGGER IF NOT EXISTS task_events_no_delete BEFORE DELETE ON task_events
|
|
124
124
|
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
125
|
+
CREATE TABLE IF NOT EXISTS task_scopes (
|
|
126
|
+
task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
127
|
+
project_root TEXT,
|
|
128
|
+
source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
|
|
129
|
+
assigned_at TEXT NOT NULL
|
|
130
|
+
);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS task_scopes_project_idx ON task_scopes(project_root, task_id);
|
|
132
|
+
CREATE TABLE IF NOT EXISTS task_views (
|
|
133
|
+
project_root TEXT PRIMARY KEY,
|
|
134
|
+
mode TEXT NOT NULL CHECK (mode IN ('project', 'graph', 'all')),
|
|
135
|
+
root_task_id TEXT REFERENCES artifacts(id),
|
|
136
|
+
updated_at TEXT NOT NULL,
|
|
137
|
+
CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
|
|
138
|
+
);
|
|
125
139
|
`;
|
|
126
140
|
|
|
127
141
|
const SEED_SQL = `
|
|
@@ -185,7 +199,7 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
185
199
|
throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
186
200
|
}
|
|
187
201
|
if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
|
|
188
|
-
if (from !== 1 && from !== 2) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
202
|
+
if (from !== 1 && from !== 2 && from !== 3) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
189
203
|
const applied: string[] = [];
|
|
190
204
|
|
|
191
205
|
inTransaction(db, () => {
|
|
@@ -243,6 +257,29 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
243
257
|
`);
|
|
244
258
|
applied.push("task-history");
|
|
245
259
|
}
|
|
260
|
+
if (schemaVersion(db) === 3) {
|
|
261
|
+
db.exec(`
|
|
262
|
+
CREATE TABLE task_scopes (
|
|
263
|
+
task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
264
|
+
project_root TEXT,
|
|
265
|
+
source TEXT NOT NULL CHECK (source IN ('cwd', 'explicit', 'unscoped')),
|
|
266
|
+
assigned_at TEXT NOT NULL
|
|
267
|
+
);
|
|
268
|
+
CREATE INDEX task_scopes_project_idx ON task_scopes(project_root, task_id);
|
|
269
|
+
CREATE TABLE task_views (
|
|
270
|
+
project_root TEXT PRIMARY KEY,
|
|
271
|
+
mode TEXT NOT NULL CHECK (mode IN ('project', 'graph', 'all')),
|
|
272
|
+
root_task_id TEXT REFERENCES artifacts(id),
|
|
273
|
+
updated_at TEXT NOT NULL,
|
|
274
|
+
CHECK ((mode = 'graph' AND root_task_id IS NOT NULL) OR (mode != 'graph' AND root_task_id IS NULL))
|
|
275
|
+
);
|
|
276
|
+
INSERT INTO task_scopes (task_id, project_root, source, assigned_at)
|
|
277
|
+
SELECT id, NULL, 'unscoped', strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
278
|
+
FROM artifacts WHERE kind = 'task';
|
|
279
|
+
PRAGMA user_version = 4;
|
|
280
|
+
`);
|
|
281
|
+
applied.push("task-project-scope");
|
|
282
|
+
}
|
|
246
283
|
});
|
|
247
284
|
return { from, to: schemaVersion(db), applied };
|
|
248
285
|
}
|
package/src/domain/gate.ts
CHANGED
package/src/domain/task-event.ts
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { basename, isAbsolute, normalize } from "node:path";
|
|
2
|
+
import { TASK_PROJECT_ROOT_MAX_LENGTH } from "../constants.ts";
|
|
3
|
+
|
|
4
|
+
export type TaskViewMode = "project" | "graph" | "all";
|
|
5
|
+
export type TaskScopeSource = "cwd" | "explicit" | "unscoped";
|
|
6
|
+
|
|
7
|
+
export interface TaskProjectScope {
|
|
8
|
+
taskId: string;
|
|
9
|
+
projectRoot?: string;
|
|
10
|
+
source: TaskScopeSource;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TaskViewPreference {
|
|
14
|
+
projectRoot: string;
|
|
15
|
+
mode: TaskViewMode;
|
|
16
|
+
rootTaskId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TaskViewSelection {
|
|
20
|
+
mode: TaskViewMode;
|
|
21
|
+
label: string;
|
|
22
|
+
projectRoot?: string;
|
|
23
|
+
rootTaskId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeProjectRoot(value: string): string {
|
|
27
|
+
if (!isAbsolute(value)) throw new Error("project_root must be an absolute path");
|
|
28
|
+
const normalized = normalize(value);
|
|
29
|
+
if (normalized.length > TASK_PROJECT_ROOT_MAX_LENGTH) {
|
|
30
|
+
throw new Error(`project_root cannot exceed ${TASK_PROJECT_ROOT_MAX_LENGTH} characters`);
|
|
31
|
+
}
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function taskScopeLabel(mode: TaskViewMode, projectRoot?: string, rootTitle?: string): string {
|
|
36
|
+
if (mode === "all") return "All projects";
|
|
37
|
+
const project = projectRoot ? basename(projectRoot) || projectRoot : "Unscoped";
|
|
38
|
+
return mode === "graph" ? `${project} · ${rootTitle ?? "focused graph"}` : project;
|
|
39
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type LogLevel = "info" | "warn" | "error";
|
|
2
|
+
|
|
3
|
+
/** Credential-safe structured daemon event. Callers must pass bounded, non-sensitive fields. */
|
|
4
|
+
export function logEvent(level: LogLevel, event: string, fields: Record<string, unknown> = {}): void {
|
|
5
|
+
console.error(JSON.stringify({ timestamp: new Date().toISOString(), level, component: "papyrus-daemon", event, ...fields }));
|
|
6
|
+
}
|
package/src/ops.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { exec } from "node:child_process";
|
|
|
7
7
|
import type { Db } from "./db.ts";
|
|
8
8
|
import { inTransaction } from "./db.ts";
|
|
9
9
|
import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
|
|
10
|
-
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
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";
|
|
13
13
|
export type CreateInput = CreateArtifactInput;
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
GATE_TEST_TIMEOUT_MS,
|
|
21
21
|
GATE_OUTPUT_LIMIT,
|
|
22
22
|
GATE_MAX_BUFFER_BYTES,
|
|
23
|
+
GATE_FILE_MAX_BYTES,
|
|
23
24
|
} from "./constants.ts";
|
|
24
25
|
|
|
25
26
|
const require_ = createRequire(import.meta.url);
|
|
@@ -234,6 +235,12 @@ export function injectableRules(db: Db): Array<{ id: string; title: string; body
|
|
|
234
235
|
});
|
|
235
236
|
}
|
|
236
237
|
|
|
238
|
+
function readBoundedGateFile(path: string): string {
|
|
239
|
+
const { readFileSync, statSync } = require_("node:fs");
|
|
240
|
+
if (statSync(path).size > GATE_FILE_MAX_BYTES) throw new Error(`file exceeds ${GATE_FILE_MAX_BYTES} bytes`);
|
|
241
|
+
return readFileSync(path, "utf-8") as string;
|
|
242
|
+
}
|
|
243
|
+
|
|
237
244
|
export function runGates(db: Db, artifactId: string): GateResult[] {
|
|
238
245
|
const art = getArtifact(db, artifactId);
|
|
239
246
|
if (!art) throw new Error("artifact not found");
|
|
@@ -246,9 +253,8 @@ export function runGates(db: Db, artifactId: string): GateResult[] {
|
|
|
246
253
|
return { gate, passed: exists, output: exists ? "exists" : "not found" };
|
|
247
254
|
}
|
|
248
255
|
case "contains": {
|
|
249
|
-
const { readFileSync } = require_("node:fs");
|
|
250
256
|
try {
|
|
251
|
-
const content =
|
|
257
|
+
const content = readBoundedGateFile(gate.target);
|
|
252
258
|
const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
|
|
253
259
|
return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
|
|
254
260
|
} catch {
|
|
@@ -299,9 +305,8 @@ function runNonProcessGate(gate: Gate): GateResult {
|
|
|
299
305
|
return { gate, passed: exists, output: exists ? "exists" : "not found" };
|
|
300
306
|
}
|
|
301
307
|
if (gate.type === "contains") {
|
|
302
|
-
const { readFileSync } = require_("node:fs");
|
|
303
308
|
try {
|
|
304
|
-
const content =
|
|
309
|
+
const content = readBoundedGateFile(gate.target);
|
|
305
310
|
const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
|
|
306
311
|
return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
|
|
307
312
|
} catch {
|
|
@@ -312,15 +317,21 @@ function runNonProcessGate(gate: Gate): GateResult {
|
|
|
312
317
|
}
|
|
313
318
|
|
|
314
319
|
/** Gate runner for daemon request paths; subprocess gates never block the event loop. */
|
|
315
|
-
export async function runGatesAsync(db: Db, artifactId: string): Promise<GateResult[]> {
|
|
320
|
+
export async function runGatesAsync(db: Db, artifactId: string, options: GateRunOptions = {}): Promise<GateResult[]> {
|
|
316
321
|
const art = getArtifact(db, artifactId);
|
|
317
322
|
if (!art) throw new Error("artifact not found");
|
|
318
323
|
const gates = (art.extra["gates"] as Gate[]) ?? [];
|
|
319
324
|
const results: GateResult[] = [];
|
|
320
325
|
for (const gate of gates) {
|
|
326
|
+
const remainingMs = options.deadlineMs === undefined ? undefined : options.deadlineMs - Date.now();
|
|
327
|
+
if (remainingMs !== undefined && remainingMs <= 0) {
|
|
328
|
+
results.push({ gate, passed: false, output: "gate runtime deadline exceeded" });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
321
331
|
if (gate.type === "command" || gate.type === "test") {
|
|
322
332
|
const command = gate.type === "test" ? `npx vitest run ${gate.target} --reporter=dot` : gate.target;
|
|
323
|
-
const
|
|
333
|
+
const configuredTimeout = gate.type === "test" ? GATE_TEST_TIMEOUT_MS : GATE_COMMAND_TIMEOUT_MS;
|
|
334
|
+
const timeout = remainingMs === undefined ? configuredTimeout : Math.max(1, Math.min(configuredTimeout, remainingMs));
|
|
324
335
|
const executed = await executeGateCommand(command, timeout);
|
|
325
336
|
results.push({
|
|
326
337
|
gate,
|
package/src/ports/gate-runner.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { GateResult } from "../domain/gate.ts";
|
|
1
|
+
import type { GateResult, GateRunOptions } from "../domain/gate.ts";
|
|
2
2
|
|
|
3
3
|
export interface GateRunner {
|
|
4
4
|
run(artifactId: string): GateResult[];
|
|
5
|
-
runAsync(artifactId: string): Promise<GateResult[]>;
|
|
5
|
+
runAsync(artifactId: string, options?: GateRunOptions): Promise<GateResult[]>;
|
|
6
6
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { TaskProjectScope, TaskScopeSource, TaskViewMode, TaskViewPreference } from "../domain/task-scope.ts";
|
|
2
|
+
|
|
3
|
+
export interface TaskScopeStore {
|
|
4
|
+
assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope;
|
|
5
|
+
get(taskId: string): TaskProjectScope | undefined;
|
|
6
|
+
taskIds(projectRoot: string | undefined, limit: number): string[];
|
|
7
|
+
view(projectRoot: string): TaskViewPreference;
|
|
8
|
+
setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewPreference;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class InMemoryTaskScopeStore implements TaskScopeStore {
|
|
12
|
+
private readonly scopes = new Map<string, TaskProjectScope>();
|
|
13
|
+
private readonly views = new Map<string, TaskViewPreference>();
|
|
14
|
+
|
|
15
|
+
assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
|
|
16
|
+
const scope = { taskId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
|
|
17
|
+
this.scopes.set(taskId, scope);
|
|
18
|
+
return scope;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(taskId: string): TaskProjectScope | undefined { return this.scopes.get(taskId); }
|
|
22
|
+
|
|
23
|
+
taskIds(projectRoot: string | undefined, limit: number): string[] {
|
|
24
|
+
return [...this.scopes.values()]
|
|
25
|
+
.filter((scope) => scope.projectRoot === projectRoot)
|
|
26
|
+
.map((scope) => scope.taskId)
|
|
27
|
+
.sort()
|
|
28
|
+
.slice(0, limit);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
view(projectRoot: string): TaskViewPreference {
|
|
32
|
+
return this.views.get(projectRoot) ?? { projectRoot, mode: "project" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewPreference {
|
|
36
|
+
const view = { projectRoot, mode, ...(rootTaskId === undefined ? {} : { rootTaskId }) };
|
|
37
|
+
this.views.set(projectRoot, view);
|
|
38
|
+
return view;
|
|
39
|
+
}
|
|
40
|
+
}
|