@danypops/papyrus 0.11.4 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +124 -38
- package/extension/src/notes.ts +16 -4
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-widget.ts +13 -1
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +77 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +285 -33
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +201 -40
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/skill-execution.ts +169 -75
- package/src/task-service.ts +70 -38
package/src/skill-execution.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { SKILL_MAX_RENDERED_BYTES, SKILL_RUN_ID_MAX_LENGTH, TASK_EXECUTION_MAX_EDGES } from "./constants.ts";
|
|
2
|
+
import { SKILL_MAX_RENDERED_BYTES, SKILL_RUN_ID_MAX_LENGTH, SKILL_WORKFLOW_MAX_NESTING_DEPTH, TASK_EXECUTION_MAX_EDGES } from "./constants.ts";
|
|
3
3
|
import type { Artifact } from "./domain/artifact.ts";
|
|
4
4
|
import { validateChecklist } from "./domain/checklist.ts";
|
|
5
5
|
import {
|
|
6
6
|
resolveSkillArguments,
|
|
7
7
|
validateSkillDefinition,
|
|
8
8
|
type SkillArgumentValue,
|
|
9
|
+
type SkillCallBlueprint,
|
|
9
10
|
type SkillDefinition,
|
|
10
11
|
} from "./domain/skill-definition.ts";
|
|
11
12
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
@@ -35,8 +36,12 @@ export interface SkillWorkflowRunResult {
|
|
|
35
36
|
docs: string[];
|
|
36
37
|
rules: string[];
|
|
37
38
|
tasks: string[];
|
|
39
|
+
/** Nested workflow Skill runs this pipeline triggered as pipeline steps, in execution order. */
|
|
40
|
+
skillRuns: string[];
|
|
38
41
|
};
|
|
42
|
+
/** Real starting points: for a nested skill-call root step, that nested run's own root tasks (recursively), not just "all its tasks". */
|
|
39
43
|
rootTaskIds: string[];
|
|
44
|
+
/** Scoped to this definition's own directly-created tasks only -- nested runs' tasks are real, graph-linked, and visible via /tasks graph, but not folded into this projection. */
|
|
40
45
|
execution: TaskExecutionPlan;
|
|
41
46
|
}
|
|
42
47
|
|
|
@@ -115,12 +120,48 @@ function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map
|
|
|
115
120
|
return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
type SkillWorkflowHistory = { events: TaskEventStore; scopes: TaskScopeStore; projectRoot: string; context?: TaskEventContext };
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Public entry point: wraps one complete pipeline run (including every nested sub-pipeline
|
|
127
|
+
* it triggers) in exactly one atomic transaction. The recursive core (runWorkflowSteps) never
|
|
128
|
+
* opens its own atomic wrapper -- SQLite savepoint nesting (inTransaction in db.ts) would
|
|
129
|
+
* tolerate it, but wrapping once here keeps the atomicity story unambiguous: one skills.run
|
|
130
|
+
* call is one all-or-nothing graph mutation, however many nested skills it triggers.
|
|
131
|
+
*/
|
|
118
132
|
export function instantiateSkillWorkflow(
|
|
119
133
|
artifacts: ArtifactStore,
|
|
120
134
|
skillId: string,
|
|
121
135
|
input: InstantiateSkillWorkflowInput = {},
|
|
122
|
-
history?:
|
|
136
|
+
history?: SkillWorkflowHistory,
|
|
123
137
|
): SkillWorkflowRunResult {
|
|
138
|
+
const run = () => runWorkflowSteps(artifacts, skillId, input, history, new Set(), 0);
|
|
139
|
+
if (history) return history.events.atomic(run);
|
|
140
|
+
return requireAtomicArtifactStore(artifacts).atomic(run);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The recursive pipeline core. A workflow Skill's `skills` blueprint entries are pipeline
|
|
145
|
+
* steps that trigger another workflow Skill's own run -- the Jenkins "downstream job" /
|
|
146
|
+
* Ansible "include_tasks" primitive. Nested runs execute BEFORE this level's dependsOn/parent
|
|
147
|
+
* edges are wired, since a step depending on a skill-call ref needs to know every task id
|
|
148
|
+
* that nested run actually produced (not knowable ahead of time -- it depends on the nested
|
|
149
|
+
* skill's own definition). `ancestorSkillIds` tracks the current call CHAIN (not a global
|
|
150
|
+
* ever-visited set): sibling skill-calls under the same parent are independent and may
|
|
151
|
+
* legitimately share a called skill; only a real cycle back to an ancestor is rejected.
|
|
152
|
+
*/
|
|
153
|
+
function runWorkflowSteps(
|
|
154
|
+
artifacts: ArtifactStore,
|
|
155
|
+
skillId: string,
|
|
156
|
+
input: InstantiateSkillWorkflowInput,
|
|
157
|
+
history: SkillWorkflowHistory | undefined,
|
|
158
|
+
ancestorSkillIds: ReadonlySet<string>,
|
|
159
|
+
depth: number,
|
|
160
|
+
): SkillWorkflowRunResult {
|
|
161
|
+
if (ancestorSkillIds.has(skillId)) throw new Error(`skill workflow nesting cycle includes "${skillId}"`);
|
|
162
|
+
if (depth > SKILL_WORKFLOW_MAX_NESTING_DEPTH) throw new Error(`skill workflow nesting exceeds ${SKILL_WORKFLOW_MAX_NESTING_DEPTH} levels`);
|
|
163
|
+
const nextAncestors = new Set([...ancestorSkillIds, skillId]);
|
|
164
|
+
|
|
124
165
|
const { definition } = requireWorkflowSkill(artifacts, skillId);
|
|
125
166
|
const projectRoot = history ? normalizeProjectRoot(history.projectRoot) : undefined;
|
|
126
167
|
const arguments_ = resolveSkillArguments(definition, input.arguments);
|
|
@@ -130,97 +171,150 @@ export function instantiateSkillWorkflow(
|
|
|
130
171
|
...rendered.blueprints.docs.map(({ ref }) => ref),
|
|
131
172
|
...rendered.blueprints.rules.map(({ ref }) => ref),
|
|
132
173
|
...rendered.blueprints.tasks.map(({ ref }) => ref),
|
|
174
|
+
...rendered.blueprints.skills.map(({ ref }) => ref),
|
|
133
175
|
];
|
|
134
176
|
const ids = new Map(refs.map((ref) => [ref, `${runId}-${ref}`]));
|
|
135
177
|
const taskIds = rendered.blueprints.tasks.map(({ ref }) => ids.get(ref)!);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
178
|
+
// A bound at THIS level's own blueprint size; nested runs are independently bounded the same
|
|
179
|
+
// way at their own level, and nesting depth is separately capped -- so total blast radius
|
|
180
|
+
// across a whole pipeline stays bounded on both dimensions even though a step's dependency
|
|
181
|
+
// on a skill-call ref can fan out to more edges than this per-level count captures exactly.
|
|
139
182
|
const relationshipCount = rendered.links.length
|
|
140
183
|
+ rendered.blueprints.tasks.reduce((count, task) => count + (task.dependsOn?.length ?? 0) + (task.parent ? 2 : 0), 0)
|
|
141
|
-
+
|
|
184
|
+
+ rendered.blueprints.skills.reduce((count, call) => count + (call.dependsOn?.length ?? 0) + (call.parent ? 2 : 0), 0)
|
|
185
|
+
+ rendered.blueprints.tasks.filter((task) => (task.dependsOn?.length ?? 0) === 0).length
|
|
186
|
+
+ rendered.blueprints.skills.filter((call) => (call.dependsOn?.length ?? 0) === 0).length;
|
|
142
187
|
if (relationshipCount > TASK_EXECUTION_MAX_EDGES) {
|
|
143
188
|
throw new Error(`skill workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
|
|
144
189
|
}
|
|
145
190
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
191
|
+
const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
|
|
192
|
+
id: ids.get(blueprint.ref),
|
|
193
|
+
kind: "doc",
|
|
194
|
+
title: blueprint.title,
|
|
195
|
+
body: blueprint.body,
|
|
196
|
+
subtype: blueprint.subtype,
|
|
197
|
+
labels: withRunLabel(blueprint.labels, runId),
|
|
198
|
+
extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
|
|
199
|
+
}));
|
|
200
|
+
const rules = rendered.blueprints.rules.map((blueprint) => artifacts.create({
|
|
201
|
+
id: ids.get(blueprint.ref),
|
|
202
|
+
kind: "rule",
|
|
203
|
+
title: blueprint.title,
|
|
204
|
+
body: blueprint.body,
|
|
205
|
+
labels: withRunLabel(blueprint.labels, runId),
|
|
206
|
+
extra: {
|
|
207
|
+
...(blueprint.extra ?? {}),
|
|
208
|
+
...(blueprint.condition ? { condition: blueprint.condition } : {}),
|
|
209
|
+
...(blueprint.action ? { action: blueprint.action } : {}),
|
|
210
|
+
...(blueprint.severity ? { severity: blueprint.severity } : {}),
|
|
211
|
+
skillRun: { id: runId, skillId, ref: blueprint.ref },
|
|
212
|
+
scope: { type: "skill-run", runId, taskIds },
|
|
213
|
+
},
|
|
214
|
+
}));
|
|
215
|
+
const tasks = rendered.blueprints.tasks.map((blueprint) => {
|
|
216
|
+
const task = artifacts.create({
|
|
149
217
|
id: ids.get(blueprint.ref),
|
|
150
|
-
kind: "
|
|
218
|
+
kind: "task",
|
|
151
219
|
title: blueprint.title,
|
|
152
220
|
body: blueprint.body,
|
|
153
|
-
subtype: blueprint.subtype,
|
|
154
221
|
labels: withRunLabel(blueprint.labels, runId),
|
|
155
222
|
extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
|
|
156
|
-
}));
|
|
157
|
-
const rules = rendered.blueprints.rules.map((blueprint) => artifacts.create({
|
|
158
|
-
id: ids.get(blueprint.ref),
|
|
159
|
-
kind: "rule",
|
|
160
|
-
title: blueprint.title,
|
|
161
|
-
body: blueprint.body,
|
|
162
|
-
labels: withRunLabel(blueprint.labels, runId),
|
|
163
|
-
extra: {
|
|
164
|
-
...(blueprint.extra ?? {}),
|
|
165
|
-
...(blueprint.condition ? { condition: blueprint.condition } : {}),
|
|
166
|
-
...(blueprint.action ? { action: blueprint.action } : {}),
|
|
167
|
-
...(blueprint.severity ? { severity: blueprint.severity } : {}),
|
|
168
|
-
skillRun: { id: runId, skillId, ref: blueprint.ref },
|
|
169
|
-
scope: { type: "skill-run", runId, taskIds },
|
|
170
|
-
},
|
|
171
|
-
}));
|
|
172
|
-
const tasks = rendered.blueprints.tasks.map((blueprint) => {
|
|
173
|
-
const task = artifacts.create({
|
|
174
|
-
id: ids.get(blueprint.ref),
|
|
175
|
-
kind: "task",
|
|
176
|
-
title: blueprint.title,
|
|
177
|
-
body: blueprint.body,
|
|
178
|
-
labels: withRunLabel(blueprint.labels, runId),
|
|
179
|
-
extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
|
|
180
|
-
});
|
|
181
|
-
if (history) {
|
|
182
|
-
history.scopes.assign(task.id, projectRoot, "cwd");
|
|
183
|
-
history.events.append({
|
|
184
|
-
taskId: task.id,
|
|
185
|
-
type: "created",
|
|
186
|
-
actor: history.context?.actor ?? "system",
|
|
187
|
-
source: history.context?.source ?? "skill-run",
|
|
188
|
-
toStatus: task.status as TaskStatus,
|
|
189
|
-
...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
|
|
190
|
-
...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
return task;
|
|
194
223
|
});
|
|
224
|
+
if (history) {
|
|
225
|
+
history.scopes.assign(task.id, projectRoot, "cwd");
|
|
226
|
+
history.events.append({
|
|
227
|
+
taskId: task.id,
|
|
228
|
+
type: "created",
|
|
229
|
+
actor: history.context?.actor ?? "system",
|
|
230
|
+
source: history.context?.source ?? "skill-run",
|
|
231
|
+
toStatus: task.status as TaskStatus,
|
|
232
|
+
...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
|
|
233
|
+
...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return task;
|
|
237
|
+
});
|
|
195
238
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
239
|
+
// Nested pipeline steps run before edge-wiring: dependents need to know what tasks each
|
|
240
|
+
// nested run actually produced. stepTaskIds/stepRootTaskIds map EVERY step ref (task or
|
|
241
|
+
// skill-call) to the task id(s) it resolves to, so dependsOn/parent wiring below treats
|
|
242
|
+
// both kinds of step uniformly.
|
|
243
|
+
const nestedRuns: SkillWorkflowRunResult[] = [];
|
|
244
|
+
const stepTaskIds = new Map<string, string[]>(tasks.map((task, index) => [rendered.blueprints.tasks[index]!.ref, [task.id]]));
|
|
245
|
+
const stepRootTaskIds = new Map<string, string[]>(
|
|
246
|
+
tasks.map((task, index) => [rendered.blueprints.tasks[index]!.ref, (rendered.blueprints.tasks[index]!.dependsOn?.length ?? 0) === 0 ? [task.id] : []]),
|
|
247
|
+
);
|
|
248
|
+
for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
|
|
249
|
+
const nested = runWorkflowSteps(
|
|
250
|
+
artifacts,
|
|
251
|
+
call.skillId,
|
|
252
|
+
{ runId: `${runId}-${call.ref}`, arguments: call.arguments },
|
|
253
|
+
history,
|
|
254
|
+
nextAncestors,
|
|
255
|
+
depth + 1,
|
|
256
|
+
);
|
|
257
|
+
nestedRuns.push(nested);
|
|
258
|
+
stepTaskIds.set(call.ref, nested.created.tasks);
|
|
259
|
+
stepRootTaskIds.set(call.ref, nested.rootTaskIds);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const blueprint of rendered.blueprints.tasks) {
|
|
263
|
+
const id = ids.get(blueprint.ref)!;
|
|
264
|
+
for (const dependency of blueprint.dependsOn ?? []) {
|
|
265
|
+
for (const dependencyId of stepTaskIds.get(dependency) ?? []) artifacts.link({ from: id, relation: "depends_on", to: dependencyId });
|
|
266
|
+
}
|
|
267
|
+
if (blueprint.parent) {
|
|
268
|
+
const parentId = ids.get(blueprint.parent)!;
|
|
269
|
+
artifacts.link({ from: parentId, relation: "contains", to: id });
|
|
270
|
+
artifacts.link({ from: id, relation: "part_of", to: parentId });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
|
|
274
|
+
const stepTaskIdsForCall = stepTaskIds.get(call.ref) ?? [];
|
|
275
|
+
for (const dependency of call.dependsOn ?? []) {
|
|
276
|
+
for (const dependencyId of stepTaskIds.get(dependency) ?? []) {
|
|
277
|
+
for (const taskId of stepTaskIdsForCall) artifacts.link({ from: taskId, relation: "depends_on", to: dependencyId });
|
|
205
278
|
}
|
|
206
279
|
}
|
|
207
|
-
|
|
208
|
-
|
|
280
|
+
if (call.parent) {
|
|
281
|
+
const parentId = ids.get(call.parent)!;
|
|
282
|
+
for (const rootTaskId of stepRootTaskIds.get(call.ref) ?? []) {
|
|
283
|
+
artifacts.link({ from: parentId, relation: "contains", to: rootTaskId });
|
|
284
|
+
artifacts.link({ from: rootTaskId, relation: "part_of", to: parentId });
|
|
285
|
+
}
|
|
209
286
|
}
|
|
210
|
-
|
|
287
|
+
}
|
|
288
|
+
for (const link of rendered.links) {
|
|
289
|
+
const fromIds = stepTaskIds.get(link.from) ?? [ids.get(link.from)!];
|
|
290
|
+
const toIds = stepTaskIds.get(link.to) ?? [ids.get(link.to)!];
|
|
291
|
+
for (const from of fromIds) for (const to of toIds) artifacts.link({ from, relation: link.relation, to });
|
|
292
|
+
}
|
|
211
293
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return
|
|
294
|
+
const rootTaskIds = [
|
|
295
|
+
...rendered.blueprints.tasks.filter((task) => (task.dependsOn?.length ?? 0) === 0).map((task) => ids.get(task.ref)!),
|
|
296
|
+
...(rendered.blueprints.skills as SkillCallBlueprint[])
|
|
297
|
+
.filter((call) => (call.dependsOn?.length ?? 0) === 0)
|
|
298
|
+
.flatMap((call) => stepRootTaskIds.get(call.ref) ?? []),
|
|
299
|
+
];
|
|
300
|
+
for (const task of rendered.blueprints.tasks) {
|
|
301
|
+
if ((task.dependsOn?.length ?? 0) === 0) artifacts.link({ from: skillId, relation: "triggers", to: ids.get(task.ref)! });
|
|
302
|
+
}
|
|
303
|
+
for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
|
|
304
|
+
if ((call.dependsOn?.length ?? 0) === 0) artifacts.link({ from: skillId, relation: "triggers", to: call.skillId });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
skillId,
|
|
309
|
+
runId,
|
|
310
|
+
arguments: arguments_,
|
|
311
|
+
created: {
|
|
312
|
+
docs: [...docs.map(({ id }) => id), ...nestedRuns.flatMap((run) => run.created.docs)],
|
|
313
|
+
rules: [...rules.map(({ id }) => id), ...nestedRuns.flatMap((run) => run.created.rules)],
|
|
314
|
+
tasks: [...tasks.map(({ id }) => id), ...nestedRuns.flatMap((run) => run.created.tasks)],
|
|
315
|
+
skillRuns: [...nestedRuns.map((run) => run.runId), ...nestedRuns.flatMap((run) => run.created.skillRuns)],
|
|
316
|
+
},
|
|
317
|
+
rootTaskIds,
|
|
318
|
+
execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
|
|
319
|
+
};
|
|
226
320
|
}
|
package/src/task-service.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface TaskFilter {
|
|
|
34
34
|
projectRoot?: string;
|
|
35
35
|
scope?: TaskViewMode;
|
|
36
36
|
rootTaskId?: string;
|
|
37
|
+
/** Requesting agent session id — scopes Task Focus reads so concurrent agents see only their own Focus. Defaults to a shared "global" scope when omitted. */
|
|
38
|
+
sessionId?: string;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export type TaskStatus = TaskLifecycleStatus;
|
|
@@ -280,7 +282,7 @@ export class Tasks {
|
|
|
280
282
|
throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
|
|
281
283
|
}
|
|
282
284
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
283
|
-
const focus = this.focusStore.get();
|
|
285
|
+
const focus = this.focusStore.get(filter.sessionId);
|
|
284
286
|
const focusedId = focus?.taskId;
|
|
285
287
|
const nodes = new Map(tasks.map((task) => [task.id, {
|
|
286
288
|
task,
|
|
@@ -326,11 +328,11 @@ export class Tasks {
|
|
|
326
328
|
}
|
|
327
329
|
|
|
328
330
|
focused(filter?: TaskFilter): TaskFocus | null {
|
|
329
|
-
const focus = this.focusStore.get();
|
|
331
|
+
const focus = this.focusStore.get(filter?.sessionId);
|
|
330
332
|
if (!focus) return null;
|
|
331
333
|
const task = this.artifacts.get(focus.taskId);
|
|
332
334
|
if (!task || task.kind !== "task" || task.status === "done" || task.status === "canceled") {
|
|
333
|
-
this.focusStore.clear(focus.taskId);
|
|
335
|
+
this.focusStore.clear(focus.taskId, filter?.sessionId);
|
|
334
336
|
return null;
|
|
335
337
|
}
|
|
336
338
|
if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
|
|
@@ -346,7 +348,7 @@ export class Tasks {
|
|
|
346
348
|
return this.events.atomic(() => {
|
|
347
349
|
const task = this.require(id);
|
|
348
350
|
if (task.status === "done" || task.status === "canceled") throw new Error(`cannot focus task from ${task.status}`);
|
|
349
|
-
this.focusStore.set(id);
|
|
351
|
+
this.focusStore.set(id, context.sessionId);
|
|
350
352
|
this.appendEvent({ taskId: id, type: "focus_set" }, context);
|
|
351
353
|
return task;
|
|
352
354
|
});
|
|
@@ -354,9 +356,9 @@ export class Tasks {
|
|
|
354
356
|
|
|
355
357
|
pauseFocus(context: TaskEventContext = {}): TaskFocus {
|
|
356
358
|
return this.events.atomic(() => {
|
|
357
|
-
const focus = this.focused();
|
|
359
|
+
const focus = this.focused({ sessionId: context.sessionId });
|
|
358
360
|
if (!focus) throw new Error("no focused task");
|
|
359
|
-
const state = this.focusStore.pause(focus.artifact.id, context.reason);
|
|
361
|
+
const state = this.focusStore.pause(focus.artifact.id, context.reason, context.sessionId);
|
|
360
362
|
this.appendEvent({ taskId: focus.artifact.id, type: "focus_paused" }, context);
|
|
361
363
|
return { artifact: focus.artifact, status: state.status, updatedAt: state.updatedAt, ...(state.pauseReason ? { pauseReason: state.pauseReason } : {}) };
|
|
362
364
|
});
|
|
@@ -364,9 +366,9 @@ export class Tasks {
|
|
|
364
366
|
|
|
365
367
|
unpauseFocus(context: TaskEventContext = {}): TaskFocus {
|
|
366
368
|
return this.events.atomic(() => {
|
|
367
|
-
const focus = this.focused();
|
|
369
|
+
const focus = this.focused({ sessionId: context.sessionId });
|
|
368
370
|
if (!focus) throw new Error("no focused task");
|
|
369
|
-
const state = this.focusStore.unpause(focus.artifact.id);
|
|
371
|
+
const state = this.focusStore.unpause(focus.artifact.id, context.sessionId);
|
|
370
372
|
this.appendEvent({ taskId: focus.artifact.id, type: "focus_unpaused" }, context);
|
|
371
373
|
return { artifact: focus.artifact, status: state.status, updatedAt: state.updatedAt };
|
|
372
374
|
});
|
|
@@ -374,9 +376,9 @@ export class Tasks {
|
|
|
374
376
|
|
|
375
377
|
clearFocus(context: TaskEventContext = {}): { cleared: boolean } {
|
|
376
378
|
return this.events.atomic(() => {
|
|
377
|
-
const focus = this.focusStore.get();
|
|
379
|
+
const focus = this.focusStore.get(context.sessionId);
|
|
378
380
|
if (focus) this.appendEvent({ taskId: focus.taskId, type: "focus_cleared" }, context);
|
|
379
|
-
this.focusStore.clear();
|
|
381
|
+
this.focusStore.clear(undefined, context.sessionId);
|
|
380
382
|
return { cleared: focus !== undefined };
|
|
381
383
|
});
|
|
382
384
|
}
|
|
@@ -389,14 +391,14 @@ export class Tasks {
|
|
|
389
391
|
if (action === "start") {
|
|
390
392
|
const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
|
|
391
393
|
if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
|
|
392
|
-
this.focusStore.set(id);
|
|
394
|
+
this.focusStore.set(id, context.sessionId);
|
|
393
395
|
}
|
|
394
396
|
const updated = this.artifacts.setStatus(id, transition.to)!;
|
|
395
397
|
const eventType = { start: "started", submit: "submitted", reject: "review_rejected", retry: "retried", cancel: "canceled" }[action] as AppendTaskEvent["type"];
|
|
396
398
|
this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: transition.to }, context);
|
|
397
399
|
if (action === "start" || action === "retry") this.propagateProgressToAncestors(id, context);
|
|
398
|
-
if (action === "retry") this.focusStore.set(id);
|
|
399
|
-
if (action === "cancel") this.focusStore.
|
|
400
|
+
if (action === "retry") this.focusStore.set(id, context.sessionId);
|
|
401
|
+
if (action === "cancel") this.focusStore.clearEverywhere(id);
|
|
400
402
|
return updated;
|
|
401
403
|
});
|
|
402
404
|
}
|
|
@@ -437,30 +439,60 @@ export class Tasks {
|
|
|
437
439
|
return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
|
|
438
440
|
}
|
|
439
441
|
|
|
440
|
-
depend(id: string, dependencyId: string): Artifact {
|
|
441
|
-
this.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
442
|
+
depend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
|
|
443
|
+
return this.events.atomic(() => {
|
|
444
|
+
this.require(id);
|
|
445
|
+
this.require(dependencyId);
|
|
446
|
+
const graph = this.graph();
|
|
447
|
+
assertDependencyEdgeAllowed(graph, id, dependencyId);
|
|
448
|
+
const node = graph.nodes.find((entry) => entry.task.id === id)!;
|
|
449
|
+
if (node.dependencyIds.includes(dependencyId)) return this.show(id);
|
|
450
|
+
if (node.dependencyIds.length >= TASK_EXECUTION_MAX_DEGREE) {
|
|
451
|
+
throw new Error(`task "${id}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
|
|
452
|
+
}
|
|
453
|
+
const successorCount = graph.nodes.filter((entry) => entry.dependencyIds.includes(dependencyId)).length;
|
|
454
|
+
if (successorCount >= TASK_EXECUTION_MAX_DEGREE) {
|
|
455
|
+
throw new Error(`task "${dependencyId}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} successors`);
|
|
456
|
+
}
|
|
457
|
+
this.artifacts.link({ from: id, relation: "depends_on", to: dependencyId }, context);
|
|
458
|
+
this.appendEvent({ taskId: id, type: "dependency_added", reason: context.reason }, context);
|
|
459
|
+
return this.show(id);
|
|
460
|
+
});
|
|
456
461
|
}
|
|
457
462
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
this.
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
463
|
+
/** Idempotent: undepending an already-absent dependency is a no-op. Never starts, completes, or focuses work — only removes the edge. */
|
|
464
|
+
undepend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
|
|
465
|
+
return this.events.atomic(() => {
|
|
466
|
+
this.require(id);
|
|
467
|
+
this.require(dependencyId);
|
|
468
|
+
const removed = this.artifacts.unlink({ from: id, relation: "depends_on", to: dependencyId }, context);
|
|
469
|
+
if (removed) this.appendEvent({ taskId: id, type: "dependency_removed", reason: context.reason }, context);
|
|
470
|
+
return this.show(id);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
contain(parentId: string, childId: string, context: TaskEventContext = {}): Artifact {
|
|
475
|
+
return this.events.atomic(() => {
|
|
476
|
+
this.require(parentId);
|
|
477
|
+
this.require(childId);
|
|
478
|
+
const alreadyContained = this.relationships(parentId).some((edge) => edge.relation === "contains" && edge.from === parentId && edge.to === childId);
|
|
479
|
+
this.artifacts.link({ from: parentId, relation: "contains", to: childId }, context);
|
|
480
|
+
this.artifacts.link({ from: childId, relation: "part_of", to: parentId }, context);
|
|
481
|
+
if (!alreadyContained) this.appendEvent({ taskId: parentId, type: "containment_added", reason: context.reason }, context);
|
|
482
|
+
return this.show(parentId);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Idempotent: removing an already-absent containment is a no-op. Both contains/part_of edges are removed atomically. */
|
|
487
|
+
uncontain(parentId: string, childId: string, context: TaskEventContext = {}): Artifact {
|
|
488
|
+
return this.events.atomic(() => {
|
|
489
|
+
this.require(parentId);
|
|
490
|
+
this.require(childId);
|
|
491
|
+
const removedContains = this.artifacts.unlink({ from: parentId, relation: "contains", to: childId }, context);
|
|
492
|
+
this.artifacts.unlink({ from: childId, relation: "part_of", to: parentId }, context);
|
|
493
|
+
if (removedContains) this.appendEvent({ taskId: parentId, type: "containment_removed", reason: context.reason }, context);
|
|
494
|
+
return this.show(parentId);
|
|
495
|
+
});
|
|
464
496
|
}
|
|
465
497
|
|
|
466
498
|
private descendantIds(rootTaskId: string, projectTaskIds: string[]): Set<string> {
|
|
@@ -575,7 +607,7 @@ export class Tasks {
|
|
|
575
607
|
attemptId,
|
|
576
608
|
evidence: { gates, checklist, result: "rejected" },
|
|
577
609
|
}, context);
|
|
578
|
-
return { artifact, gates, checklist, completed: false, focused: this.active(), blocked: [] };
|
|
610
|
+
return { artifact, gates, checklist, completed: false, focused: this.active({ sessionId: context.sessionId }), blocked: [] };
|
|
579
611
|
});
|
|
580
612
|
}
|
|
581
613
|
return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context, options));
|
|
@@ -597,7 +629,7 @@ export class Tasks {
|
|
|
597
629
|
attemptId,
|
|
598
630
|
evidence: { gates, checklist, result: "completed" },
|
|
599
631
|
}, context);
|
|
600
|
-
this.focusStore.
|
|
632
|
+
this.focusStore.clearEverywhere(id);
|
|
601
633
|
const blocked: TaskBlockage[] = [];
|
|
602
634
|
let focused: Artifact | null = null;
|
|
603
635
|
for (const successorId of [...successorIds].sort()) {
|
|
@@ -610,7 +642,7 @@ export class Tasks {
|
|
|
610
642
|
continue;
|
|
611
643
|
}
|
|
612
644
|
if (options.focusSuccessor !== false && !focused) {
|
|
613
|
-
this.focusStore.set(successor.id);
|
|
645
|
+
this.focusStore.set(successor.id, context.sessionId);
|
|
614
646
|
focused = successor;
|
|
615
647
|
}
|
|
616
648
|
}
|