@danypops/papyrus 0.45.2 → 0.46.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 +10 -0
- package/package.json +1 -1
- package/src/authority-registry.ts +2 -1
- package/src/cli/task-command.ts +89 -4
- package/src/constants.ts +6 -1
- package/src/db.ts +68 -1
- package/src/docs/docs-service.ts +192 -0
- package/src/domain/task-lease.ts +6 -0
- package/src/domain/task-scope.ts +16 -0
- package/src/domain-service-shared.ts +162 -0
- package/src/handlers/docs.ts +1 -1
- package/src/handlers/playbooks.ts +3 -1
- package/src/handlers/rules.ts +5 -3
- package/src/handlers/shared.ts +16 -1
- package/src/handlers/tasks.ts +135 -16
- package/src/index.ts +1 -1
- package/src/modules/docs.ts +2 -2
- package/src/modules/notes.ts +2 -2
- package/src/modules/playbooks.ts +6 -4
- package/src/modules/rules.ts +4 -2
- package/src/modules/tasks.ts +25 -2
- package/src/playbook/playbook-definition.ts +1 -1
- package/src/{domain-services.ts → playbook/playbook-service.ts} +53 -453
- package/src/rules/rules-service.ts +224 -0
- package/src/service.ts +7 -2
- package/src/stores/sqlite-task-create-request-store.ts +47 -0
- package/src/stores/sqlite-task-scope-store.ts +154 -15
- package/src/stores/task-create-request-store.ts +45 -0
- package/src/stores/task-scope-store.ts +91 -1
- package/src/task/task-service.ts +131 -13
package/src/task/task-service.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { Artifact } from "../artifact/artifact.ts";
|
|
2
3
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
3
4
|
import {
|
|
4
5
|
TASK_BODY_MAX_LENGTH,
|
|
5
6
|
TASK_CANCEL_SUBTREE_MAX_NODES,
|
|
7
|
+
TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH,
|
|
8
|
+
TASK_CREATE_IDEMPOTENCY_RETENTION_MS,
|
|
6
9
|
TASK_EXECUTION_MAX_DEGREE,
|
|
7
10
|
TASK_EXECUTION_MAX_EDGES,
|
|
8
11
|
TASK_EXECUTION_MAX_NODES,
|
|
9
12
|
TASK_FOCUS_STALE_AFTER_MS,
|
|
10
13
|
TASK_LABEL_MAX_COUNT,
|
|
11
14
|
TASK_LABEL_MAX_LENGTH,
|
|
15
|
+
TASK_PROJECT_ALIAS_MAX_COUNT,
|
|
16
|
+
TASK_PROJECT_LIST_MAX_RESULTS,
|
|
17
|
+
TASK_PROJECT_NAME_MAX_LENGTH,
|
|
12
18
|
TASK_SCOPE_MAX_TASKS,
|
|
13
19
|
TASK_TITLE_MAX_LENGTH,
|
|
14
20
|
} from "../constants.ts";
|
|
@@ -24,15 +30,23 @@ import type {
|
|
|
24
30
|
TaskHistoryQuery,
|
|
25
31
|
TaskLifecycleStatus,
|
|
26
32
|
} from "../domain/task-event.ts";
|
|
27
|
-
import type { TaskLease } from "../domain/task-lease.ts";
|
|
33
|
+
import type { TaskLease, TaskLeaseView } from "../domain/task-lease.ts";
|
|
28
34
|
import {
|
|
29
35
|
normalizeProjectRoot,
|
|
36
|
+
type RegisterTaskProjectInput,
|
|
37
|
+
type TaskProject,
|
|
30
38
|
type TaskScopeSource,
|
|
31
39
|
type TaskViewMode,
|
|
32
40
|
type TaskViewSelection,
|
|
33
41
|
taskScopeLabel,
|
|
34
42
|
} from "../domain/task-scope.ts";
|
|
43
|
+
import { type TransitionTable, validateTransitionFrom } from "../domain-service-shared.ts";
|
|
35
44
|
import type { GateRunner } from "../stores/gate-runner.ts";
|
|
45
|
+
import {
|
|
46
|
+
InMemoryTaskCreateRequestStore,
|
|
47
|
+
TaskCreateIdempotencyConflictError,
|
|
48
|
+
type TaskCreateRequestStore,
|
|
49
|
+
} from "../stores/task-create-request-store.ts";
|
|
36
50
|
import { InMemoryTaskEventStore, type TaskEventStore } from "../stores/task-event-store.ts";
|
|
37
51
|
import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "../stores/task-focus-store.ts";
|
|
38
52
|
import { InMemoryTaskLeaseStore, type TaskLeaseStore } from "../stores/task-lease-store.ts";
|
|
@@ -61,6 +75,14 @@ export interface TaskFilter {
|
|
|
61
75
|
|
|
62
76
|
export type TaskStatus = TaskLifecycleStatus;
|
|
63
77
|
|
|
78
|
+
export class TaskProjectNotFoundError extends Error {}
|
|
79
|
+
export class TaskProjectAmbiguousError extends Error {}
|
|
80
|
+
|
|
81
|
+
export interface CreateTaskRequestContext {
|
|
82
|
+
key?: string;
|
|
83
|
+
caller?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
export interface CreateTaskInput {
|
|
65
87
|
id?: string;
|
|
66
88
|
title: string;
|
|
@@ -128,7 +150,7 @@ export interface TaskGraph {
|
|
|
128
150
|
scope?: TaskViewSelection;
|
|
129
151
|
}
|
|
130
152
|
|
|
131
|
-
const TASK_TRANSITIONS:
|
|
153
|
+
const TASK_TRANSITIONS: TransitionTable<TaskTransition, TaskStatus> = {
|
|
132
154
|
start: { from: ["todo"], to: "in-progress" },
|
|
133
155
|
submit: { from: ["in-progress"], to: "review" },
|
|
134
156
|
reject: { from: ["review"], to: "rejected" },
|
|
@@ -147,6 +169,18 @@ const TASK_TRANSITIONS: Record<TaskTransition, { from: TaskStatus[]; to: TaskSta
|
|
|
147
169
|
reopen: { from: ["canceled"], to: "todo" },
|
|
148
170
|
};
|
|
149
171
|
|
|
172
|
+
function canonicalJson(value: unknown): string {
|
|
173
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
174
|
+
if (typeof value === "object" && value !== null) {
|
|
175
|
+
return `{${Object.entries(value)
|
|
176
|
+
.filter(([, entry]) => entry !== undefined)
|
|
177
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
178
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
|
|
179
|
+
.join(",")}}`;
|
|
180
|
+
}
|
|
181
|
+
return JSON.stringify(value) ?? "null";
|
|
182
|
+
}
|
|
183
|
+
|
|
150
184
|
export class Tasks {
|
|
151
185
|
constructor(
|
|
152
186
|
private readonly artifacts: ArtifactStore,
|
|
@@ -155,6 +189,7 @@ export class Tasks {
|
|
|
155
189
|
private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
|
|
156
190
|
private readonly scopes: TaskScopeStore = new InMemoryTaskScopeStore(),
|
|
157
191
|
private readonly leases: TaskLeaseStore = new InMemoryTaskLeaseStore(),
|
|
192
|
+
private readonly createRequests: TaskCreateRequestStore = new InMemoryTaskCreateRequestStore(),
|
|
158
193
|
) {}
|
|
159
194
|
|
|
160
195
|
private require(id: string): Artifact {
|
|
@@ -164,8 +199,30 @@ export class Tasks {
|
|
|
164
199
|
return artifact;
|
|
165
200
|
}
|
|
166
201
|
|
|
167
|
-
create(input: CreateTaskInput, context: TaskEventContext = {}): Artifact {
|
|
202
|
+
create(input: CreateTaskInput, context: TaskEventContext = {}, request: CreateTaskRequestContext = {}): Artifact {
|
|
168
203
|
return this.events.atomic(() => {
|
|
204
|
+
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
205
|
+
const key = request.key?.trim();
|
|
206
|
+
if (request.key !== undefined && (!key || key.length > TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH)) {
|
|
207
|
+
throw new Error(`idempotency key must be between 1 and ${TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH} characters`);
|
|
208
|
+
}
|
|
209
|
+
const now = new Date().toISOString();
|
|
210
|
+
const scope = `${request.caller?.trim() || "anonymous"}\u0000${projectRoot ?? "unscoped"}`;
|
|
211
|
+
const requestHash = key
|
|
212
|
+
? createHash("sha256")
|
|
213
|
+
.update(canonicalJson({ ...input, projectRoot }))
|
|
214
|
+
.digest("hex")
|
|
215
|
+
: undefined;
|
|
216
|
+
if (key && requestHash) {
|
|
217
|
+
this.createRequests.prune(now);
|
|
218
|
+
const replay = this.createRequests.get(scope, key, now);
|
|
219
|
+
if (replay) {
|
|
220
|
+
if (replay.requestHash !== requestHash) {
|
|
221
|
+
throw new TaskCreateIdempotencyConflictError(`idempotency key "${key}" was already used with a different task payload`);
|
|
222
|
+
}
|
|
223
|
+
return JSON.parse(replay.responseJson) as Artifact;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
169
226
|
if ((input.dependsOn?.length ?? 0) > TASK_EXECUTION_MAX_DEGREE) {
|
|
170
227
|
throw new Error(`task cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
|
|
171
228
|
}
|
|
@@ -174,7 +231,6 @@ export class Tasks {
|
|
|
174
231
|
const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
|
|
175
232
|
if (input.gates !== undefined) extra.gates = validateGates(input.gates);
|
|
176
233
|
if (input.checklist !== undefined) extra.checklist = validateChecklist(input.checklist);
|
|
177
|
-
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
178
234
|
if (input.parentId && this.scopes.get(input.parentId)?.projectRoot !== projectRoot) {
|
|
179
235
|
throw new Error(`parent task "${input.parentId}" is outside project scope`);
|
|
180
236
|
}
|
|
@@ -193,7 +249,18 @@ export class Tasks {
|
|
|
193
249
|
if (input.parentId) this.contain(input.parentId, task.id);
|
|
194
250
|
for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
|
|
195
251
|
this.appendEvent({ taskId: task.id, type: "created", toStatus: task.status as TaskStatus }, context);
|
|
196
|
-
|
|
252
|
+
const created = this.show(task.id);
|
|
253
|
+
if (key && requestHash) {
|
|
254
|
+
this.createRequests.put({
|
|
255
|
+
scope,
|
|
256
|
+
key,
|
|
257
|
+
requestHash,
|
|
258
|
+
responseJson: JSON.stringify(created),
|
|
259
|
+
createdAt: now,
|
|
260
|
+
expiresAt: new Date(Date.parse(now) + TASK_CREATE_IDEMPOTENCY_RETENTION_MS).toISOString(),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return created;
|
|
197
264
|
});
|
|
198
265
|
}
|
|
199
266
|
|
|
@@ -396,6 +463,51 @@ export class Tasks {
|
|
|
396
463
|
};
|
|
397
464
|
}
|
|
398
465
|
|
|
466
|
+
projects(query?: string, limit = 20): TaskProject[] {
|
|
467
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > TASK_PROJECT_LIST_MAX_RESULTS) {
|
|
468
|
+
throw new Error(`project list limit must be between 1 and ${TASK_PROJECT_LIST_MAX_RESULTS}`);
|
|
469
|
+
}
|
|
470
|
+
return this.scopes.projects(query, limit);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
resolveProject(reference: string): TaskProject {
|
|
474
|
+
const matches = this.scopes.matchingProjects(reference);
|
|
475
|
+
if (matches.length === 0) {
|
|
476
|
+
const candidates = this.scopes.projects(reference, 10);
|
|
477
|
+
const fallback = candidates.length === 0 ? this.scopes.projects(undefined, 10) : candidates;
|
|
478
|
+
const suffix =
|
|
479
|
+
fallback.length === 0 ? "" : ` Candidates: ${fallback.map((project) => `${project.name} (${project.projectRoot})`).join(", ")}`;
|
|
480
|
+
throw new TaskProjectNotFoundError(`no task project named or aliased "${reference}" is registered.${suffix}`);
|
|
481
|
+
}
|
|
482
|
+
if (matches.length > 1) {
|
|
483
|
+
throw new TaskProjectAmbiguousError(
|
|
484
|
+
`task project reference "${reference}" is ambiguous: ${matches
|
|
485
|
+
.slice(0, 10)
|
|
486
|
+
.map((project) => `${project.name} (${project.projectRoot})`)
|
|
487
|
+
.join(", ")}`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return matches[0]!;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
registerProject(input: RegisterTaskProjectInput, existingReference?: string): TaskProject {
|
|
494
|
+
const projectRoot = normalizeProjectRoot(input.projectRoot);
|
|
495
|
+
const name = input.name?.trim();
|
|
496
|
+
if (name !== undefined && (name.length === 0 || name.length > TASK_PROJECT_NAME_MAX_LENGTH)) {
|
|
497
|
+
throw new Error(`project name must be between 1 and ${TASK_PROJECT_NAME_MAX_LENGTH} characters`);
|
|
498
|
+
}
|
|
499
|
+
if ((input.aliases?.length ?? 0) > TASK_PROJECT_ALIAS_MAX_COUNT) {
|
|
500
|
+
throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
|
|
501
|
+
}
|
|
502
|
+
for (const alias of input.aliases ?? []) {
|
|
503
|
+
if (alias.trim().length === 0 || alias.length > TASK_PROJECT_NAME_MAX_LENGTH) {
|
|
504
|
+
throw new Error(`each project alias must be between 1 and ${TASK_PROJECT_NAME_MAX_LENGTH} characters`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const existingId = existingReference ? this.resolveProject(existingReference).id : input.existingId;
|
|
508
|
+
return this.scopes.registerProject({ projectRoot, ...(name ? { name } : {}), aliases: input.aliases, existingId });
|
|
509
|
+
}
|
|
510
|
+
|
|
399
511
|
show(id: string): Artifact {
|
|
400
512
|
this.require(id);
|
|
401
513
|
return this.artifacts.get(id, { tree: true })!;
|
|
@@ -480,15 +592,21 @@ export class Tasks {
|
|
|
480
592
|
return this.focusStore.reapStale(cutoff);
|
|
481
593
|
}
|
|
482
594
|
|
|
595
|
+
private presentLease(lease: TaskLease): TaskLeaseView {
|
|
596
|
+
const task = this.require(lease.taskId);
|
|
597
|
+
const { taskId: _taskId, ...details } = lease;
|
|
598
|
+
return { taskName: task.alias, taskTitle: task.title, ...details };
|
|
599
|
+
}
|
|
600
|
+
|
|
483
601
|
/** A lease is orthogonal to lifecycle and Focus: claiming a task does not start it, and does not require it to be Focused. */
|
|
484
|
-
claimLease(id: string, owner: string, ttlMs?: number, note?: string):
|
|
602
|
+
claimLease(id: string, owner: string, ttlMs?: number, note?: string): TaskLeaseView {
|
|
485
603
|
this.require(id);
|
|
486
|
-
return this.leases.claim(id, owner, ttlMs, note);
|
|
604
|
+
return this.presentLease(this.leases.claim(id, owner, ttlMs, note));
|
|
487
605
|
}
|
|
488
606
|
|
|
489
|
-
heartbeatLease(id: string, owner: string, token: string, ttlMs?: number):
|
|
607
|
+
heartbeatLease(id: string, owner: string, token: string, ttlMs?: number): TaskLeaseView {
|
|
490
608
|
this.require(id);
|
|
491
|
-
return this.leases.heartbeat(id, owner, token, ttlMs);
|
|
609
|
+
return this.presentLease(this.leases.heartbeat(id, owner, token, ttlMs));
|
|
492
610
|
}
|
|
493
611
|
|
|
494
612
|
/** Idempotent for an already-absent or already-expired lease, matching undepend/uncontain's precedent -- never throws merely because there was nothing left to release. */
|
|
@@ -497,9 +615,10 @@ export class Tasks {
|
|
|
497
615
|
return this.leases.release(id, owner, token);
|
|
498
616
|
}
|
|
499
617
|
|
|
500
|
-
getLease(id: string):
|
|
618
|
+
getLease(id: string): TaskLeaseView | undefined {
|
|
501
619
|
this.require(id);
|
|
502
|
-
|
|
620
|
+
const lease = this.leases.get(id);
|
|
621
|
+
return lease ? this.presentLease(lease) : undefined;
|
|
503
622
|
}
|
|
504
623
|
|
|
505
624
|
reapStaleLeases(now: () => string = () => new Date().toISOString()): number {
|
|
@@ -509,8 +628,7 @@ export class Tasks {
|
|
|
509
628
|
transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
|
|
510
629
|
return this.events.atomic(() => {
|
|
511
630
|
const task = this.require(id);
|
|
512
|
-
const transition = TASK_TRANSITIONS
|
|
513
|
-
if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
|
|
631
|
+
const transition = validateTransitionFrom("task", action, task.status, TASK_TRANSITIONS);
|
|
514
632
|
if (action === "start") {
|
|
515
633
|
const blocking = this.dependencyIds(id)
|
|
516
634
|
.map((dependencyId) => this.require(dependencyId))
|