@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/package.json +12 -1
- package/src/daemon/daemon.ts +2 -2
- package/src/domain/gate-execution.ts +205 -0
- package/src/handlers/artifact-helpers.ts +110 -0
- package/src/handlers/operation-schema.ts +161 -0
- package/src/handlers/paired-mutation.ts +102 -0
- package/src/handlers/shared.ts +47 -433
- package/src/handlers/task-classifiers.ts +88 -0
- package/src/log/log.ts +31 -20
- package/src/ops.ts +1 -198
- package/src/service.ts +1 -1
- package/src/stores/sqlite-gate-runner.ts +1 -1
- package/src/stores/task-mutation-request-store.ts +16 -1
- package/src/task/task-edges.ts +106 -0
- package/src/task/task-focus-coordinator.ts +189 -0
- package/src/task/task-lifecycle-errors.ts +18 -0
- package/src/task/task-project-scope.ts +107 -0
- package/src/task/task-service.ts +64 -251
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
|
|
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 "../
|
|
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
|
-
|
|
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
|
+
}
|