@danypops/papyrus 0.45.3 → 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.
@@ -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: Record<TaskTransition, { from: TaskStatus[]; to: TaskStatus }> = {
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
- return this.show(task.id);
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): TaskLease {
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): TaskLease {
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): TaskLease | undefined {
618
+ getLease(id: string): TaskLeaseView | undefined {
501
619
  this.require(id);
502
- return this.leases.get(id);
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[action];
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))