@danypops/papyrus 0.54.2 → 0.54.4

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/src/ops.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  * ops.ts — typed operations over the Papyrus DB.
3
3
  * Enforces the schema protocol (kinds, statuses, relations) via FK + app validation.
4
4
  */
5
- import { createRequire } from "node:module";
6
5
  import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./artifact/artifact.ts";
7
6
  import { generateUniqueAlias, isValidAlias, slugify } from "./artifact/artifact-alias.ts";
8
7
  import type { ArtifactTrashRecord } from "./artifact/artifact-trash.ts";
@@ -22,25 +21,11 @@ import {
22
21
  normalizeArtifactEventQuery,
23
22
  resolveArtifactEvent,
24
23
  } from "./artifact/artifact-event.ts";
25
- import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
26
24
 
27
25
  export type { Artifact } from "./artifact/artifact.ts";
28
- export type { Gate, GateResult } from "./domain/gate.ts";
29
26
  export type CreateInput = CreateArtifactInput;
30
27
 
31
- import {
32
- DEFAULT_GRAPH_DEPTH,
33
- DEFAULT_GRAPH_MAX_NODES,
34
- GATE_COMMAND_TIMEOUT_MS,
35
- GATE_FILE_MAX_BYTES,
36
- GATE_MAX_BUFFER_BYTES,
37
- GATE_OUTPUT_LIMIT,
38
- GATE_TEST_TIMEOUT_MS,
39
- MAX_GRAPH_DEPTH,
40
- MAX_GRAPH_NODES,
41
- } from "./constants.ts";
42
-
43
- const require_ = createRequire(import.meta.url);
28
+ import { DEFAULT_GRAPH_DEPTH, DEFAULT_GRAPH_MAX_NODES, MAX_GRAPH_DEPTH, MAX_GRAPH_NODES } from "./constants.ts";
44
29
 
45
30
  interface ResolvedCreateInput extends CreateInput {
46
31
  kind: string;
@@ -592,185 +577,3 @@ export function injectableRules(db: Db): Array<{ id: string; title: string; body
592
577
  return { id: art.id, title: art.title, body: art.body, extra: art.extra };
593
578
  });
594
579
  }
595
-
596
- function readBoundedGateFile(path: string): string {
597
- const { readFileSync, statSync } = require_("node:fs");
598
- if (statSync(path).size > GATE_FILE_MAX_BYTES) throw new Error(`file exceeds ${GATE_FILE_MAX_BYTES} bytes`);
599
- return readFileSync(path, "utf-8") as string;
600
- }
601
-
602
- /**
603
- * Shared by the sync and async process-gate runners so "test" is never a second, independently
604
- * maintained copy of "command"'s own command-template selection.
605
- *
606
- * "test" runs `gate.target` verbatim, exactly like "command" -- the only real difference is a
607
- * more generous default timeout (GATE_TEST_TIMEOUT_MS vs GATE_COMMAND_TIMEOUT_MS), since a test
608
- * suite routinely runs longer than an arbitrary command. It previously wrapped target in
609
- * `npx vitest run ${target} --reporter=dot`, silently wrong for every real consumer in this
610
- * ecosystem (all Bun-native, none use vitest): a target that was itself a full command (e.g.
611
- * `bun test path/to.test.ts`, exactly what every existing gate/checklist example here has always
612
- * shown) got parsed by vitest as three separate positional args, triggering vitest's own broad
613
- * discovery across the whole repo instead of running the intended command at all -- a real
614
- * incident (task ab1463e2) that produced an unrelated multi-suite vitest failure cascade instead
615
- * of the actual target ever running.
616
- */
617
- function processGateCommand(gate: Gate): { command: string; timeout: number } {
618
- if (gate.type === "test") return { command: gate.target, timeout: gate.timeoutMs ?? GATE_TEST_TIMEOUT_MS };
619
- return { command: gate.target, timeout: gate.timeoutMs ?? GATE_COMMAND_TIMEOUT_MS };
620
- }
621
-
622
- /**
623
- * spawnSync + manual stdout/stderr concatenation, not execSync: execSync's return value is stdout
624
- * only. Many real commands (bun test's own per-test lines and its pass/fail summary among them)
625
- * write their actual output to stderr, so an execSync-based match against gate.expect saw only the
626
- * first line of a banner and never the result -- every such gate failed regardless of whether the
627
- * command actually passed. This one function now serves both "command" and "test" gates;
628
- * previously "test" was a second, separately-maintained execSync path that never checked
629
- * gate.expect at all.
630
- */
631
- /**
632
- * Keeps the LAST GATE_OUTPUT_LIMIT characters, not the first -- a real command's own meaningful
633
- * pass/fail summary is its last lines, not its first (setup/banner noise). See GATE_OUTPUT_LIMIT's
634
- * own doc comment (constants.ts) for the real incident this fixes.
635
- */
636
- function gateOutputTail(text: string): string {
637
- return text.length > GATE_OUTPUT_LIMIT ? text.slice(-GATE_OUTPUT_LIMIT) : text;
638
- }
639
-
640
- /**
641
- * The one place "did this process gate pass, and what should its display output say" is decided,
642
- * shared by the sync (runProcessGateSync) and async (executeGateCommand's caller) process-gate
643
- * runners -- previously two hand-copied inline checks that already had to be fixed twice, by
644
- * hand, more than once (gate.expect seeing stderr too; GATE_OUTPUT_LIMIT's truncation direction).
645
- * `matchable` must be the FULL captured output (bounded only by GATE_MAX_BUFFER_BYTES / Node's own
646
- * spawnSync maxBuffer, never GATE_OUTPUT_LIMIT), so gate.expect always sees the whole run, never
647
- * the truncated display copy `output` becomes.
648
- */
649
- function evaluateProcessGateResult(gate: Gate, code: number | null, matchable: string): { passed: boolean; output: string } {
650
- const exitedZero = code === 0;
651
- return {
652
- passed: exitedZero && (gate.expect ? matchable.includes(gate.expect) : true),
653
- output: gateOutputTail(matchable) || (exitedZero ? "ok" : `command exited with code ${code}`),
654
- };
655
- }
656
-
657
- /** The other shared branch: a literal spawn-level failure (command not found, spawnSync's own maxBuffer exceeded, etc.) -- distinct from a process that ran and exited non-zero, which evaluateProcessGateResult above handles. Identical treatment on both the sync and async paths: the raw error message, tail-truncated like any other gate output. */
658
- function spawnErrorGateResult(gate: Gate, error: Error): GateResult {
659
- return { gate, passed: false, output: gateOutputTail(error.message) };
660
- }
661
-
662
- function runProcessGateSync(gate: Gate, cwd?: string): GateResult {
663
- const { spawnSync } = require_("node:child_process");
664
- const { command, timeout } = processGateCommand(gate);
665
- const result = spawnSync(command, { shell: true, encoding: "utf-8", timeout, ...(cwd ? { cwd } : {}) });
666
- if (result.error) return spawnErrorGateResult(gate, result.error);
667
- const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
668
- return { gate, ...evaluateProcessGateResult(gate, result.status, combined) };
669
- }
670
-
671
- export function runGates(db: Db, artifactId: string, options: GateRunOptions = {}): GateResult[] {
672
- const art = getArtifact(db, artifactId);
673
- if (!art) throw new Error("artifact not found");
674
- const gates = (art.extra.gates as Gate[]) ?? [];
675
- const cwd = options.cwd;
676
- return gates.map((gate) => (gate.type === "command" || gate.type === "test" ? runProcessGateSync(gate, cwd) : runNonProcessGate(gate)));
677
- }
678
-
679
- /**
680
- * Runs one gate command with two invariants a prior implementation lacked (a real incident; see
681
- * GateRunOptions.cwd's doc comment):
682
- * 1. `cwd` is always explicit, never inherited from the daemon's own process cwd.
683
- * 2. The whole process group is killed on timeout, not just the immediate shell. `exec()`'s own
684
- * `timeout` option only signals the process it directly spawned (the shell running
685
- * `command`); a shell's own child (e.g. `bun` under `sh -c "bun test"`) is not in general
686
- * killed by that signal and can be reparented and keep running -- and consuming memory --
687
- * indefinitely after Papyrus considers the gate "timed out". Spawning detached (its own
688
- * process group) and killing the negated pid on our own timer reaches the whole tree.
689
- */
690
- function executeGateCommand(gate: Gate, command: string, timeout: number, cwd?: string): Promise<GateResult> {
691
- // `spawn(..., { shell: true, detached: true })` instead of the `exec()` convenience wrapper:
692
- // `detached` (needed to make the shell the leader of its own process group, so the negated pid
693
- // below reaches every descendant, not just the shell) is not part of Node's `exec()`/
694
- // `ExecOptions` type at all -- `spawn`'s options support it directly and correctly.
695
- const { spawn } = require_("node:child_process") as typeof import("node:child_process");
696
- return new Promise((resolve) => {
697
- let settled = false;
698
- let buffered = "";
699
- let truncated = false;
700
- const child = spawn(command, { shell: true, detached: true, ...(cwd ? { cwd } : {}) });
701
-
702
- const append = (chunk: Buffer): void => {
703
- if (truncated) return;
704
- buffered += chunk.toString("utf8");
705
- if (buffered.length > GATE_MAX_BUFFER_BYTES) {
706
- buffered = buffered.slice(0, GATE_MAX_BUFFER_BYTES);
707
- truncated = true;
708
- }
709
- };
710
- child.stdout?.on("data", append);
711
- child.stderr?.on("data", append);
712
-
713
- const finish = (result: GateResult): void => {
714
- if (settled) return;
715
- settled = true;
716
- clearTimeout(timer);
717
- resolve(result);
718
- };
719
-
720
- child.on("error", (error) => finish(spawnErrorGateResult(gate, error)));
721
- child.on("close", (code) => finish({ gate, ...evaluateProcessGateResult(gate, code, buffered.trim()) }));
722
-
723
- const timer = setTimeout(() => {
724
- if (settled) return;
725
- if (child.pid !== undefined) {
726
- try {
727
- process.kill(-child.pid, "SIGKILL");
728
- } catch {
729
- child.kill("SIGKILL");
730
- }
731
- }
732
- finish({ gate, passed: false, output: `gate command timed out after ${timeout}ms` });
733
- }, timeout);
734
- });
735
- }
736
-
737
- function runNonProcessGate(gate: Gate): GateResult {
738
- if (gate.type === "file-exists") {
739
- const { existsSync } = require_("node:fs");
740
- const exists = existsSync(gate.target);
741
- return { gate, passed: exists, output: exists ? "exists" : "not found" };
742
- }
743
- if (gate.type === "contains") {
744
- try {
745
- const content = readBoundedGateFile(gate.target);
746
- const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
747
- return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
748
- } catch {
749
- return { gate, passed: false, output: "file not readable" };
750
- }
751
- }
752
- return { gate, passed: false, output: `unknown gate type: ${String(gate.type)}` };
753
- }
754
-
755
- /** Gate runner for daemon request paths; subprocess gates never block the event loop. */
756
- export async function runGatesAsync(db: Db, artifactId: string, options: GateRunOptions = {}): Promise<GateResult[]> {
757
- const art = getArtifact(db, artifactId);
758
- if (!art) throw new Error("artifact not found");
759
- const gates = (art.extra.gates as Gate[]) ?? [];
760
- const results: GateResult[] = [];
761
- for (const gate of gates) {
762
- const remainingMs = options.deadlineMs === undefined ? undefined : options.deadlineMs - Date.now();
763
- if (remainingMs !== undefined && remainingMs <= 0) {
764
- results.push({ gate, passed: false, output: "gate runtime deadline exceeded" });
765
- continue;
766
- }
767
- if (gate.type === "command" || gate.type === "test") {
768
- const { command, timeout: configuredTimeout } = processGateCommand(gate);
769
- const timeout = remainingMs === undefined ? configuredTimeout : Math.max(1, Math.min(configuredTimeout, remainingMs));
770
- results.push(await executeGateCommand(gate, command, timeout, options.cwd));
771
- } else {
772
- results.push(runNonProcessGate(gate));
773
- }
774
- }
775
- return results;
776
- }
package/src/service.ts CHANGED
@@ -594,7 +594,7 @@ export function createApp(deps: {
594
594
  * (daemon.ts) wires this to a PushChannel; tests and other embedders can ignore it.
595
595
  */
596
596
  onOperationExecuted?: (operation: string, input: OperationInput) => void;
597
- /** Defaults to a no-op (createVehicleHttpApp's own default) -- daemon.ts wires vehicleLogger() so a failed invocation is actually logged, not silently discarded. */
597
+ /** Defaults to a no-op (createVehicleHttpApp's own default) -- daemon.ts wires log/log.ts's own `logger` so a failed invocation is actually logged, not silently discarded. */
598
598
  logger?: Logger;
599
599
  /**
600
600
  * Backs GET /daemon/diagnose -- "who am I, and what happened recently" (see
@@ -1,6 +1,6 @@
1
1
  import type { Db } from "../db.ts";
2
2
  import type { GateResult, GateRunOptions } from "../domain/gate.ts";
3
- import { runGates, runGatesAsync } from "../ops.ts";
3
+ import { runGates, runGatesAsync } from "../domain/gate-execution.ts";
4
4
  import type { GateRunner } from "./gate-runner.ts";
5
5
 
6
6
  export class SQLiteGateRunner implements GateRunner {
@@ -56,6 +56,10 @@ export class InMemoryTaskMutationRequestStore implements TaskMutationRequestStor
56
56
  }
57
57
 
58
58
  put(record: TaskMutationRequestRecord): void {
59
+ // Checked in the same order sqlite-task-mutation-request-store.ts's own catch-and-reclassify
60
+ // does: a still-pending (taskId, operation) always becomes the more specific
61
+ // TaskMutationPendingError first, regardless of which underlying constraint actually
62
+ // collided (SQLite's partial unique pending index, or the PRIMARY KEY check below).
59
63
  if (record.state === "pending" && record.taskId) {
60
64
  const existing = this.findPending(record.taskId, record.operation, record.createdAt);
61
65
  if (existing) {
@@ -66,7 +70,18 @@ export class InMemoryTaskMutationRequestStore implements TaskMutationRequestStor
66
70
  );
67
71
  }
68
72
  }
69
- this.records.set(this.recordKey(record.scope, record.key), { ...record });
73
+ // Mirror SQLite's own PRIMARY KEY (request_scope, idempotency_key): a genuine duplicate
74
+ // (scope, key) is always rejected, never silently overwritten -- SQLite throws on any such
75
+ // collision regardless of whether the colliding row's other columns match, so this does too,
76
+ // rather than only checking receiptId. Every real write path already dedupes via
77
+ // mutationRequests.get() before ever calling put() twice for the same (scope, key)
78
+ // (task-service.ts's prepareMutation()), so this is unreachable through normal application
79
+ // flow today -- it only guards a caller that talks to the store interface directly.
80
+ const recordKey = this.recordKey(record.scope, record.key);
81
+ if (this.records.has(recordKey)) {
82
+ throw new Error(`task mutation request already exists for scope "${record.scope}" key "${record.key}"`);
83
+ }
84
+ this.records.set(recordKey, { ...record });
70
85
  }
71
86
 
72
87
  complete(scope: string, key: string, responseJson: string, updatedAt: string): void {
@@ -0,0 +1,106 @@
1
+ import type { Artifact, ArtifactEdge } from "../artifact/artifact.ts";
2
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
3
+ import { TASK_EXECUTION_MAX_DEGREE } from "../constants.ts";
4
+ import type { AppendTaskEvent, TaskEventContext } from "../domain/task-event.ts";
5
+ import type { TaskEventStore } from "../stores/task-event-store.ts";
6
+ import { assertDependencyEdgeAllowed, TaskExecutionBoundExceededError } from "./task-execution.ts";
7
+ // Type-only import: erased entirely at compile time, so this does not create a real runtime
8
+ // circular dependency even though task-service.ts also imports TaskEdges (a real value) from
9
+ // this file -- only one direction of this pair carries an actual runtime import.
10
+ import type { TaskGraph } from "./task-service.ts";
11
+
12
+ /**
13
+ * Task dependency/containment edge mutations (depend/undepend/contain/uncontain), split out of
14
+ * the Tasks god class as part of a SOLID-audit-driven decomposition (see task b51419a0 and the
15
+ * "TaskEdges" child of "Epic: Modularize papyrus/pi-papyrus god-files into building-block
16
+ * modules"), mirroring the TaskLeaseCoordinator/TaskMutationCoordinator/TaskFocusCoordinator/
17
+ * TaskProjectScope precedent in this same directory.
18
+ *
19
+ * Unlike those simpler extractions, this one only owns the mutation side of edges -- it reads the
20
+ * graph/relationships it needs through injected callbacks (dependencyCheckGraph/dependencyIds/
21
+ * relationships) rather than duplicating that graph-construction machinery, since those reads are
22
+ * shared with concerns that stay on Tasks (list/graph/buildGraph, progress propagation, blockage
23
+ * checks in transition/complete).
24
+ */
25
+ export class TaskEdges {
26
+ constructor(
27
+ private readonly artifacts: Pick<ArtifactStore, "link" | "unlink">,
28
+ private readonly events: TaskEventStore,
29
+ /** Delegates to Tasks.require() so edge methods get the identical not-found/wrong-kind checks every other Tasks method already enforces, without duplicating that logic here. */
30
+ private readonly requireTask: (id: string) => Artifact,
31
+ /** Delegates to Tasks.show() -- every edge mutation returns the affected task's own current (post-mutation) view. */
32
+ private readonly showTask: (id: string) => Artifact,
33
+ /** Delegates to Tasks' own actor/source/sessionId/reason defaulting so every event this collaborator appends looks identical to one Tasks itself would have appended. */
34
+ private readonly appendEvent: (event: Omit<AppendTaskEvent, "actor" | "source">, context: TaskEventContext) => void,
35
+ /** Delegates to Tasks' own private dependencyCheckGraph() -- project-scoped cycle-check graph construction, which itself composes list()/graph()/buildGraph(), all of which stay on Tasks. */
36
+ private readonly dependencyCheckGraph: (id: string, dependencyId: string) => TaskGraph,
37
+ /** Delegates to Tasks' own private dependencyIds() -- bounded prerequisite lookup, also used outside edge mutations (transition's blockage check). */
38
+ private readonly dependencyIds: (id: string) => string[],
39
+ /** Delegates to Tasks' own private relationships() -- bounded relationship lookup, also used outside edge mutations (parentIds/progress propagation). */
40
+ private readonly relationships: (id: string) => ArtifactEdge[],
41
+ ) {}
42
+
43
+ depend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
44
+ return this.events.atomic(() => {
45
+ this.requireTask(id);
46
+ this.requireTask(dependencyId);
47
+ const graph = this.dependencyCheckGraph(id, dependencyId);
48
+ assertDependencyEdgeAllowed(graph, id, dependencyId);
49
+ const node = graph.nodes.find((entry) => entry.task.id === id)!;
50
+ if (node.dependencyIds.includes(dependencyId)) return this.showTask(id);
51
+ if (node.dependencyIds.length >= TASK_EXECUTION_MAX_DEGREE) {
52
+ throw new TaskExecutionBoundExceededError(`task "${id}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
53
+ }
54
+ const successorCount = graph.nodes.filter((entry) => entry.dependencyIds.includes(dependencyId)).length;
55
+ if (successorCount >= TASK_EXECUTION_MAX_DEGREE) {
56
+ throw new TaskExecutionBoundExceededError(`task "${dependencyId}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} successors`);
57
+ }
58
+ this.artifacts.link({ from: id, relation: "depends_on", to: dependencyId }, context);
59
+ this.appendEvent({ taskId: id, type: "dependency_added", reason: context.reason }, context);
60
+ return this.showTask(id);
61
+ });
62
+ }
63
+
64
+ /** Idempotent: undepending an already-absent dependency is a no-op. Never starts, completes, or focuses work — only removes the edge. */
65
+ undepend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
66
+ return this.events.atomic(() => {
67
+ const task = this.requireTask(id);
68
+ const dependency = this.requireTask(dependencyId);
69
+ const removed = this.artifacts.unlink({ from: id, relation: "depends_on", to: dependencyId }, context);
70
+ if (removed) this.appendEvent({ taskId: id, type: "dependency_removed", reason: context.reason }, context);
71
+ // Only meaningful if the removed edge was itself unmet -- removing an already-satisfied
72
+ // dependency, or removing one from a task that was already unblocked, changes nothing.
73
+ if (removed && task.status === "todo" && dependency.status !== "done") {
74
+ const stillBlocking = this.dependencyIds(id).filter((remainingId) => this.requireTask(remainingId).status !== "done");
75
+ if (stillBlocking.length === 0) this.appendEvent({ taskId: id, type: "became_ready" }, context);
76
+ }
77
+ return this.showTask(id);
78
+ });
79
+ }
80
+
81
+ contain(parentId: string, childId: string, context: TaskEventContext = {}): Artifact {
82
+ return this.events.atomic(() => {
83
+ this.requireTask(parentId);
84
+ this.requireTask(childId);
85
+ const alreadyContained = this.relationships(parentId).some(
86
+ (edge) => edge.relation === "contains" && edge.from === parentId && edge.to === childId,
87
+ );
88
+ this.artifacts.link({ from: parentId, relation: "contains", to: childId }, context);
89
+ this.artifacts.link({ from: childId, relation: "part_of", to: parentId }, context);
90
+ if (!alreadyContained) this.appendEvent({ taskId: parentId, type: "containment_added", reason: context.reason }, context);
91
+ return this.showTask(parentId);
92
+ });
93
+ }
94
+
95
+ /** Idempotent: removing an already-absent containment is a no-op. Both contains/part_of edges are removed atomically. */
96
+ uncontain(parentId: string, childId: string, context: TaskEventContext = {}): Artifact {
97
+ return this.events.atomic(() => {
98
+ this.requireTask(parentId);
99
+ this.requireTask(childId);
100
+ const removedContains = this.artifacts.unlink({ from: parentId, relation: "contains", to: childId }, context);
101
+ this.artifacts.unlink({ from: childId, relation: "part_of", to: parentId }, context);
102
+ if (removedContains) this.appendEvent({ taskId: parentId, type: "containment_removed", reason: context.reason }, context);
103
+ return this.showTask(parentId);
104
+ });
105
+ }
106
+ }
@@ -0,0 +1,189 @@
1
+ import type { Artifact } from "../artifact/artifact.ts";
2
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
3
+ import { TASK_FOCUS_STALE_AFTER_MS } from "../constants.ts";
4
+ import { type AppendTaskEvent, type TaskEventContext, validateEventContext } from "../domain/task-event.ts";
5
+ import type { TaskEventStore } from "../stores/task-event-store.ts";
6
+ import type { TaskFocusStatus, TaskFocusStore } from "../stores/task-focus-store.ts";
7
+ import { TaskInvalidTransitionError } from "./task-lifecycle-errors.ts";
8
+ import type { TaskMutationCoordinator, TaskMutationRequestContext } from "./task-mutation-coordinator.ts";
9
+ // Type-only import: erased entirely at compile time, so this does not create a real runtime
10
+ // circular dependency even though task-service.ts also imports TaskFocusCoordinator (a real
11
+ // value) from this file -- only one direction of this pair carries an actual runtime import.
12
+ import type { TaskFilter, TaskMutationMetadata } from "./task-service.ts";
13
+
14
+ export interface TaskFocus {
15
+ artifact: Artifact;
16
+ status: TaskFocusStatus;
17
+ updatedAt: string;
18
+ pauseReason?: string;
19
+ }
20
+
21
+ export type TaskFocusMutationResult = TaskFocus & TaskMutationMetadata;
22
+
23
+ /**
24
+ * Task Focus (the single active/paused task per session scope), split out of the Tasks god class
25
+ * as part of a SOLID-audit-driven decomposition (see task b51419a0 and the "TaskFocusCoordinator"
26
+ * child of "Epic: Modularize papyrus/pi-papyrus god-files into building-block modules"), mirroring
27
+ * the existing TaskLeaseCoordinator/TaskMutationCoordinator precedent in this same directory.
28
+ *
29
+ * Focus is orthogonal to lifecycle and lease: focusing a task does not start it, and does not
30
+ * claim its lease -- so this concern has nothing to do with status transitions or worker
31
+ * exclusivity, the other concerns that were previously interleaved with it in one class.
32
+ */
33
+ export class TaskFocusCoordinator {
34
+ constructor(
35
+ private readonly artifacts: Pick<ArtifactStore, "get">,
36
+ private readonly focusStore: TaskFocusStore,
37
+ private readonly events: TaskEventStore,
38
+ private readonly mutationCoordinator: TaskMutationCoordinator,
39
+ /** Delegates to Tasks.require() so Focus methods get the identical not-found/wrong-kind checks every other Tasks method already enforces, without duplicating that logic here. */
40
+ private readonly requireTask: (id: string) => Artifact,
41
+ /** Delegates to Tasks.list() for the projectRoot-membership check in focused() -- list() is itself part of the project-scope concern, not duplicated here. */
42
+ private readonly listTasks: (filter?: TaskFilter) => Artifact[],
43
+ /** Delegates to Tasks' own actor/source/sessionId/reason defaulting so every event this coordinator appends looks identical to one Tasks itself would have appended. */
44
+ private readonly appendEvent: (event: Omit<AppendTaskEvent, "actor" | "source">, context: TaskEventContext) => void,
45
+ ) {}
46
+
47
+ focused(filter?: TaskFilter): TaskFocus | null {
48
+ const focus = this.focusStore.get(filter?.sessionId);
49
+ if (!focus) return null;
50
+ const task = this.artifacts.get(focus.taskId);
51
+ if (task?.kind !== "task" || task.status === "done" || task.status === "canceled") {
52
+ this.focusStore.clear(focus.taskId, filter?.sessionId);
53
+ return null;
54
+ }
55
+ if (filter?.projectRoot && !this.listTasks(filter).some((candidate) => candidate.id === task.id)) return null;
56
+ return {
57
+ artifact: task,
58
+ status: focus.status,
59
+ updatedAt: focus.updatedAt,
60
+ ...(focus.pauseReason ? { pauseReason: focus.pauseReason } : {}),
61
+ };
62
+ }
63
+
64
+ active(filter?: TaskFilter): Artifact | null {
65
+ const focus = this.focused(filter);
66
+ return focus?.status === "active" ? focus.artifact : null;
67
+ }
68
+
69
+ focus(id: string, context: TaskEventContext = {}): Artifact {
70
+ return this.events.atomic(() => {
71
+ const task = this.requireTask(id);
72
+ if (task.status === "done" || task.status === "canceled") throw new Error(`cannot focus task from ${task.status}`);
73
+ this.focusStore.set(id, context.sessionId);
74
+ this.appendEvent({ taskId: id, type: "focus_set" }, context);
75
+ return task;
76
+ });
77
+ }
78
+
79
+ pauseFocus(context: TaskEventContext = {}, request: TaskMutationRequestContext = {}): TaskFocusMutationResult {
80
+ const inspection = this.mutationCoordinator.prepare<TaskFocusMutationResult>("pause", undefined, context, request, false);
81
+ if (inspection.replay) return inspection.replay;
82
+ const focus = this.focused({ sessionId: context.sessionId });
83
+ if (!focus) {
84
+ throw new TaskInvalidTransitionError(
85
+ "pause",
86
+ "none",
87
+ "paused",
88
+ ["focus"],
89
+ "Focus a non-terminal task before pausing; do not blindly retry pause.",
90
+ );
91
+ }
92
+ const prepared = inspection.pending
93
+ ? inspection
94
+ : this.mutationCoordinator.prepare<TaskFocusMutationResult>("pause", undefined, context, request, true, () =>
95
+ validateEventContext(context),
96
+ );
97
+ if (focus.status === "paused") {
98
+ return this.mutationCoordinator.complete(prepared.record, {
99
+ ...focus,
100
+ changed: false,
101
+ operation: "pause",
102
+ currentStatus: "paused",
103
+ intendedStatus: "paused",
104
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
105
+ });
106
+ }
107
+ return this.events.atomic(() => {
108
+ const state = this.focusStore.pause(focus.artifact.id, context.reason, context.sessionId);
109
+ this.appendEvent({ taskId: focus.artifact.id, type: "focus_paused" }, context);
110
+ return this.mutationCoordinator.complete(prepared.record, {
111
+ artifact: focus.artifact,
112
+ status: state.status,
113
+ updatedAt: state.updatedAt,
114
+ ...(state.pauseReason ? { pauseReason: state.pauseReason } : {}),
115
+ changed: true,
116
+ operation: "pause",
117
+ currentStatus: "paused",
118
+ intendedStatus: "paused",
119
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
120
+ });
121
+ });
122
+ }
123
+
124
+ unpauseFocus(context: TaskEventContext = {}, request: TaskMutationRequestContext = {}): TaskFocusMutationResult {
125
+ const inspection = this.mutationCoordinator.prepare<TaskFocusMutationResult>("unpause", undefined, context, request, false);
126
+ if (inspection.replay) return inspection.replay;
127
+ const focus = this.focused({ sessionId: context.sessionId });
128
+ if (!focus) {
129
+ throw new TaskInvalidTransitionError(
130
+ "unpause",
131
+ "none",
132
+ "active",
133
+ ["focus"],
134
+ "Focus a non-terminal task before resuming; do not blindly retry unpause.",
135
+ );
136
+ }
137
+ const prepared = inspection.pending
138
+ ? inspection
139
+ : this.mutationCoordinator.prepare<TaskFocusMutationResult>("unpause", undefined, context, request, true, () =>
140
+ validateEventContext(context),
141
+ );
142
+ if (focus.status === "active") {
143
+ return this.mutationCoordinator.complete(prepared.record, {
144
+ ...focus,
145
+ changed: false,
146
+ operation: "unpause",
147
+ currentStatus: "active",
148
+ intendedStatus: "active",
149
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
150
+ });
151
+ }
152
+ return this.events.atomic(() => {
153
+ const state = this.focusStore.unpause(focus.artifact.id, context.sessionId);
154
+ this.appendEvent({ taskId: focus.artifact.id, type: "focus_unpaused" }, context);
155
+ return this.mutationCoordinator.complete(prepared.record, {
156
+ artifact: focus.artifact,
157
+ status: state.status,
158
+ updatedAt: state.updatedAt,
159
+ changed: true,
160
+ operation: "unpause",
161
+ currentStatus: "active",
162
+ intendedStatus: "active",
163
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
164
+ });
165
+ });
166
+ }
167
+
168
+ clearFocus(context: TaskEventContext = {}): { cleared: boolean } {
169
+ return this.events.atomic(() => {
170
+ const focus = this.focusStore.get(context.sessionId);
171
+ if (focus) this.appendEvent({ taskId: focus.taskId, type: "focus_cleared" }, context);
172
+ this.focusStore.clear(undefined, context.sessionId);
173
+ return { cleared: focus !== undefined };
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Time-based reclamation of Focus scopes nobody has touched in TASK_FOCUS_STALE_AFTER_MS,
179
+ * independent of and in addition to the TASK_FOCUS_MAX_SCOPES hard cap -- see
180
+ * clean-up-stale-per-session-task-focus-rows-on-real-session-l-9i7s and constants.ts's
181
+ * comment on why this is deliberately not driven by session_start/session_shutdown.
182
+ * No task-lifecycle event is appended: this is daemon housekeeping, not a caller-driven
183
+ * mutation, and there is no longer a specific session/actor to attribute it to.
184
+ */
185
+ reapStaleFocus(now: () => string = () => new Date().toISOString()): number {
186
+ const cutoff = new Date(new Date(now()).getTime() - TASK_FOCUS_STALE_AFTER_MS).toISOString();
187
+ return this.focusStore.reapStale(cutoff);
188
+ }
189
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared lifecycle-transition error, split into its own file so both task-service.ts (transition/
3
+ * complete) and task-focus-coordinator.ts (pause/unpause) can throw the identical class without a
4
+ * runtime circular import between them -- task-service.ts still re-exports this under the same
5
+ * name for backward compatibility with every existing consumer (src/index.ts's public surface,
6
+ * handlers/tasks.ts's `instanceof` check).
7
+ */
8
+ export class TaskInvalidTransitionError extends Error {
9
+ constructor(
10
+ readonly operation: string,
11
+ readonly currentStatus: string,
12
+ readonly intendedStatus: string,
13
+ readonly allowedActions: readonly string[],
14
+ readonly recovery: string,
15
+ ) {
16
+ super(`cannot ${operation} task from ${currentStatus}; intended status is ${intendedStatus}`);
17
+ }
18
+ }