@danypops/papyrus 0.31.0 → 0.32.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/extension/src/domain-tools.ts +24 -1
- package/package.json +1 -1
- package/src/adapters/sqlite-task-event-store.ts +21 -0
- package/src/adapters/sqlite-task-lease-store.ts +98 -0
- package/src/cli.ts +76 -0
- package/src/constants.ts +10 -1
- package/src/db.ts +31 -0
- package/src/domain/task-event.ts +36 -0
- package/src/domain/task-lease.ts +42 -0
- package/src/domain-services.ts +7 -7
- package/src/modules/tasks.ts +12 -1
- package/src/ports/task-event-store.ts +23 -1
- package/src/ports/task-lease-store.ts +82 -0
- package/src/service.ts +9 -1
- package/src/task-service.ts +46 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
4
|
+
import type { TaskLease } from "../../src/domain/task-lease.ts";
|
|
4
5
|
import { PROOF_TYPES } from "../../src/domain/checklist.ts";
|
|
5
6
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
6
7
|
import type { TaskExecutionPlan } from "../../src/task-execution.ts";
|
|
@@ -237,7 +238,7 @@ export function registerTasksTool(pi: ExtensionAPI): void {
|
|
|
237
238
|
pi.registerTool({
|
|
238
239
|
name: "tasks",
|
|
239
240
|
label: "Tasks",
|
|
240
|
-
description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). PREFER addressing a task by `name` (its exact title) over `id` for every action -- id is a backend implementation detail, resolved from name automatically, and only needs to appear explicitly when a name is genuinely ambiguous (two tasks share a title; the error will say so and list the real ids to disambiguate with). Task results likewise show name and status, not id, unless two shown tasks share a title. `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Prefer this over low-level papyrus_* tools for task work.",
|
|
241
|
+
description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, undepend, contain, uncontain, remove, restore, claim, heartbeat_lease, release_lease, lease, event_feed. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. update can recover a Task accidentally created terminal by setting status=todo with a reason, but cannot rewrite legitimate lifecycle history. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. undepend/uncontain are idempotent for an already-absent relationship and never start, complete, or focus work merely because an edge disappeared; uncontain removes both contains and part_of edges atomically. remove moves a Task to a time-gated trash (restorable via restore until the purge deadline; refuses if it is the live Task Focus). claim/heartbeat_lease/release_lease/lease manage a bounded work-reservation lease -- independent of both lifecycle status and Focus, so multiple sessions can Focus the same task while only one owner holds its lease at a time; claim throws if a DIFFERENT owner already holds a live lease, release/heartbeat require the exact token claim returned. `owner` defaults to this session's own id when omitted. PREFER addressing a task by `name` (its exact title) over `id` for every action -- id is a backend implementation detail, resolved from name automatically, and only needs to appear explicitly when a name is genuinely ambiguous (two tasks share a title; the error will say so and list the real ids to disambiguate with). Task results likewise show name and status, not id, unless two shown tasks share a title. `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` are the name-based equivalents of `dependency_id`/`parent_id`/`child_id`/`root_task_id`/`depends_on`. Prefer this over low-level papyrus_* tools for task work.",
|
|
241
242
|
parameters: Type.Object({
|
|
242
243
|
action: Type.String(),
|
|
243
244
|
id: Type.Optional(Type.String()),
|
|
@@ -268,6 +269,11 @@ export function registerTasksTool(pi: ExtensionAPI): void {
|
|
|
268
269
|
scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
|
|
269
270
|
root_task_id: Type.Optional(Type.String()),
|
|
270
271
|
root_task_name: Type.Optional(Type.String()),
|
|
272
|
+
owner: Type.Optional(Type.String()),
|
|
273
|
+
token: Type.Optional(Type.String()),
|
|
274
|
+
ttl_ms: Type.Optional(Type.Number()),
|
|
275
|
+
note: Type.Optional(Type.String()),
|
|
276
|
+
event_types: Type.Optional(Type.Array(Type.String())),
|
|
271
277
|
}),
|
|
272
278
|
renderCall(args, theme) { return renderPapyrusToolCall("Tasks", args, theme); },
|
|
273
279
|
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
@@ -413,6 +419,23 @@ export function registerTasksTool(pi: ExtensionAPI): void {
|
|
|
413
419
|
}))),
|
|
414
420
|
);
|
|
415
421
|
}
|
|
422
|
+
if (action === "event_feed") {
|
|
423
|
+
const page = await callService<Record<string, unknown>, { events: Array<{ id: number; occurredAt: string; taskId: string; type: string }>; nextCursor?: number }>("tasks.event_feed", { cursor: params.cursor, limit: params.limit, event_types: params.event_types });
|
|
424
|
+
const output = page.events.length === 0 ? "No events." : page.events.map((event) => `${event.id} ${event.occurredAt} ${event.taskId} ${event.type}`).join("\n");
|
|
425
|
+
return text(page.nextCursor !== undefined ? `${output}\n\n(more available -- resume with cursor: ${page.nextCursor})` : output, createPreviewDetails("tasks.event_feed", "Task event feed", output));
|
|
426
|
+
}
|
|
427
|
+
if (action === "claim" || action === "heartbeat_lease" || action === "release_lease" || action === "lease") {
|
|
428
|
+
const leaseRequest = { ...request, owner: (params.owner as string | undefined) ?? resolvedSessionId };
|
|
429
|
+
if (action === "release_lease") {
|
|
430
|
+
const released = await callService<Record<string, unknown>, { released: boolean }>("tasks.release_lease", leaseRequest);
|
|
431
|
+
const output = released.released ? "Lease released." : "No live lease to release.";
|
|
432
|
+
return text(output, createPreviewDetails("tasks.release_lease", "Task lease", output));
|
|
433
|
+
}
|
|
434
|
+
const operation = action === "claim" ? "tasks.claim" : action === "heartbeat_lease" ? "tasks.heartbeat_lease" : "tasks.lease";
|
|
435
|
+
const lease = await callService<Record<string, unknown>, TaskLease | null>(operation, leaseRequest);
|
|
436
|
+
const output = lease ? `Leased by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).` : "No live lease.";
|
|
437
|
+
return text(output, createPreviewDetails(operation, "Task lease", output));
|
|
438
|
+
}
|
|
416
439
|
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
417
440
|
if (trashResult) return trashResult;
|
|
418
441
|
const operations = {
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { Db } from "../db.ts";
|
|
2
2
|
import { inTransaction } from "../db.ts";
|
|
3
3
|
import {
|
|
4
|
+
normalizeTaskEventFeedQuery,
|
|
4
5
|
normalizeTaskHistoryQuery,
|
|
5
6
|
validateTaskEvent,
|
|
6
7
|
type AppendTaskEvent,
|
|
7
8
|
type TaskEvent,
|
|
8
9
|
type TaskEventEvidence,
|
|
10
|
+
type TaskEventFeedPage,
|
|
11
|
+
type TaskEventFeedQuery,
|
|
9
12
|
type TaskEventType,
|
|
10
13
|
type TaskHistoryPage,
|
|
11
14
|
type TaskHistoryQuery,
|
|
@@ -89,4 +92,22 @@ export class SQLiteTaskEventStore implements TaskEventStore {
|
|
|
89
92
|
const events = rows.slice(0, limit).map(mapRow);
|
|
90
93
|
return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
91
94
|
}
|
|
95
|
+
|
|
96
|
+
feed(query: TaskEventFeedQuery = {}): TaskEventFeedPage {
|
|
97
|
+
const { limit, cursor, eventTypes } = normalizeTaskEventFeedQuery(query);
|
|
98
|
+
const typeFilter = eventTypes ? `AND event_type IN (${eventTypes.map(() => "?").join(", ")})` : "";
|
|
99
|
+
const params: unknown[] = [];
|
|
100
|
+
if (cursor !== undefined) params.push(cursor);
|
|
101
|
+
if (eventTypes) params.push(...eventTypes);
|
|
102
|
+
params.push(limit + 1);
|
|
103
|
+
const rows = this.db.prepare(`
|
|
104
|
+
SELECT * FROM task_events
|
|
105
|
+
WHERE 1=1 ${cursor === undefined ? "" : "AND id > ?"} ${typeFilter}
|
|
106
|
+
ORDER BY id ASC
|
|
107
|
+
LIMIT ?
|
|
108
|
+
`).all(...params) as TaskEventRow[];
|
|
109
|
+
const hasMore = rows.length > limit;
|
|
110
|
+
const events = rows.slice(0, limit).map(mapRow);
|
|
111
|
+
return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
112
|
+
}
|
|
92
113
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { TASK_LEASE_DEFAULT_TTL_MS } from "../constants.ts";
|
|
2
|
+
import type { Db } from "../db.ts";
|
|
3
|
+
import { inTransaction } from "../db.ts";
|
|
4
|
+
import { isLeaseExpired, validateLeaseNote, validateLeaseOwner, validateLeaseTtlMs, type TaskLease } from "../domain/task-lease.ts";
|
|
5
|
+
import type { TaskLeaseStore } from "../ports/task-lease-store.ts";
|
|
6
|
+
|
|
7
|
+
interface TaskLeaseRow {
|
|
8
|
+
task_id: string;
|
|
9
|
+
owner: string;
|
|
10
|
+
token: string;
|
|
11
|
+
claimed_at: string;
|
|
12
|
+
lease_expires_at: string;
|
|
13
|
+
heartbeat_at: string | null;
|
|
14
|
+
note: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fromRow(row: TaskLeaseRow): TaskLease {
|
|
18
|
+
return {
|
|
19
|
+
taskId: row.task_id,
|
|
20
|
+
owner: row.owner,
|
|
21
|
+
token: row.token,
|
|
22
|
+
claimedAt: row.claimed_at,
|
|
23
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
24
|
+
...(row.heartbeat_at ? { heartbeatAt: row.heartbeat_at } : {}),
|
|
25
|
+
...(row.note ? { note: row.note } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class SQLiteTaskLeaseStore implements TaskLeaseStore {
|
|
30
|
+
constructor(private readonly db: Db) {}
|
|
31
|
+
|
|
32
|
+
private row(taskId: string): TaskLeaseRow | undefined {
|
|
33
|
+
return (this.db.prepare("SELECT * FROM task_leases WHERE task_id = ?").get(taskId) as TaskLeaseRow | null) ?? undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
claim(taskId: string, owner: string, ttlMs: number = TASK_LEASE_DEFAULT_TTL_MS, note?: string): TaskLease {
|
|
37
|
+
validateLeaseOwner(owner);
|
|
38
|
+
validateLeaseNote(note);
|
|
39
|
+
validateLeaseTtlMs(ttlMs);
|
|
40
|
+
return inTransaction(this.db, () => {
|
|
41
|
+
const now = new Date();
|
|
42
|
+
const nowIso = now.toISOString();
|
|
43
|
+
const existing = this.row(taskId);
|
|
44
|
+
const current = existing ? fromRow(existing) : undefined;
|
|
45
|
+
if (current && !isLeaseExpired(current, nowIso) && current.owner !== owner) {
|
|
46
|
+
throw new Error(`task "${taskId}" is already leased by "${current.owner}" until ${current.leaseExpiresAt}`);
|
|
47
|
+
}
|
|
48
|
+
const renewingSameOwner = current !== undefined && !isLeaseExpired(current, nowIso) && current.owner === owner;
|
|
49
|
+
const token = renewingSameOwner ? current!.token : crypto.randomUUID();
|
|
50
|
+
const claimedAt = renewingSameOwner ? current!.claimedAt : nowIso;
|
|
51
|
+
const leaseExpiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
52
|
+
this.db.prepare(`
|
|
53
|
+
INSERT INTO task_leases (task_id, owner, token, claimed_at, lease_expires_at, heartbeat_at, note)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?)
|
|
55
|
+
ON CONFLICT(task_id) DO UPDATE SET owner = excluded.owner, token = excluded.token, claimed_at = excluded.claimed_at,
|
|
56
|
+
lease_expires_at = excluded.lease_expires_at, heartbeat_at = NULL, note = excluded.note
|
|
57
|
+
`).run(taskId, owner, token, claimedAt, leaseExpiresAt, note ?? null);
|
|
58
|
+
return fromRow(this.row(taskId)!);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
heartbeat(taskId: string, owner: string, token: string, ttlMs: number = TASK_LEASE_DEFAULT_TTL_MS): TaskLease {
|
|
63
|
+
validateLeaseTtlMs(ttlMs);
|
|
64
|
+
return inTransaction(this.db, () => {
|
|
65
|
+
const nowIso = new Date().toISOString();
|
|
66
|
+
const existing = this.row(taskId);
|
|
67
|
+
const current = existing ? fromRow(existing) : undefined;
|
|
68
|
+
if (!current || isLeaseExpired(current, nowIso)) throw new Error(`task "${taskId}" has no live lease to renew`);
|
|
69
|
+
if (current.owner !== owner || current.token !== token) throw new Error(`lease for task "${taskId}" is held by a different owner/token`);
|
|
70
|
+
const leaseExpiresAt = new Date(Date.now() + ttlMs).toISOString();
|
|
71
|
+
this.db.prepare("UPDATE task_leases SET lease_expires_at = ?, heartbeat_at = ? WHERE task_id = ?").run(leaseExpiresAt, nowIso, taskId);
|
|
72
|
+
return fromRow(this.row(taskId)!);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
release(taskId: string, owner: string, token: string): { released: boolean } {
|
|
77
|
+
return inTransaction(this.db, () => {
|
|
78
|
+
const nowIso = new Date().toISOString();
|
|
79
|
+
const existing = this.row(taskId);
|
|
80
|
+
const current = existing ? fromRow(existing) : undefined;
|
|
81
|
+
if (!current || isLeaseExpired(current, nowIso)) return { released: false };
|
|
82
|
+
if (current.owner !== owner || current.token !== token) throw new Error(`lease for task "${taskId}" is held by a different owner/token`);
|
|
83
|
+
this.db.prepare("DELETE FROM task_leases WHERE task_id = ?").run(taskId);
|
|
84
|
+
return { released: true };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get(taskId: string): TaskLease | undefined {
|
|
89
|
+
const existing = this.row(taskId);
|
|
90
|
+
if (!existing) return undefined;
|
|
91
|
+
const lease = fromRow(existing);
|
|
92
|
+
return isLeaseExpired(lease, new Date().toISOString()) ? undefined : lease;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
reapExpired(olderThanIso: string): number {
|
|
96
|
+
return this.db.prepare("DELETE FROM task_leases WHERE lease_expires_at < ?").run(olderThanIso).changes;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -148,6 +148,12 @@ const USAGE = `Usage:
|
|
|
148
148
|
papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
|
|
149
149
|
papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
|
|
150
150
|
papyrus tasks reap-stale-focus [--json]
|
|
151
|
+
papyrus tasks claim <id> --owner <owner> [--ttl-ms <ms>] [--note <text>] [--json]
|
|
152
|
+
papyrus tasks heartbeat-lease <id> --owner <owner> --token <token> [--ttl-ms <ms>] [--json]
|
|
153
|
+
papyrus tasks release-lease <id> --owner <owner> --token <token> [--json]
|
|
154
|
+
papyrus tasks lease <id> [--json]
|
|
155
|
+
papyrus tasks reap-stale-leases [--json]
|
|
156
|
+
papyrus tasks event-feed [--cursor <n>] [--limit <n>] [--event-types-json <json>] [--json]
|
|
151
157
|
papyrus tasks history <id> [--json]
|
|
152
158
|
papyrus tasks scope [project|all|graph <root-id>] [--json]
|
|
153
159
|
papyrus tasks assign-project <id> [project-root] [--json]
|
|
@@ -182,6 +188,7 @@ function usage(): never {
|
|
|
182
188
|
type TaskCliClient = Pick<PapyrusClient, "call">;
|
|
183
189
|
type MigrationResult = { from: number; to: number; applied: string[] };
|
|
184
190
|
type CliArtifact = { id: string; title: string; status: string; body?: string };
|
|
191
|
+
type CliTaskLease = { taskId: string; owner: string; token: string; claimedAt: string; leaseExpiresAt: string; heartbeatAt?: string; note?: string };
|
|
185
192
|
type CliCompletion = Omit<TaskCompletion, "artifact" | "blocked"> & {
|
|
186
193
|
artifact: CliArtifact;
|
|
187
194
|
blocked: Array<Omit<TaskBlockage, "artifact"> & { artifact: CliArtifact }>;
|
|
@@ -1320,6 +1327,12 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
|
|
|
1320
1327
|
let limit: number | undefined;
|
|
1321
1328
|
let listScope: "project" | "graph" | "all" | undefined;
|
|
1322
1329
|
let rootTaskId: string | undefined;
|
|
1330
|
+
let owner: string | undefined;
|
|
1331
|
+
let token: string | undefined;
|
|
1332
|
+
let ttlMs: number | undefined;
|
|
1333
|
+
let note: string | undefined;
|
|
1334
|
+
let cursor: number | undefined;
|
|
1335
|
+
let eventTypes: string[] | undefined;
|
|
1323
1336
|
for (let index = 0; index < args.length; index++) {
|
|
1324
1337
|
const argument = args[index]!;
|
|
1325
1338
|
if (argument === "--json") continue;
|
|
@@ -1375,6 +1388,22 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
|
|
|
1375
1388
|
continue;
|
|
1376
1389
|
}
|
|
1377
1390
|
if (argument === "--root-task-id") { rootTaskId = args[++index]; if (!rootTaskId) throw new Error("--root-task-id requires a value"); continue; }
|
|
1391
|
+
if (argument === "--owner") { owner = args[++index]; if (!owner) throw new Error("--owner requires a value"); continue; }
|
|
1392
|
+
if (argument === "--token") { token = args[++index]; if (!token) throw new Error("--token requires a value"); continue; }
|
|
1393
|
+
if (argument === "--note") { note = args[++index]; if (note === undefined) throw new Error("--note requires a value"); continue; }
|
|
1394
|
+
if (argument === "--ttl-ms") {
|
|
1395
|
+
const value = args[++index];
|
|
1396
|
+
if (!value || Number.isNaN(Number(value))) throw new Error("--ttl-ms requires a numeric value");
|
|
1397
|
+
ttlMs = Number(value);
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (argument === "--cursor") {
|
|
1401
|
+
const value = args[++index];
|
|
1402
|
+
if (!value || Number.isNaN(Number(value))) throw new Error("--cursor requires a numeric value");
|
|
1403
|
+
cursor = Number(value);
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
if (argument === "--event-types-json") { eventTypes = parseJsonStringArrayFlag(args[++index]!, "--event-types-json"); continue; }
|
|
1378
1407
|
if (argument.startsWith("--")) throw new Error(`unknown tasks option ${argument}`);
|
|
1379
1408
|
positional.push(argument);
|
|
1380
1409
|
}
|
|
@@ -1426,6 +1455,53 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
|
|
|
1426
1455
|
human = `Reaped ${reaped.removed} stale Focus scope(s).`;
|
|
1427
1456
|
break;
|
|
1428
1457
|
}
|
|
1458
|
+
case "claim": {
|
|
1459
|
+
if (!id || dependencyId) throw new Error("tasks claim requires exactly one task id");
|
|
1460
|
+
if (!owner) throw new Error("tasks claim requires --owner");
|
|
1461
|
+
const lease = await client.call<Record<string, unknown>, CliTaskLease>("tasks.claim", { id, owner, ttl_ms: ttlMs, note });
|
|
1462
|
+
result = lease;
|
|
1463
|
+
human = `Claimed by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).`;
|
|
1464
|
+
break;
|
|
1465
|
+
}
|
|
1466
|
+
case "heartbeat-lease": {
|
|
1467
|
+
if (!id || dependencyId) throw new Error("tasks heartbeat-lease requires exactly one task id");
|
|
1468
|
+
if (!owner || !token) throw new Error("tasks heartbeat-lease requires --owner and --token");
|
|
1469
|
+
const lease = await client.call<Record<string, unknown>, CliTaskLease>("tasks.heartbeat_lease", { id, owner, token, ttl_ms: ttlMs });
|
|
1470
|
+
result = lease;
|
|
1471
|
+
human = `Renewed until ${lease.leaseExpiresAt}.`;
|
|
1472
|
+
break;
|
|
1473
|
+
}
|
|
1474
|
+
case "release-lease": {
|
|
1475
|
+
if (!id || dependencyId) throw new Error("tasks release-lease requires exactly one task id");
|
|
1476
|
+
if (!owner || !token) throw new Error("tasks release-lease requires --owner and --token");
|
|
1477
|
+
const released = await client.call<Record<string, unknown>, { released: boolean }>("tasks.release_lease", { id, owner, token });
|
|
1478
|
+
result = released;
|
|
1479
|
+
human = released.released ? "Lease released." : "No live lease to release.";
|
|
1480
|
+
break;
|
|
1481
|
+
}
|
|
1482
|
+
case "lease": {
|
|
1483
|
+
if (!id || dependencyId) throw new Error("tasks lease requires exactly one task id");
|
|
1484
|
+
const lease = await client.call<Record<string, unknown>, CliTaskLease | null>("tasks.lease", { id });
|
|
1485
|
+
result = lease;
|
|
1486
|
+
human = lease ? `Leased by "${lease.owner}" until ${lease.leaseExpiresAt}.` : "No live lease.";
|
|
1487
|
+
break;
|
|
1488
|
+
}
|
|
1489
|
+
case "reap-stale-leases": {
|
|
1490
|
+
if (id) throw new Error("tasks reap-stale-leases accepts no positional arguments");
|
|
1491
|
+
const reaped = await client.call<Record<string, unknown>, { removed: number }>("tasks.reap_stale_leases", {});
|
|
1492
|
+
result = reaped;
|
|
1493
|
+
human = `Reaped ${reaped.removed} expired lease(s).`;
|
|
1494
|
+
break;
|
|
1495
|
+
}
|
|
1496
|
+
case "event-feed": {
|
|
1497
|
+
if (id) throw new Error("tasks event-feed accepts no positional arguments");
|
|
1498
|
+
const page = await client.call<Record<string, unknown>, import("./domain/task-event.ts").TaskEventFeedPage>("tasks.event_feed", { cursor, limit, event_types: eventTypes });
|
|
1499
|
+
result = page;
|
|
1500
|
+
human = page.events.length === 0
|
|
1501
|
+
? "No events."
|
|
1502
|
+
: page.events.map((event) => `${event.id} ${event.occurredAt} ${event.taskId} ${event.type}`).join("\n");
|
|
1503
|
+
break;
|
|
1504
|
+
}
|
|
1429
1505
|
case "create": {
|
|
1430
1506
|
if (id) throw new Error("tasks create accepts no positional arguments");
|
|
1431
1507
|
if (!title) throw new Error("tasks create requires --title");
|
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 = 20;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
|
|
13
13
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -134,6 +134,15 @@ export const TASK_HISTORY_MAX_LIMIT = 100;
|
|
|
134
134
|
export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
|
|
135
135
|
export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
136
136
|
export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
|
|
137
|
+
/** Task lease claims: bounded work reservation, orthogonal to Task lifecycle and Focus. */
|
|
138
|
+
export const TASK_LEASE_DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
139
|
+
export const TASK_LEASE_MIN_TTL_MS = 1_000;
|
|
140
|
+
export const TASK_LEASE_MAX_TTL_MS = 4 * 60 * 60 * 1000;
|
|
141
|
+
export const TASK_LEASE_OWNER_MAX_LENGTH = 128;
|
|
142
|
+
export const TASK_LEASE_NOTE_MAX_LENGTH = 500;
|
|
143
|
+
/** Global, cross-task sequenced Task event feed (readiness/lifecycle subscriptions), distinct from tasks.history's per-task cursor. */
|
|
144
|
+
export const TASK_EVENT_FEED_DEFAULT_LIMIT = 50;
|
|
145
|
+
export const TASK_EVENT_FEED_MAX_LIMIT = 200;
|
|
137
146
|
/** Deferred human Note payload, inbox, and provenance bounds. */
|
|
138
147
|
export const NOTE_BODY_MAX_CHARACTERS = 10_000;
|
|
139
148
|
export const NOTE_TITLE_MAX_CHARACTERS = 80;
|
package/src/db.ts
CHANGED
|
@@ -256,6 +256,16 @@ BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only'); END;
|
|
|
256
256
|
CREATE TRIGGER IF NOT EXISTS discussion_rounds_no_delete BEFORE DELETE ON discussion_rounds
|
|
257
257
|
WHEN NOT EXISTS (SELECT 1 FROM artifact_trash WHERE artifact_id = OLD.discussion_id AND purge_after <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
258
258
|
BEGIN SELECT RAISE(ABORT, 'discussion_rounds are append-only except during an explicit, elapsed-grace-period artifact trash purge'); END;
|
|
259
|
+
CREATE TABLE IF NOT EXISTS task_leases (
|
|
260
|
+
task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
261
|
+
owner TEXT NOT NULL,
|
|
262
|
+
token TEXT NOT NULL,
|
|
263
|
+
claimed_at TEXT NOT NULL,
|
|
264
|
+
lease_expires_at TEXT NOT NULL,
|
|
265
|
+
heartbeat_at TEXT,
|
|
266
|
+
note TEXT
|
|
267
|
+
);
|
|
268
|
+
CREATE INDEX IF NOT EXISTS task_leases_expiry_idx ON task_leases(lease_expires_at);
|
|
259
269
|
`;
|
|
260
270
|
|
|
261
271
|
const SEED_SQL = `
|
|
@@ -545,6 +555,27 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
545
555
|
if (!existing.has("option_descriptions")) db.exec("ALTER TABLE discussion_rounds ADD COLUMN option_descriptions TEXT");
|
|
546
556
|
},
|
|
547
557
|
},
|
|
558
|
+
{
|
|
559
|
+
version: 20,
|
|
560
|
+
name: "task-leases",
|
|
561
|
+
// See domain/task-lease.ts. One row per task (a task has at most one live lease at a time),
|
|
562
|
+
// same PRIMARY KEY-per-task shape as task_focus -- but a lease is deliberately orthogonal to
|
|
563
|
+
// Focus (multiple sessions can focus the same task; only one owner can hold its lease).
|
|
564
|
+
up: (db) => {
|
|
565
|
+
db.exec(`
|
|
566
|
+
CREATE TABLE IF NOT EXISTS task_leases (
|
|
567
|
+
task_id TEXT PRIMARY KEY REFERENCES artifacts(id),
|
|
568
|
+
owner TEXT NOT NULL,
|
|
569
|
+
token TEXT NOT NULL,
|
|
570
|
+
claimed_at TEXT NOT NULL,
|
|
571
|
+
lease_expires_at TEXT NOT NULL,
|
|
572
|
+
heartbeat_at TEXT,
|
|
573
|
+
note TEXT
|
|
574
|
+
);
|
|
575
|
+
CREATE INDEX IF NOT EXISTS task_leases_expiry_idx ON task_leases(lease_expires_at);
|
|
576
|
+
`);
|
|
577
|
+
},
|
|
578
|
+
},
|
|
548
579
|
];
|
|
549
580
|
|
|
550
581
|
/**
|
package/src/domain/task-event.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
TASK_EVENT_ACTOR_MAX_LENGTH,
|
|
3
|
+
TASK_EVENT_FEED_DEFAULT_LIMIT,
|
|
4
|
+
TASK_EVENT_FEED_MAX_LIMIT,
|
|
3
5
|
TASK_EVENT_MAX_EVIDENCE_BYTES,
|
|
4
6
|
TASK_EVENT_REASON_MAX_LENGTH,
|
|
5
7
|
TASK_HISTORY_DEFAULT_LIMIT,
|
|
@@ -28,6 +30,7 @@ export const TASK_EVENT_TYPES = [
|
|
|
28
30
|
"dependency_removed",
|
|
29
31
|
"containment_added",
|
|
30
32
|
"containment_removed",
|
|
33
|
+
"became_ready",
|
|
31
34
|
] as const;
|
|
32
35
|
|
|
33
36
|
export type TaskEventType = typeof TASK_EVENT_TYPES[number];
|
|
@@ -86,6 +89,39 @@ export interface TaskHistoryPage {
|
|
|
86
89
|
nextCursor?: number;
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A bounded, sequenced, cross-task replay feed -- distinct from TaskHistoryQuery, which is
|
|
94
|
+
* scoped to one task. A consumer resumes exactly where it left off via nextCursor rather than
|
|
95
|
+
* re-scanning the whole graph; always ascending (a subscription only ever replays forward).
|
|
96
|
+
*/
|
|
97
|
+
export interface TaskEventFeedQuery {
|
|
98
|
+
cursor?: number;
|
|
99
|
+
limit?: number;
|
|
100
|
+
eventTypes?: TaskEventType[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface TaskEventFeedPage {
|
|
104
|
+
events: TaskEvent[];
|
|
105
|
+
nextCursor?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizeTaskEventFeedQuery(query: TaskEventFeedQuery = {}): Required<Pick<TaskEventFeedQuery, "limit">> & Pick<TaskEventFeedQuery, "cursor" | "eventTypes"> {
|
|
109
|
+
const limit = query.limit ?? TASK_EVENT_FEED_DEFAULT_LIMIT;
|
|
110
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > TASK_EVENT_FEED_MAX_LIMIT) {
|
|
111
|
+
throw new Error(`task event feed limit must be between 1 and ${TASK_EVENT_FEED_MAX_LIMIT}`);
|
|
112
|
+
}
|
|
113
|
+
if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 0)) {
|
|
114
|
+
throw new Error("task event feed cursor must be a non-negative integer");
|
|
115
|
+
}
|
|
116
|
+
if (query.eventTypes !== undefined) {
|
|
117
|
+
if (query.eventTypes.length === 0) throw new Error("task event feed eventTypes, if provided, must be non-empty");
|
|
118
|
+
for (const type of query.eventTypes) {
|
|
119
|
+
if (!TASK_EVENT_TYPES.includes(type)) throw new Error(`unknown task event type "${type}"`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { limit, cursor: query.cursor, eventTypes: query.eventTypes };
|
|
123
|
+
}
|
|
124
|
+
|
|
89
125
|
export function normalizeTaskHistoryQuery(query: TaskHistoryQuery = {}): Required<Pick<TaskHistoryQuery, "limit" | "direction">> & Pick<TaskHistoryQuery, "cursor"> {
|
|
90
126
|
const limit = query.limit ?? TASK_HISTORY_DEFAULT_LIMIT;
|
|
91
127
|
if (!Number.isInteger(limit) || limit < 1 || limit > TASK_HISTORY_MAX_LIMIT) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { TASK_LEASE_MAX_TTL_MS, TASK_LEASE_MIN_TTL_MS, TASK_LEASE_NOTE_MAX_LENGTH, TASK_LEASE_OWNER_MAX_LENGTH } from "../constants.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A bounded work reservation on a Task, independent of both the Task's own lifecycle status
|
|
5
|
+
* and of per-scope Task Focus -- expresses "I intend to work this" for concurrent workers
|
|
6
|
+
* coordinating over the same Task graph, without claiming the Task actually started or that
|
|
7
|
+
* any particular session is looking at it.
|
|
8
|
+
*/
|
|
9
|
+
export interface TaskLease {
|
|
10
|
+
taskId: string;
|
|
11
|
+
owner: string;
|
|
12
|
+
token: string;
|
|
13
|
+
claimedAt: string;
|
|
14
|
+
leaseExpiresAt: string;
|
|
15
|
+
heartbeatAt?: string;
|
|
16
|
+
note?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateLeaseOwner(owner: string): string {
|
|
20
|
+
if (owner.length === 0 || owner.length > TASK_LEASE_OWNER_MAX_LENGTH) {
|
|
21
|
+
throw new Error(`lease owner must be between 1 and ${TASK_LEASE_OWNER_MAX_LENGTH} characters`);
|
|
22
|
+
}
|
|
23
|
+
return owner;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateLeaseNote(note: string | undefined): string | undefined {
|
|
27
|
+
if (note !== undefined && note.length > TASK_LEASE_NOTE_MAX_LENGTH) {
|
|
28
|
+
throw new Error(`lease note cannot exceed ${TASK_LEASE_NOTE_MAX_LENGTH} characters`);
|
|
29
|
+
}
|
|
30
|
+
return note;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateLeaseTtlMs(ttlMs: number): number {
|
|
34
|
+
if (!Number.isFinite(ttlMs) || ttlMs < TASK_LEASE_MIN_TTL_MS || ttlMs > TASK_LEASE_MAX_TTL_MS) {
|
|
35
|
+
throw new Error(`lease ttl_ms must be between ${TASK_LEASE_MIN_TTL_MS} and ${TASK_LEASE_MAX_TTL_MS}`);
|
|
36
|
+
}
|
|
37
|
+
return ttlMs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isLeaseExpired(lease: Pick<TaskLease, "leaseExpiresAt">, now: string): boolean {
|
|
41
|
+
return lease.leaseExpiresAt <= now;
|
|
42
|
+
}
|
package/src/domain-services.ts
CHANGED
|
@@ -191,7 +191,7 @@ export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
194
|
-
const document = requireMutableDocument(requireDocument(artifacts, id), authority);
|
|
194
|
+
const document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority));
|
|
195
195
|
const transition = DOCUMENT_TRANSITIONS[action];
|
|
196
196
|
if (!transition.from.includes(document.status)) throw new Error(`cannot ${action} document from ${document.status}`);
|
|
197
197
|
return artifacts.setStatus(id, transition.to, context)!;
|
|
@@ -215,10 +215,10 @@ export function updateDocument(artifacts: ArtifactStore, id: string, input: Upda
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
218
|
-
requireMutableDocument(requireDocument(artifacts, id), authority);
|
|
218
|
+
requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority));
|
|
219
219
|
const target = artifacts.get(targetId);
|
|
220
220
|
if (!target) throw new Error(`target artifact "${targetId}" not found`);
|
|
221
|
-
requireMutableDocument(target, authority);
|
|
221
|
+
requireLocallyOwnedContent(requireMutableDocument(target, authority));
|
|
222
222
|
artifacts.link({ from: id, relation, to: targetId }, context);
|
|
223
223
|
return showDocument(artifacts, id);
|
|
224
224
|
}
|
|
@@ -308,7 +308,7 @@ export function previewRule(artifacts: ArtifactStore, id: string): string {
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition, context?: ArtifactEventContext): Artifact {
|
|
311
|
-
const rule = requireKind(artifacts, id, "rule");
|
|
311
|
+
const rule = requireLocallyOwnedContent(requireKind(artifacts, id, "rule"));
|
|
312
312
|
const expected = action === "enable" ? "deprecated" : "active";
|
|
313
313
|
const target = action === "enable" ? "active" : "deprecated";
|
|
314
314
|
if (rule.status !== expected) throw new Error(`cannot ${action} rule from ${rule.status}`);
|
|
@@ -334,7 +334,7 @@ export function updateRule(artifacts: ArtifactStore, id: string, input: UpdateRu
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string, context?: ArtifactEventContext): Artifact {
|
|
337
|
-
requireKind(artifacts, ruleId, "rule");
|
|
337
|
+
requireLocallyOwnedContent(requireKind(artifacts, ruleId, "rule"));
|
|
338
338
|
requireKind(artifacts, taskId, "task");
|
|
339
339
|
artifacts.link({ from: ruleId, relation: "gates", to: taskId }, context);
|
|
340
340
|
return showRule(artifacts, ruleId);
|
|
@@ -512,7 +512,7 @@ export function skillInvocation(artifacts: ArtifactStore, id: string, visited: S
|
|
|
512
512
|
}
|
|
513
513
|
|
|
514
514
|
export function transitionSkill(artifacts: ArtifactStore, id: string, action: SkillTransition, context?: ArtifactEventContext): Artifact {
|
|
515
|
-
const skill = requireKind(artifacts, id, "skill");
|
|
515
|
+
const skill = requireLocallyOwnedContent(requireKind(artifacts, id, "skill"));
|
|
516
516
|
const expected = action === "enable" ? "deprecated" : "active";
|
|
517
517
|
const target = action === "enable" ? "active" : "deprecated";
|
|
518
518
|
if (skill.status !== expected) throw new Error(`cannot ${action} skill from ${skill.status}`);
|
|
@@ -619,7 +619,7 @@ export function updatePlaybook(artifacts: ArtifactStore, id: string, input: Upda
|
|
|
619
619
|
}
|
|
620
620
|
|
|
621
621
|
export function transitionPlaybook(artifacts: ArtifactStore, id: string, action: PlaybookTransition, context?: ArtifactEventContext): Artifact {
|
|
622
|
-
const playbook = requireKind(artifacts, id, "playbook");
|
|
622
|
+
const playbook = requireLocallyOwnedContent(requireKind(artifacts, id, "playbook"));
|
|
623
623
|
const expected = action === "enable" ? "deprecated" : "active";
|
|
624
624
|
const target = action === "enable" ? "active" : "deprecated";
|
|
625
625
|
if (playbook.status !== expected) throw new Error(`cannot ${action} playbook from ${playbook.status}`);
|
package/src/modules/tasks.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* including the composition root's own helpers.
|
|
20
20
|
*/
|
|
21
21
|
import type { Checklist } from "../domain/checklist.ts";
|
|
22
|
-
import type { TaskEventContext, TaskEventDirection } from "../domain/task-event.ts";
|
|
22
|
+
import type { TaskEventContext, TaskEventDirection, TaskEventFeedQuery } from "../domain/task-event.ts";
|
|
23
23
|
import type { TaskViewMode } from "../domain/task-scope.ts";
|
|
24
24
|
import type { OperationDefinition } from "../module-registry.ts";
|
|
25
25
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
@@ -92,6 +92,7 @@ export const TASKS_OPERATION_NAMES = [
|
|
|
92
92
|
"tasks.pause", "tasks.unpause", "tasks.clear_focus", "tasks.start", "tasks.submit", "tasks.complete",
|
|
93
93
|
"tasks.run_gates", "tasks.set_checklist", "tasks.set_gates", "tasks.context", "tasks.reject", "tasks.retry", "tasks.cancel",
|
|
94
94
|
"tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain", "tasks.reap_stale_focus",
|
|
95
|
+
"tasks.claim", "tasks.heartbeat_lease", "tasks.release_lease", "tasks.lease", "tasks.reap_stale_leases", "tasks.event_feed",
|
|
95
96
|
] as const;
|
|
96
97
|
|
|
97
98
|
export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionIdentity: SessionIdentity): OperationDefinition[] {
|
|
@@ -171,5 +172,15 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
|
|
|
171
172
|
define("tasks.undepend", (input: OperationInput) => tasks.undepend(string(input, "id"), string(input, "dependency_id"), eventContext(input))),
|
|
172
173
|
define("tasks.contain", (input: OperationInput) => tasks.contain(string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
|
|
173
174
|
define("tasks.uncontain", (input: OperationInput) => tasks.uncontain(string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
|
|
175
|
+
define("tasks.claim", (input: OperationInput) => tasks.claimLease(string(input, "id"), string(input, "owner"), optionalNumber(input, "ttl_ms"), optionalString(input, "note"))),
|
|
176
|
+
define("tasks.heartbeat_lease", (input: OperationInput) => tasks.heartbeatLease(string(input, "id"), string(input, "owner"), string(input, "token"), optionalNumber(input, "ttl_ms"))),
|
|
177
|
+
define("tasks.release_lease", (input: OperationInput) => tasks.releaseLease(string(input, "id"), string(input, "owner"), string(input, "token"))),
|
|
178
|
+
define("tasks.lease", (input: OperationInput) => tasks.getLease(string(input, "id")) ?? null),
|
|
179
|
+
define("tasks.reap_stale_leases", () => ({ removed: tasks.reapStaleLeases() })),
|
|
180
|
+
define("tasks.event_feed", (input: OperationInput) => tasks.eventFeed({
|
|
181
|
+
cursor: optionalNumber(input, "cursor"),
|
|
182
|
+
limit: optionalNumber(input, "limit"),
|
|
183
|
+
eventTypes: optionalStringArray(input, "event_types") as TaskEventFeedQuery["eventTypes"],
|
|
184
|
+
})),
|
|
174
185
|
];
|
|
175
186
|
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
normalizeTaskEventFeedQuery,
|
|
3
|
+
normalizeTaskHistoryQuery,
|
|
4
|
+
validateTaskEvent,
|
|
5
|
+
type AppendTaskEvent,
|
|
6
|
+
type TaskEvent,
|
|
7
|
+
type TaskEventFeedPage,
|
|
8
|
+
type TaskEventFeedQuery,
|
|
9
|
+
type TaskHistoryPage,
|
|
10
|
+
type TaskHistoryQuery,
|
|
11
|
+
} from "../domain/task-event.ts";
|
|
2
12
|
|
|
3
13
|
export interface TaskEventStore {
|
|
4
14
|
atomic<T>(operation: () => T): T;
|
|
5
15
|
append(event: AppendTaskEvent): TaskEvent;
|
|
6
16
|
history(taskId: string, query?: TaskHistoryQuery): TaskHistoryPage;
|
|
17
|
+
/** Bounded, sequenced, cross-task replay feed -- see TaskEventFeedQuery. */
|
|
18
|
+
feed(query?: TaskEventFeedQuery): TaskEventFeedPage;
|
|
7
19
|
}
|
|
8
20
|
|
|
9
21
|
export class InMemoryTaskEventStore implements TaskEventStore {
|
|
@@ -40,4 +52,14 @@ export class InMemoryTaskEventStore implements TaskEventStore {
|
|
|
40
52
|
const events = ordered.slice(0, limit);
|
|
41
53
|
return { events, ...(ordered.length > limit ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
42
54
|
}
|
|
55
|
+
|
|
56
|
+
feed(query: TaskEventFeedQuery = {}): TaskEventFeedPage {
|
|
57
|
+
const { limit, cursor, eventTypes } = normalizeTaskEventFeedQuery(query);
|
|
58
|
+
const types = eventTypes ? new Set(eventTypes) : undefined;
|
|
59
|
+
const ordered = this.events
|
|
60
|
+
.filter((event) => (cursor === undefined || event.id > cursor) && (types === undefined || types.has(event.type)))
|
|
61
|
+
.sort((left, right) => left.id - right.id);
|
|
62
|
+
const events = ordered.slice(0, limit);
|
|
63
|
+
return { events, ...(ordered.length > limit ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
64
|
+
}
|
|
43
65
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { TASK_LEASE_DEFAULT_TTL_MS } from "../constants.ts";
|
|
2
|
+
import { isLeaseExpired, validateLeaseNote, validateLeaseOwner, validateLeaseTtlMs, type TaskLease } from "../domain/task-lease.ts";
|
|
3
|
+
|
|
4
|
+
export interface TaskLeaseStore {
|
|
5
|
+
/**
|
|
6
|
+
* Creates a new lease, or renews the caller's own live lease in place (same token, extended
|
|
7
|
+
* expiry). Throws if a *different* owner already holds a live (non-expired) lease.
|
|
8
|
+
*/
|
|
9
|
+
claim(taskId: string, owner: string, ttlMs?: number, note?: string): TaskLease;
|
|
10
|
+
/** Extends an existing live lease's expiry. Throws if no live lease exists, or owner/token do not match the current holder -- a stale or forged renewal must never silently succeed. */
|
|
11
|
+
heartbeat(taskId: string, owner: string, token: string, ttlMs?: number): TaskLease;
|
|
12
|
+
/** No-op (released: false) if no live lease exists. Throws if a live lease exists but owner/token do not match -- releasing a claim you don't hold is a real conflict, not a benign no-op. */
|
|
13
|
+
release(taskId: string, owner: string, token: string): { released: boolean };
|
|
14
|
+
/** The current live lease, or undefined if none exists or it has expired (an expired row reads as absent even before a reap sweep runs). */
|
|
15
|
+
get(taskId: string): TaskLease | undefined;
|
|
16
|
+
/** Deletes every lease row whose leaseExpiresAt is strictly before olderThanIso. Returns how many were removed. */
|
|
17
|
+
reapExpired(olderThanIso: string): number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function newToken(): string {
|
|
21
|
+
return crypto.randomUUID();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class InMemoryTaskLeaseStore implements TaskLeaseStore {
|
|
25
|
+
private readonly leases = new Map<string, TaskLease>();
|
|
26
|
+
|
|
27
|
+
claim(taskId: string, owner: string, ttlMs: number = TASK_LEASE_DEFAULT_TTL_MS, note?: string): TaskLease {
|
|
28
|
+
validateLeaseOwner(owner);
|
|
29
|
+
validateLeaseNote(note);
|
|
30
|
+
validateLeaseTtlMs(ttlMs);
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const nowIso = now.toISOString();
|
|
33
|
+
const current = this.leases.get(taskId);
|
|
34
|
+
if (current && !isLeaseExpired(current, nowIso) && current.owner !== owner) {
|
|
35
|
+
throw new Error(`task "${taskId}" is already leased by "${current.owner}" until ${current.leaseExpiresAt}`);
|
|
36
|
+
}
|
|
37
|
+
const lease: TaskLease = {
|
|
38
|
+
taskId,
|
|
39
|
+
owner,
|
|
40
|
+
token: current && !isLeaseExpired(current, nowIso) && current.owner === owner ? current.token : newToken(),
|
|
41
|
+
claimedAt: current && !isLeaseExpired(current, nowIso) && current.owner === owner ? current.claimedAt : nowIso,
|
|
42
|
+
leaseExpiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
43
|
+
...(note !== undefined ? { note } : {}),
|
|
44
|
+
};
|
|
45
|
+
this.leases.set(taskId, lease);
|
|
46
|
+
return lease;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
heartbeat(taskId: string, owner: string, token: string, ttlMs: number = TASK_LEASE_DEFAULT_TTL_MS): TaskLease {
|
|
50
|
+
validateLeaseTtlMs(ttlMs);
|
|
51
|
+
const nowIso = new Date().toISOString();
|
|
52
|
+
const current = this.leases.get(taskId);
|
|
53
|
+
if (!current || isLeaseExpired(current, nowIso)) throw new Error(`task "${taskId}" has no live lease to renew`);
|
|
54
|
+
if (current.owner !== owner || current.token !== token) throw new Error(`lease for task "${taskId}" is held by a different owner/token`);
|
|
55
|
+
const renewed: TaskLease = { ...current, leaseExpiresAt: new Date(Date.now() + ttlMs).toISOString(), heartbeatAt: nowIso };
|
|
56
|
+
this.leases.set(taskId, renewed);
|
|
57
|
+
return renewed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
release(taskId: string, owner: string, token: string): { released: boolean } {
|
|
61
|
+
const nowIso = new Date().toISOString();
|
|
62
|
+
const current = this.leases.get(taskId);
|
|
63
|
+
if (!current || isLeaseExpired(current, nowIso)) return { released: false };
|
|
64
|
+
if (current.owner !== owner || current.token !== token) throw new Error(`lease for task "${taskId}" is held by a different owner/token`);
|
|
65
|
+
this.leases.delete(taskId);
|
|
66
|
+
return { released: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get(taskId: string): TaskLease | undefined {
|
|
70
|
+
const current = this.leases.get(taskId);
|
|
71
|
+
if (!current || isLeaseExpired(current, new Date().toISOString())) return undefined;
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
reapExpired(olderThanIso: string): number {
|
|
76
|
+
let removed = 0;
|
|
77
|
+
for (const [taskId, lease] of this.leases) {
|
|
78
|
+
if (lease.leaseExpiresAt < olderThanIso) { this.leases.delete(taskId); removed++; }
|
|
79
|
+
}
|
|
80
|
+
return removed;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
|
|
|
6
6
|
import { SQLiteArtifactScopeStore } from "./adapters/sqlite-artifact-scope-store.ts";
|
|
7
7
|
import { SQLiteGraphProjectionStore } from "./adapters/sqlite-graph-projection-store.ts";
|
|
8
8
|
import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
|
|
9
|
+
import { SQLiteTaskLeaseStore } from "./adapters/sqlite-task-lease-store.ts";
|
|
9
10
|
import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
|
|
10
11
|
import { SQLiteTaskScopeStore } from "./adapters/sqlite-task-scope-store.ts";
|
|
11
12
|
import { SQLiteSessionIdentityStore } from "./adapters/sqlite-session-identity-store.ts";
|
|
@@ -339,6 +340,12 @@ function handlers(
|
|
|
339
340
|
"tasks.undepend": forwardToModule("tasks.undepend"),
|
|
340
341
|
"tasks.contain": forwardToModule("tasks.contain"),
|
|
341
342
|
"tasks.uncontain": forwardToModule("tasks.uncontain"),
|
|
343
|
+
"tasks.claim": forwardToModule("tasks.claim"),
|
|
344
|
+
"tasks.heartbeat_lease": forwardToModule("tasks.heartbeat_lease"),
|
|
345
|
+
"tasks.release_lease": forwardToModule("tasks.release_lease"),
|
|
346
|
+
"tasks.lease": forwardToModule("tasks.lease"),
|
|
347
|
+
"tasks.reap_stale_leases": forwardToModule("tasks.reap_stale_leases"),
|
|
348
|
+
"tasks.event_feed": forwardToModule("tasks.event_feed"),
|
|
342
349
|
"tasks.reap_stale_focus": forwardToModule("tasks.reap_stale_focus"),
|
|
343
350
|
"docs.create": forwardToModule("docs.create"),
|
|
344
351
|
"docs.list": forwardToModule("docs.list"),
|
|
@@ -426,7 +433,8 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
426
433
|
const focus = new SQLiteTaskFocusStore(db);
|
|
427
434
|
const events = new SQLiteTaskEventStore(db);
|
|
428
435
|
const scopes = new SQLiteTaskScopeStore(db);
|
|
429
|
-
const
|
|
436
|
+
const leases = new SQLiteTaskLeaseStore(db);
|
|
437
|
+
const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
|
|
430
438
|
const notes = new Notes(artifacts);
|
|
431
439
|
const projections = new SQLiteGraphProjectionStore(db);
|
|
432
440
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
package/src/task-service.ts
CHANGED
|
@@ -13,12 +13,14 @@ import type { Artifact } from "./domain/artifact.ts";
|
|
|
13
13
|
import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
|
|
14
14
|
import { DISCUSSION_SUBTYPE, isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
|
|
15
15
|
import { validateGates, type Gate, type GateResult } from "./domain/gate.ts";
|
|
16
|
-
import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
|
|
16
|
+
import type { AppendTaskEvent, TaskEventContext, TaskEventFeedPage, TaskEventFeedQuery, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
|
|
17
17
|
import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
|
|
18
18
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
19
19
|
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
20
20
|
import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "./ports/task-focus-store.ts";
|
|
21
21
|
import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
|
|
22
|
+
import { InMemoryTaskLeaseStore, type TaskLeaseStore } from "./ports/task-lease-store.ts";
|
|
23
|
+
import type { TaskLease } from "./domain/task-lease.ts";
|
|
22
24
|
import { InMemoryTaskScopeStore, type TaskScopeStore } from "./ports/task-scope-store.ts";
|
|
23
25
|
import { assertDependencyEdgeAllowed } from "./task-execution.ts";
|
|
24
26
|
|
|
@@ -126,6 +128,7 @@ export class Tasks {
|
|
|
126
128
|
private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
|
|
127
129
|
private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
|
|
128
130
|
private readonly scopes: TaskScopeStore = new InMemoryTaskScopeStore(),
|
|
131
|
+
private readonly leases: TaskLeaseStore = new InMemoryTaskLeaseStore(),
|
|
129
132
|
) {}
|
|
130
133
|
|
|
131
134
|
private require(id: string): Artifact {
|
|
@@ -403,6 +406,32 @@ export class Tasks {
|
|
|
403
406
|
return this.focusStore.reapStale(cutoff);
|
|
404
407
|
}
|
|
405
408
|
|
|
409
|
+
/** A lease is orthogonal to lifecycle and Focus: claiming a task does not start it, and does not require it to be Focused. */
|
|
410
|
+
claimLease(id: string, owner: string, ttlMs?: number, note?: string): TaskLease {
|
|
411
|
+
this.require(id);
|
|
412
|
+
return this.leases.claim(id, owner, ttlMs, note);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
heartbeatLease(id: string, owner: string, token: string, ttlMs?: number): TaskLease {
|
|
416
|
+
this.require(id);
|
|
417
|
+
return this.leases.heartbeat(id, owner, token, ttlMs);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Idempotent for an already-absent or already-expired lease, matching undepend/uncontain's precedent -- never throws merely because there was nothing left to release. */
|
|
421
|
+
releaseLease(id: string, owner: string, token: string): { released: boolean } {
|
|
422
|
+
this.require(id);
|
|
423
|
+
return this.leases.release(id, owner, token);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
getLease(id: string): TaskLease | undefined {
|
|
427
|
+
this.require(id);
|
|
428
|
+
return this.leases.get(id);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
reapStaleLeases(now: () => string = () => new Date().toISOString()): number {
|
|
432
|
+
return this.leases.reapExpired(now());
|
|
433
|
+
}
|
|
434
|
+
|
|
406
435
|
transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
|
|
407
436
|
return this.events.atomic(() => {
|
|
408
437
|
const task = this.require(id);
|
|
@@ -459,6 +488,11 @@ export class Tasks {
|
|
|
459
488
|
return this.events.history(id, query);
|
|
460
489
|
}
|
|
461
490
|
|
|
491
|
+
/** Bounded, sequenced, cross-task replay feed -- see TaskEventFeedQuery. Not scoped to any one task, unlike history(). */
|
|
492
|
+
eventFeed(query: TaskEventFeedQuery = {}): TaskEventFeedPage {
|
|
493
|
+
return this.events.feed(query);
|
|
494
|
+
}
|
|
495
|
+
|
|
462
496
|
setChecklist(id: string, checklist: Checklist): Artifact {
|
|
463
497
|
const task = this.require(id);
|
|
464
498
|
return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
|
|
@@ -498,10 +532,16 @@ export class Tasks {
|
|
|
498
532
|
/** Idempotent: undepending an already-absent dependency is a no-op. Never starts, completes, or focuses work — only removes the edge. */
|
|
499
533
|
undepend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
|
|
500
534
|
return this.events.atomic(() => {
|
|
501
|
-
this.require(id);
|
|
502
|
-
this.require(dependencyId);
|
|
535
|
+
const task = this.require(id);
|
|
536
|
+
const dependency = this.require(dependencyId);
|
|
503
537
|
const removed = this.artifacts.unlink({ from: id, relation: "depends_on", to: dependencyId }, context);
|
|
504
538
|
if (removed) this.appendEvent({ taskId: id, type: "dependency_removed", reason: context.reason }, context);
|
|
539
|
+
// Only meaningful if the removed edge was itself unmet -- removing an already-satisfied
|
|
540
|
+
// dependency, or removing one from a task that was already unblocked, changes nothing.
|
|
541
|
+
if (removed && task.status === "todo" && dependency.status !== "done") {
|
|
542
|
+
const stillBlocking = this.dependencyIds(id).filter((remainingId) => this.require(remainingId).status !== "done");
|
|
543
|
+
if (stillBlocking.length === 0) this.appendEvent({ taskId: id, type: "became_ready" }, context);
|
|
544
|
+
}
|
|
505
545
|
return this.show(id);
|
|
506
546
|
});
|
|
507
547
|
}
|
|
@@ -676,6 +716,9 @@ export class Tasks {
|
|
|
676
716
|
blocked.push({ artifact: successor, dependencyIds });
|
|
677
717
|
continue;
|
|
678
718
|
}
|
|
719
|
+
// Every successor whose last unmet dependency was this completion, not only the one
|
|
720
|
+
// auto-focused below -- readiness is a real state change for all of them.
|
|
721
|
+
this.appendEvent({ taskId: successor.id, type: "became_ready" }, context);
|
|
679
722
|
if (options.focusSuccessor !== false && !focused) {
|
|
680
723
|
this.focusStore.set(successor.id, context.sessionId);
|
|
681
724
|
focused = successor;
|