@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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Artifact } from "../artifact/artifact.ts";
|
|
2
|
+
import { TASK_PROJECT_LIST_MAX_RESULTS } from "../constants.ts";
|
|
3
|
+
import { assertRegisterProjectInputBounds } from "../domain/project-registry.ts";
|
|
4
|
+
import type { AppendTaskEvent, TaskEventContext } from "../domain/task-event.ts";
|
|
5
|
+
import {
|
|
6
|
+
normalizeProjectRoot,
|
|
7
|
+
type RegisterTaskProjectInput,
|
|
8
|
+
type TaskProject,
|
|
9
|
+
type TaskViewMode,
|
|
10
|
+
type TaskViewSelection,
|
|
11
|
+
taskScopeLabel,
|
|
12
|
+
} from "../domain/task-scope.ts";
|
|
13
|
+
import type { TaskEventStore } from "../stores/task-event-store.ts";
|
|
14
|
+
import type { TaskScopeStore } from "../stores/task-scope-store.ts";
|
|
15
|
+
|
|
16
|
+
export class TaskProjectNotFoundError extends Error {}
|
|
17
|
+
export class TaskProjectAmbiguousError extends Error {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Task project-scope management (scopeSelection/setView/assignProject/projects/resolveProject/
|
|
21
|
+
* registerProject), split out of the Tasks god class as part of a SOLID-audit-driven
|
|
22
|
+
* decomposition (see task b51419a0 and the "TaskProjectScope" child of "Epic: Modularize
|
|
23
|
+
* papyrus/pi-papyrus god-files into building-block modules"), mirroring the existing
|
|
24
|
+
* TaskLeaseCoordinator/TaskMutationCoordinator/TaskFocusCoordinator precedent in this directory.
|
|
25
|
+
*
|
|
26
|
+
* list()/graph() (which stay on Tasks -- they're core query/graph-construction, not project-scope
|
|
27
|
+
* itself) still call scopeSelection() on this collaborator via Tasks' own thin delegation.
|
|
28
|
+
*/
|
|
29
|
+
export class TaskProjectScope {
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly scopes: TaskScopeStore,
|
|
32
|
+
private readonly events: TaskEventStore,
|
|
33
|
+
/** Delegates to Tasks.require() so project-scope methods get the identical not-found/wrong-kind checks every other Tasks method already enforces, without duplicating that logic here. */
|
|
34
|
+
private readonly requireTask: (id: string) => Artifact,
|
|
35
|
+
/** Delegates to Tasks' own actor/source/sessionId/reason defaulting so every event this collaborator appends looks identical to one Tasks itself would have appended. */
|
|
36
|
+
private readonly appendEvent: (event: Omit<AppendTaskEvent, "actor" | "source">, context: TaskEventContext) => void,
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
|
|
40
|
+
if (mode !== undefined && mode !== "project" && mode !== "graph" && mode !== "all")
|
|
41
|
+
throw new Error("task scope must be project, graph, or all");
|
|
42
|
+
if (projectRoot === undefined) return { mode: "all", label: taskScopeLabel("all") };
|
|
43
|
+
const normalized = normalizeProjectRoot(projectRoot);
|
|
44
|
+
const persisted = this.scopes.view(normalized);
|
|
45
|
+
const selectedMode = mode ?? persisted.mode;
|
|
46
|
+
const selectedRoot = rootTaskId ?? (selectedMode === "graph" ? persisted.rootTaskId : undefined);
|
|
47
|
+
if (selectedMode === "graph" && !selectedRoot) throw new Error("graph scope requires root_task_id");
|
|
48
|
+
const root = selectedRoot ? this.requireTask(selectedRoot) : undefined;
|
|
49
|
+
if (root && this.scopes.get(root.id)?.projectRoot !== normalized) throw new Error(`task "${root.id}" is outside project scope`);
|
|
50
|
+
return {
|
|
51
|
+
mode: selectedMode,
|
|
52
|
+
label: taskScopeLabel(selectedMode, normalized, root?.title),
|
|
53
|
+
projectRoot: normalized,
|
|
54
|
+
...(selectedRoot === undefined ? {} : { rootTaskId: selectedRoot }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewSelection {
|
|
59
|
+
const selection = this.scopeSelection(projectRoot, mode, rootTaskId);
|
|
60
|
+
this.scopes.setView(selection.projectRoot!, selection.mode, selection.rootTaskId);
|
|
61
|
+
return selection;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
assignProject(id: string, projectRoot: string, context: TaskEventContext = {}): Artifact {
|
|
65
|
+
return this.events.atomic(() => {
|
|
66
|
+
const task = this.requireTask(id);
|
|
67
|
+
this.scopes.assign(id, normalizeProjectRoot(projectRoot), "explicit");
|
|
68
|
+
this.appendEvent({ taskId: id, type: "project_assigned", reason: context.reason }, context);
|
|
69
|
+
return task;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
projects(query?: string, limit = 20): TaskProject[] {
|
|
74
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > TASK_PROJECT_LIST_MAX_RESULTS) {
|
|
75
|
+
throw new Error(`project list limit must be between 1 and ${TASK_PROJECT_LIST_MAX_RESULTS}`);
|
|
76
|
+
}
|
|
77
|
+
return this.scopes.projects(query, limit);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
resolveProject(reference: string): TaskProject {
|
|
81
|
+
const matches = this.scopes.matchingProjects(reference);
|
|
82
|
+
if (matches.length === 0) {
|
|
83
|
+
const candidates = this.scopes.projects(reference, 10);
|
|
84
|
+
const fallback = candidates.length === 0 ? this.scopes.projects(undefined, 10) : candidates;
|
|
85
|
+
const suffix =
|
|
86
|
+
fallback.length === 0 ? "" : ` Candidates: ${fallback.map((project) => `${project.name} (${project.projectRoot})`).join(", ")}`;
|
|
87
|
+
throw new TaskProjectNotFoundError(`no task project named or aliased "${reference}" is registered.${suffix}`);
|
|
88
|
+
}
|
|
89
|
+
if (matches.length > 1) {
|
|
90
|
+
throw new TaskProjectAmbiguousError(
|
|
91
|
+
`task project reference "${reference}" is ambiguous: ${matches
|
|
92
|
+
.slice(0, 10)
|
|
93
|
+
.map((project) => `${project.name} (${project.projectRoot})`)
|
|
94
|
+
.join(", ")}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return matches[0]!;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
registerProject(input: RegisterTaskProjectInput, existingReference?: string): TaskProject {
|
|
101
|
+
const projectRoot = normalizeProjectRoot(input.projectRoot);
|
|
102
|
+
const name = input.name?.trim();
|
|
103
|
+
assertRegisterProjectInputBounds(name, input.aliases);
|
|
104
|
+
const existingId = existingReference ? this.resolveProject(existingReference).id : input.existingId;
|
|
105
|
+
return this.scopes.registerProject({ projectRoot, ...(name ? { name } : {}), aliases: input.aliases, existingId });
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/task/task-service.ts
CHANGED
|
@@ -9,17 +9,14 @@ import {
|
|
|
9
9
|
TASK_EXECUTION_MAX_DEGREE,
|
|
10
10
|
TASK_EXECUTION_MAX_EDGES,
|
|
11
11
|
TASK_EXECUTION_MAX_NODES,
|
|
12
|
-
TASK_FOCUS_STALE_AFTER_MS,
|
|
13
12
|
TASK_LABEL_MAX_COUNT,
|
|
14
13
|
TASK_LABEL_MAX_LENGTH,
|
|
15
|
-
TASK_PROJECT_LIST_MAX_RESULTS,
|
|
16
14
|
TASK_SCOPE_MAX_TASKS,
|
|
17
15
|
TASK_TITLE_MAX_LENGTH,
|
|
18
16
|
} from "../constants.ts";
|
|
19
17
|
import { type Checklist, checklistEntries, type ProofReference, validateChecklist } from "../domain/checklist.ts";
|
|
20
18
|
import { DISCUSSION_SUBTYPE, isDiscussionArtifact, readDiscussionExtra } from "../domain/discussion.ts";
|
|
21
19
|
import { type Gate, type GateResult, validateGates } from "../domain/gate.ts";
|
|
22
|
-
import { assertRegisterProjectInputBounds } from "../domain/project-registry.ts";
|
|
23
20
|
import type {
|
|
24
21
|
AppendTaskEvent,
|
|
25
22
|
TaskEventContext,
|
|
@@ -57,16 +54,29 @@ import {
|
|
|
57
54
|
type TaskMutationRequestStore,
|
|
58
55
|
} from "../stores/task-mutation-request-store.ts";
|
|
59
56
|
import { InMemoryTaskScopeStore, type TaskScopeStore } from "../stores/task-scope-store.ts";
|
|
60
|
-
import {
|
|
57
|
+
import { TaskEdges } from "./task-edges.ts";
|
|
58
|
+
import { TaskExecutionBoundExceededError } from "./task-execution.ts";
|
|
59
|
+
import { type TaskFocus, TaskFocusCoordinator, type TaskFocusMutationResult } from "./task-focus-coordinator.ts";
|
|
61
60
|
import { TaskLeaseCoordinator } from "./task-lease-coordinator.ts";
|
|
61
|
+
import { TaskInvalidTransitionError } from "./task-lifecycle-errors.ts";
|
|
62
62
|
import {
|
|
63
63
|
TaskMutationCoordinator,
|
|
64
64
|
TaskMutationReceiptNotFoundError,
|
|
65
65
|
type TaskMutationReceiptView,
|
|
66
66
|
type TaskMutationRequestContext,
|
|
67
67
|
} from "./task-mutation-coordinator.ts";
|
|
68
|
+
import { TaskProjectAmbiguousError, TaskProjectNotFoundError, TaskProjectScope } from "./task-project-scope.ts";
|
|
68
69
|
|
|
69
|
-
export {
|
|
70
|
+
export {
|
|
71
|
+
type TaskFocus,
|
|
72
|
+
type TaskFocusMutationResult,
|
|
73
|
+
TaskInvalidTransitionError,
|
|
74
|
+
TaskMutationReceiptNotFoundError,
|
|
75
|
+
type TaskMutationReceiptView,
|
|
76
|
+
type TaskMutationRequestContext,
|
|
77
|
+
TaskProjectAmbiguousError,
|
|
78
|
+
TaskProjectNotFoundError,
|
|
79
|
+
};
|
|
70
80
|
|
|
71
81
|
export interface UpdateTaskInput {
|
|
72
82
|
title?: string;
|
|
@@ -90,21 +100,6 @@ export interface TaskFilter {
|
|
|
90
100
|
|
|
91
101
|
export type TaskStatus = TaskLifecycleStatus;
|
|
92
102
|
|
|
93
|
-
export class TaskProjectNotFoundError extends Error {}
|
|
94
|
-
export class TaskProjectAmbiguousError extends Error {}
|
|
95
|
-
|
|
96
|
-
export class TaskInvalidTransitionError extends Error {
|
|
97
|
-
constructor(
|
|
98
|
-
readonly operation: string,
|
|
99
|
-
readonly currentStatus: string,
|
|
100
|
-
readonly intendedStatus: string,
|
|
101
|
-
readonly allowedActions: readonly string[],
|
|
102
|
-
readonly recovery: string,
|
|
103
|
-
) {
|
|
104
|
-
super(`cannot ${operation} task from ${currentStatus}; intended status is ${intendedStatus}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
103
|
export interface TaskMutationMetadata {
|
|
109
104
|
changed: boolean;
|
|
110
105
|
operation: string;
|
|
@@ -152,15 +147,6 @@ export interface ChecklistReview {
|
|
|
152
147
|
reason?: string;
|
|
153
148
|
}
|
|
154
149
|
|
|
155
|
-
export interface TaskFocus {
|
|
156
|
-
artifact: Artifact;
|
|
157
|
-
status: TaskFocusStatus;
|
|
158
|
-
updatedAt: string;
|
|
159
|
-
pauseReason?: string;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export type TaskFocusMutationResult = TaskFocus & TaskMutationMetadata;
|
|
163
|
-
|
|
164
150
|
export interface TaskCompletionOptions {
|
|
165
151
|
focusSuccessor?: boolean;
|
|
166
152
|
gateDeadlineMs?: number;
|
|
@@ -234,11 +220,39 @@ export class Tasks {
|
|
|
234
220
|
) {
|
|
235
221
|
this.leaseCoordinator = new TaskLeaseCoordinator(this.leases, (id) => this.require(id));
|
|
236
222
|
this.mutationCoordinator = new TaskMutationCoordinator(this.mutationRequests, this.artifacts);
|
|
223
|
+
this.focusCoordinator = new TaskFocusCoordinator(
|
|
224
|
+
this.artifacts,
|
|
225
|
+
this.focusStore,
|
|
226
|
+
this.events,
|
|
227
|
+
this.mutationCoordinator,
|
|
228
|
+
(id) => this.require(id),
|
|
229
|
+
(filter) => this.list(filter),
|
|
230
|
+
(event, context) => this.appendEvent(event, context),
|
|
231
|
+
);
|
|
232
|
+
this.projectScope = new TaskProjectScope(
|
|
233
|
+
this.scopes,
|
|
234
|
+
this.events,
|
|
235
|
+
(id) => this.require(id),
|
|
236
|
+
(event, context) => this.appendEvent(event, context),
|
|
237
|
+
);
|
|
238
|
+
this.taskEdges = new TaskEdges(
|
|
239
|
+
this.artifacts,
|
|
240
|
+
this.events,
|
|
241
|
+
(id) => this.require(id),
|
|
242
|
+
(id) => this.show(id),
|
|
243
|
+
(event, context) => this.appendEvent(event, context),
|
|
244
|
+
(id, dependencyId) => this.dependencyCheckGraph(id, dependencyId),
|
|
245
|
+
(id) => this.dependencyIds(id),
|
|
246
|
+
(id) => this.relationships(id),
|
|
247
|
+
);
|
|
237
248
|
}
|
|
238
249
|
|
|
239
250
|
private readonly completionFlights = new Map<string, Promise<TaskCompletion>>();
|
|
240
251
|
private readonly leaseCoordinator: TaskLeaseCoordinator;
|
|
241
252
|
private readonly mutationCoordinator: TaskMutationCoordinator;
|
|
253
|
+
private readonly focusCoordinator: TaskFocusCoordinator;
|
|
254
|
+
private readonly projectScope: TaskProjectScope;
|
|
255
|
+
private readonly taskEdges: TaskEdges;
|
|
242
256
|
|
|
243
257
|
private require(id: string): Artifact {
|
|
244
258
|
const artifact = this.artifacts.get(id);
|
|
@@ -375,7 +389,7 @@ export class Tasks {
|
|
|
375
389
|
}
|
|
376
390
|
|
|
377
391
|
list(filter: TaskFilter = {}): Artifact[] {
|
|
378
|
-
const selection = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
|
|
392
|
+
const selection = this.projectScope.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
|
|
379
393
|
const limit = filter.limit ?? TASK_SCOPE_MAX_TASKS;
|
|
380
394
|
if (!Number.isInteger(limit) || limit < 1 || limit > TASK_SCOPE_MAX_TASKS + 1) {
|
|
381
395
|
throw new Error(`task list limit must be between 1 and ${TASK_SCOPE_MAX_TASKS + 1}`);
|
|
@@ -415,41 +429,19 @@ export class Tasks {
|
|
|
415
429
|
}
|
|
416
430
|
|
|
417
431
|
scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
|
|
418
|
-
|
|
419
|
-
throw new Error("task scope must be project, graph, or all");
|
|
420
|
-
if (projectRoot === undefined) return { mode: "all", label: taskScopeLabel("all") };
|
|
421
|
-
const normalized = normalizeProjectRoot(projectRoot);
|
|
422
|
-
const persisted = this.scopes.view(normalized);
|
|
423
|
-
const selectedMode = mode ?? persisted.mode;
|
|
424
|
-
const selectedRoot = rootTaskId ?? (selectedMode === "graph" ? persisted.rootTaskId : undefined);
|
|
425
|
-
if (selectedMode === "graph" && !selectedRoot) throw new Error("graph scope requires root_task_id");
|
|
426
|
-
const root = selectedRoot ? this.require(selectedRoot) : undefined;
|
|
427
|
-
if (root && this.scopes.get(root.id)?.projectRoot !== normalized) throw new Error(`task "${root.id}" is outside project scope`);
|
|
428
|
-
return {
|
|
429
|
-
mode: selectedMode,
|
|
430
|
-
label: taskScopeLabel(selectedMode, normalized, root?.title),
|
|
431
|
-
projectRoot: normalized,
|
|
432
|
-
...(selectedRoot === undefined ? {} : { rootTaskId: selectedRoot }),
|
|
433
|
-
};
|
|
432
|
+
return this.projectScope.scopeSelection(projectRoot, mode, rootTaskId);
|
|
434
433
|
}
|
|
435
434
|
|
|
436
435
|
setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewSelection {
|
|
437
|
-
|
|
438
|
-
this.scopes.setView(selection.projectRoot!, selection.mode, selection.rootTaskId);
|
|
439
|
-
return selection;
|
|
436
|
+
return this.projectScope.setView(projectRoot, mode, rootTaskId);
|
|
440
437
|
}
|
|
441
438
|
|
|
442
439
|
assignProject(id: string, projectRoot: string, context: TaskEventContext = {}): Artifact {
|
|
443
|
-
return this.
|
|
444
|
-
const task = this.require(id);
|
|
445
|
-
this.scopes.assign(id, normalizeProjectRoot(projectRoot), "explicit");
|
|
446
|
-
this.appendEvent({ taskId: id, type: "project_assigned", reason: context.reason }, context);
|
|
447
|
-
return task;
|
|
448
|
-
});
|
|
440
|
+
return this.projectScope.assignProject(id, projectRoot, context);
|
|
449
441
|
}
|
|
450
442
|
|
|
451
443
|
graph(filter: TaskFilter = {}): TaskGraph {
|
|
452
|
-
const scope = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
|
|
444
|
+
const scope = this.projectScope.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
|
|
453
445
|
const requestedLimit = filter.limit ?? TASK_EXECUTION_MAX_NODES + 1;
|
|
454
446
|
if (!Number.isInteger(requestedLimit) || requestedLimit < 1 || requestedLimit > TASK_EXECUTION_MAX_NODES + 1) {
|
|
455
447
|
throw new Error(`task graph limit must be between 1 and ${TASK_EXECUTION_MAX_NODES + 1}`);
|
|
@@ -512,38 +504,15 @@ export class Tasks {
|
|
|
512
504
|
}
|
|
513
505
|
|
|
514
506
|
projects(query?: string, limit = 20): TaskProject[] {
|
|
515
|
-
|
|
516
|
-
throw new Error(`project list limit must be between 1 and ${TASK_PROJECT_LIST_MAX_RESULTS}`);
|
|
517
|
-
}
|
|
518
|
-
return this.scopes.projects(query, limit);
|
|
507
|
+
return this.projectScope.projects(query, limit);
|
|
519
508
|
}
|
|
520
509
|
|
|
521
510
|
resolveProject(reference: string): TaskProject {
|
|
522
|
-
|
|
523
|
-
if (matches.length === 0) {
|
|
524
|
-
const candidates = this.scopes.projects(reference, 10);
|
|
525
|
-
const fallback = candidates.length === 0 ? this.scopes.projects(undefined, 10) : candidates;
|
|
526
|
-
const suffix =
|
|
527
|
-
fallback.length === 0 ? "" : ` Candidates: ${fallback.map((project) => `${project.name} (${project.projectRoot})`).join(", ")}`;
|
|
528
|
-
throw new TaskProjectNotFoundError(`no task project named or aliased "${reference}" is registered.${suffix}`);
|
|
529
|
-
}
|
|
530
|
-
if (matches.length > 1) {
|
|
531
|
-
throw new TaskProjectAmbiguousError(
|
|
532
|
-
`task project reference "${reference}" is ambiguous: ${matches
|
|
533
|
-
.slice(0, 10)
|
|
534
|
-
.map((project) => `${project.name} (${project.projectRoot})`)
|
|
535
|
-
.join(", ")}`,
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
return matches[0]!;
|
|
511
|
+
return this.projectScope.resolveProject(reference);
|
|
539
512
|
}
|
|
540
513
|
|
|
541
514
|
registerProject(input: RegisterTaskProjectInput, existingReference?: string): TaskProject {
|
|
542
|
-
|
|
543
|
-
const name = input.name?.trim();
|
|
544
|
-
assertRegisterProjectInputBounds(name, input.aliases);
|
|
545
|
-
const existingId = existingReference ? this.resolveProject(existingReference).id : input.existingId;
|
|
546
|
-
return this.scopes.registerProject({ projectRoot, ...(name ? { name } : {}), aliases: input.aliases, existingId });
|
|
515
|
+
return this.projectScope.registerProject(input, existingReference);
|
|
547
516
|
}
|
|
548
517
|
|
|
549
518
|
show(id: string): Artifact {
|
|
@@ -552,142 +521,32 @@ export class Tasks {
|
|
|
552
521
|
}
|
|
553
522
|
|
|
554
523
|
focused(filter?: TaskFilter): TaskFocus | null {
|
|
555
|
-
|
|
556
|
-
if (!focus) return null;
|
|
557
|
-
const task = this.artifacts.get(focus.taskId);
|
|
558
|
-
if (task?.kind !== "task" || task.status === "done" || task.status === "canceled") {
|
|
559
|
-
this.focusStore.clear(focus.taskId, filter?.sessionId);
|
|
560
|
-
return null;
|
|
561
|
-
}
|
|
562
|
-
if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
|
|
563
|
-
return {
|
|
564
|
-
artifact: task,
|
|
565
|
-
status: focus.status,
|
|
566
|
-
updatedAt: focus.updatedAt,
|
|
567
|
-
...(focus.pauseReason ? { pauseReason: focus.pauseReason } : {}),
|
|
568
|
-
};
|
|
524
|
+
return this.focusCoordinator.focused(filter);
|
|
569
525
|
}
|
|
570
526
|
|
|
571
527
|
active(filter?: TaskFilter): Artifact | null {
|
|
572
|
-
|
|
573
|
-
return focus?.status === "active" ? focus.artifact : null;
|
|
528
|
+
return this.focusCoordinator.active(filter);
|
|
574
529
|
}
|
|
575
530
|
|
|
576
531
|
focus(id: string, context: TaskEventContext = {}): Artifact {
|
|
577
|
-
return this.
|
|
578
|
-
const task = this.require(id);
|
|
579
|
-
if (task.status === "done" || task.status === "canceled") throw new Error(`cannot focus task from ${task.status}`);
|
|
580
|
-
this.focusStore.set(id, context.sessionId);
|
|
581
|
-
this.appendEvent({ taskId: id, type: "focus_set" }, context);
|
|
582
|
-
return task;
|
|
583
|
-
});
|
|
532
|
+
return this.focusCoordinator.focus(id, context);
|
|
584
533
|
}
|
|
585
534
|
|
|
586
535
|
pauseFocus(context: TaskEventContext = {}, request: TaskMutationRequestContext = {}): TaskFocusMutationResult {
|
|
587
|
-
|
|
588
|
-
if (inspection.replay) return inspection.replay;
|
|
589
|
-
const focus = this.focused({ sessionId: context.sessionId });
|
|
590
|
-
if (!focus) {
|
|
591
|
-
throw new TaskInvalidTransitionError(
|
|
592
|
-
"pause",
|
|
593
|
-
"none",
|
|
594
|
-
"paused",
|
|
595
|
-
["focus"],
|
|
596
|
-
"Focus a non-terminal task before pausing; do not blindly retry pause.",
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
const prepared = inspection.pending
|
|
600
|
-
? inspection
|
|
601
|
-
: this.prepareMutation<TaskFocusMutationResult>("pause", undefined, context, request, true, () => validateEventContext(context));
|
|
602
|
-
if (focus.status === "paused") {
|
|
603
|
-
return this.completeMutation(prepared.record, {
|
|
604
|
-
...focus,
|
|
605
|
-
changed: false,
|
|
606
|
-
operation: "pause",
|
|
607
|
-
currentStatus: "paused",
|
|
608
|
-
intendedStatus: "paused",
|
|
609
|
-
...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
return this.events.atomic(() => {
|
|
613
|
-
const state = this.focusStore.pause(focus.artifact.id, context.reason, context.sessionId);
|
|
614
|
-
this.appendEvent({ taskId: focus.artifact.id, type: "focus_paused" }, context);
|
|
615
|
-
return this.completeMutation(prepared.record, {
|
|
616
|
-
artifact: focus.artifact,
|
|
617
|
-
status: state.status,
|
|
618
|
-
updatedAt: state.updatedAt,
|
|
619
|
-
...(state.pauseReason ? { pauseReason: state.pauseReason } : {}),
|
|
620
|
-
changed: true,
|
|
621
|
-
operation: "pause",
|
|
622
|
-
currentStatus: "paused",
|
|
623
|
-
intendedStatus: "paused",
|
|
624
|
-
...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
|
|
625
|
-
});
|
|
626
|
-
});
|
|
536
|
+
return this.focusCoordinator.pauseFocus(context, request);
|
|
627
537
|
}
|
|
628
538
|
|
|
629
539
|
unpauseFocus(context: TaskEventContext = {}, request: TaskMutationRequestContext = {}): TaskFocusMutationResult {
|
|
630
|
-
|
|
631
|
-
if (inspection.replay) return inspection.replay;
|
|
632
|
-
const focus = this.focused({ sessionId: context.sessionId });
|
|
633
|
-
if (!focus) {
|
|
634
|
-
throw new TaskInvalidTransitionError(
|
|
635
|
-
"unpause",
|
|
636
|
-
"none",
|
|
637
|
-
"active",
|
|
638
|
-
["focus"],
|
|
639
|
-
"Focus a non-terminal task before resuming; do not blindly retry unpause.",
|
|
640
|
-
);
|
|
641
|
-
}
|
|
642
|
-
const prepared = inspection.pending
|
|
643
|
-
? inspection
|
|
644
|
-
: this.prepareMutation<TaskFocusMutationResult>("unpause", undefined, context, request, true, () => validateEventContext(context));
|
|
645
|
-
if (focus.status === "active") {
|
|
646
|
-
return this.completeMutation(prepared.record, {
|
|
647
|
-
...focus,
|
|
648
|
-
changed: false,
|
|
649
|
-
operation: "unpause",
|
|
650
|
-
currentStatus: "active",
|
|
651
|
-
intendedStatus: "active",
|
|
652
|
-
...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
return this.events.atomic(() => {
|
|
656
|
-
const state = this.focusStore.unpause(focus.artifact.id, context.sessionId);
|
|
657
|
-
this.appendEvent({ taskId: focus.artifact.id, type: "focus_unpaused" }, context);
|
|
658
|
-
return this.completeMutation(prepared.record, {
|
|
659
|
-
artifact: focus.artifact,
|
|
660
|
-
status: state.status,
|
|
661
|
-
updatedAt: state.updatedAt,
|
|
662
|
-
changed: true,
|
|
663
|
-
operation: "unpause",
|
|
664
|
-
currentStatus: "active",
|
|
665
|
-
intendedStatus: "active",
|
|
666
|
-
...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
|
|
667
|
-
});
|
|
668
|
-
});
|
|
540
|
+
return this.focusCoordinator.unpauseFocus(context, request);
|
|
669
541
|
}
|
|
670
542
|
|
|
671
543
|
clearFocus(context: TaskEventContext = {}): { cleared: boolean } {
|
|
672
|
-
return this.
|
|
673
|
-
const focus = this.focusStore.get(context.sessionId);
|
|
674
|
-
if (focus) this.appendEvent({ taskId: focus.taskId, type: "focus_cleared" }, context);
|
|
675
|
-
this.focusStore.clear(undefined, context.sessionId);
|
|
676
|
-
return { cleared: focus !== undefined };
|
|
677
|
-
});
|
|
544
|
+
return this.focusCoordinator.clearFocus(context);
|
|
678
545
|
}
|
|
679
546
|
|
|
680
|
-
/**
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
* clean-up-stale-per-session-task-focus-rows-on-real-session-l-9i7s and constants.ts's
|
|
684
|
-
* comment on why this is deliberately not driven by session_start/session_shutdown.
|
|
685
|
-
* No task-lifecycle event is appended: this is daemon housekeeping, not a caller-driven
|
|
686
|
-
* mutation, and there is no longer a specific session/actor to attribute it to.
|
|
687
|
-
*/
|
|
688
|
-
reapStaleFocus(now: () => string = () => new Date().toISOString()): number {
|
|
689
|
-
const cutoff = new Date(new Date(now()).getTime() - TASK_FOCUS_STALE_AFTER_MS).toISOString();
|
|
690
|
-
return this.focusStore.reapStale(cutoff);
|
|
547
|
+
/** Delegates to TaskFocusCoordinator -- see its own doc comment on TASK_FOCUS_STALE_AFTER_MS/TASK_FOCUS_MAX_SCOPES. */
|
|
548
|
+
reapStaleFocus(now?: () => string): number {
|
|
549
|
+
return this.focusCoordinator.reapStaleFocus(now);
|
|
691
550
|
}
|
|
692
551
|
|
|
693
552
|
/** A lease is orthogonal to lifecycle and Focus: claiming a task does not start it, and does not require it to be Focused. */
|
|
@@ -974,24 +833,7 @@ export class Tasks {
|
|
|
974
833
|
}
|
|
975
834
|
|
|
976
835
|
depend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
|
|
977
|
-
return this.
|
|
978
|
-
this.require(id);
|
|
979
|
-
this.require(dependencyId);
|
|
980
|
-
const graph = this.dependencyCheckGraph(id, dependencyId);
|
|
981
|
-
assertDependencyEdgeAllowed(graph, id, dependencyId);
|
|
982
|
-
const node = graph.nodes.find((entry) => entry.task.id === id)!;
|
|
983
|
-
if (node.dependencyIds.includes(dependencyId)) return this.show(id);
|
|
984
|
-
if (node.dependencyIds.length >= TASK_EXECUTION_MAX_DEGREE) {
|
|
985
|
-
throw new TaskExecutionBoundExceededError(`task "${id}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
|
|
986
|
-
}
|
|
987
|
-
const successorCount = graph.nodes.filter((entry) => entry.dependencyIds.includes(dependencyId)).length;
|
|
988
|
-
if (successorCount >= TASK_EXECUTION_MAX_DEGREE) {
|
|
989
|
-
throw new TaskExecutionBoundExceededError(`task "${dependencyId}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} successors`);
|
|
990
|
-
}
|
|
991
|
-
this.artifacts.link({ from: id, relation: "depends_on", to: dependencyId }, context);
|
|
992
|
-
this.appendEvent({ taskId: id, type: "dependency_added", reason: context.reason }, context);
|
|
993
|
-
return this.show(id);
|
|
994
|
-
});
|
|
836
|
+
return this.taskEdges.depend(id, dependencyId, context);
|
|
995
837
|
}
|
|
996
838
|
|
|
997
839
|
/**
|
|
@@ -1025,45 +867,16 @@ export class Tasks {
|
|
|
1025
867
|
|
|
1026
868
|
/** Idempotent: undepending an already-absent dependency is a no-op. Never starts, completes, or focuses work — only removes the edge. */
|
|
1027
869
|
undepend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
|
|
1028
|
-
return this.
|
|
1029
|
-
const task = this.require(id);
|
|
1030
|
-
const dependency = this.require(dependencyId);
|
|
1031
|
-
const removed = this.artifacts.unlink({ from: id, relation: "depends_on", to: dependencyId }, context);
|
|
1032
|
-
if (removed) this.appendEvent({ taskId: id, type: "dependency_removed", reason: context.reason }, context);
|
|
1033
|
-
// Only meaningful if the removed edge was itself unmet -- removing an already-satisfied
|
|
1034
|
-
// dependency, or removing one from a task that was already unblocked, changes nothing.
|
|
1035
|
-
if (removed && task.status === "todo" && dependency.status !== "done") {
|
|
1036
|
-
const stillBlocking = this.dependencyIds(id).filter((remainingId) => this.require(remainingId).status !== "done");
|
|
1037
|
-
if (stillBlocking.length === 0) this.appendEvent({ taskId: id, type: "became_ready" }, context);
|
|
1038
|
-
}
|
|
1039
|
-
return this.show(id);
|
|
1040
|
-
});
|
|
870
|
+
return this.taskEdges.undepend(id, dependencyId, context);
|
|
1041
871
|
}
|
|
1042
872
|
|
|
1043
873
|
contain(parentId: string, childId: string, context: TaskEventContext = {}): Artifact {
|
|
1044
|
-
return this.
|
|
1045
|
-
this.require(parentId);
|
|
1046
|
-
this.require(childId);
|
|
1047
|
-
const alreadyContained = this.relationships(parentId).some(
|
|
1048
|
-
(edge) => edge.relation === "contains" && edge.from === parentId && edge.to === childId,
|
|
1049
|
-
);
|
|
1050
|
-
this.artifacts.link({ from: parentId, relation: "contains", to: childId }, context);
|
|
1051
|
-
this.artifacts.link({ from: childId, relation: "part_of", to: parentId }, context);
|
|
1052
|
-
if (!alreadyContained) this.appendEvent({ taskId: parentId, type: "containment_added", reason: context.reason }, context);
|
|
1053
|
-
return this.show(parentId);
|
|
1054
|
-
});
|
|
874
|
+
return this.taskEdges.contain(parentId, childId, context);
|
|
1055
875
|
}
|
|
1056
876
|
|
|
1057
877
|
/** Idempotent: removing an already-absent containment is a no-op. Both contains/part_of edges are removed atomically. */
|
|
1058
878
|
uncontain(parentId: string, childId: string, context: TaskEventContext = {}): Artifact {
|
|
1059
|
-
return this.
|
|
1060
|
-
this.require(parentId);
|
|
1061
|
-
this.require(childId);
|
|
1062
|
-
const removedContains = this.artifacts.unlink({ from: parentId, relation: "contains", to: childId }, context);
|
|
1063
|
-
this.artifacts.unlink({ from: childId, relation: "part_of", to: parentId }, context);
|
|
1064
|
-
if (removedContains) this.appendEvent({ taskId: parentId, type: "containment_removed", reason: context.reason }, context);
|
|
1065
|
-
return this.show(parentId);
|
|
1066
|
-
});
|
|
879
|
+
return this.taskEdges.uncontain(parentId, childId, context);
|
|
1067
880
|
}
|
|
1068
881
|
|
|
1069
882
|
private descendantIds(rootTaskId: string, projectTaskIds: string[]): Set<string> {
|