task-manager-logic 0.1.3 → 0.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-manager-logic",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "Runtime-first TaskSpace transition processors and backend-neutral in-memory owner",
6
6
  "type": "module",
@@ -0,0 +1,88 @@
1
+ import type { TaskId, TaskSpaceDefinition } from 'task-manager-contract';
2
+
3
+ export interface TaskCompletionGraph {
4
+ readonly predecessorIdsByTask: ReadonlyMap<TaskId, readonly TaskId[]>;
5
+ readonly authoritySuccessorIdsByTask: ReadonlyMap<TaskId, readonly TaskId[]>;
6
+ }
7
+
8
+ /**
9
+ * Builds the one completion graph used by admission and runtime readiness.
10
+ * Authority edges additionally include child -> parent containment because a
11
+ * group cannot complete before its descendants.
12
+ */
13
+ export function buildTaskCompletionGraph(definition: TaskSpaceDefinition): TaskCompletionGraph {
14
+ const taskById = new Map(definition.tasks.map((task) => [task.taskId, task]));
15
+ const childrenByParent = new Map<TaskId, { readonly taskId: TaskId; readonly order: number }[]>();
16
+ for (const relation of definition.relations) {
17
+ if (relation.kind !== 'parent-child' || relation.parentTaskId === null) continue;
18
+ const children = childrenByParent.get(relation.parentTaskId) ?? [];
19
+ children.push({ taskId: relation.childTaskId, order: relation.order });
20
+ childrenByParent.set(relation.parentTaskId, children);
21
+ }
22
+ for (const children of childrenByParent.values()) {
23
+ children.sort((left, right) => left.order - right.order || left.taskId.localeCompare(right.taskId));
24
+ }
25
+
26
+ const descendantsByTask = new Map<TaskId, readonly TaskId[]>();
27
+ const descendants = (taskId: TaskId): readonly TaskId[] => {
28
+ const cached = descendantsByTask.get(taskId);
29
+ if (cached) return cached;
30
+ const result = (childrenByParent.get(taskId) ?? [])
31
+ .flatMap((child) => [child.taskId, ...descendants(child.taskId)]);
32
+ descendantsByTask.set(taskId, result);
33
+ return result;
34
+ };
35
+
36
+ const runtimeSuccessors = new Map<TaskId, Set<TaskId>>();
37
+ const addRuntimeEdge = (predecessor: TaskId, successor: TaskId): void => {
38
+ const successors = runtimeSuccessors.get(predecessor) ?? new Set<TaskId>();
39
+ successors.add(successor);
40
+ runtimeSuccessors.set(predecessor, successors);
41
+ };
42
+ for (const relation of definition.relations) {
43
+ if (relation.kind !== 'dependency') continue;
44
+ addRuntimeEdge(relation.predecessorTaskId, relation.successorTaskId);
45
+ if (taskById.get(relation.successorTaskId)?.kind === 'task-group') {
46
+ for (const descendant of descendants(relation.successorTaskId)) {
47
+ addRuntimeEdge(relation.predecessorTaskId, descendant);
48
+ }
49
+ }
50
+ }
51
+ for (const group of definition.tasks) {
52
+ if (group.kind !== 'task-group' || group.mode !== 'sequential') continue;
53
+ const children = childrenByParent.get(group.taskId) ?? [];
54
+ for (let index = 1; index < children.length; index += 1) {
55
+ const predecessor = children[index - 1]!.taskId;
56
+ const successor = children[index]!.taskId;
57
+ addRuntimeEdge(predecessor, successor);
58
+ for (const descendant of descendants(successor)) addRuntimeEdge(predecessor, descendant);
59
+ }
60
+ }
61
+
62
+ const predecessorSets = new Map<TaskId, Set<TaskId>>();
63
+ for (const [predecessor, successors] of runtimeSuccessors) {
64
+ for (const successor of successors) {
65
+ const predecessors = predecessorSets.get(successor) ?? new Set<TaskId>();
66
+ predecessors.add(predecessor);
67
+ predecessorSets.set(successor, predecessors);
68
+ }
69
+ }
70
+ const authoritySuccessors = new Map<TaskId, Set<TaskId>>(
71
+ [...runtimeSuccessors].map(([taskId, successors]) => [taskId, new Set(successors)]),
72
+ );
73
+ for (const [parent, children] of childrenByParent) {
74
+ for (const child of children) {
75
+ const successors = authoritySuccessors.get(child.taskId) ?? new Set<TaskId>();
76
+ successors.add(parent);
77
+ authoritySuccessors.set(child.taskId, successors);
78
+ }
79
+ }
80
+ return {
81
+ predecessorIdsByTask: new Map(
82
+ [...predecessorSets].map(([taskId, predecessors]) => [taskId, [...predecessors]]),
83
+ ),
84
+ authoritySuccessorIdsByTask: new Map(
85
+ [...authoritySuccessors].map(([taskId, successors]) => [taskId, [...successors]]),
86
+ ),
87
+ };
88
+ }
@@ -52,10 +52,11 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
52
52
 
53
53
  async compareAndSwap(commit: TaskSpaceCommit): Promise<TaskSpaceCommitResult> {
54
54
  commit = deepImmutableClone(commit);
55
+ const submittedCommandDigest = validateSubmittedCommand(commit);
55
56
  const current = this.#spaces.get(commit.taskSpaceId);
56
57
  const replay = current?.receiptsByCommand.get(commit.commandId);
57
58
  if (current && replay) {
58
- if (replay.commandDigest !== commit.commandDigest) {
59
+ if (replay.commandDigest !== submittedCommandDigest) {
59
60
  throw new TaskManagerError('command-receipt-mismatch', `command ${commit.commandId} digest does not match prior admission`);
60
61
  }
61
62
  return deepImmutableClone({
@@ -105,6 +106,22 @@ export class InMemoryTaskSpaceOwner implements TaskSpaceOwnerPort {
105
106
  }
106
107
  }
107
108
 
109
+ function validateSubmittedCommand(commit: TaskSpaceCommit): `sha256:${string}` {
110
+ if (commit.kind !== 'task-space-commit'
111
+ || commit.command.commandId !== commit.commandId
112
+ || commit.command.taskSpaceId !== commit.taskSpaceId) {
113
+ throw new TaskManagerError('command-receipt-mismatch', 'submitted command identity does not match its CAS envelope');
114
+ }
115
+ const commandDigest = canonicalOwnDataDigest(commit.command);
116
+ if (commandDigest !== commit.commandDigest) {
117
+ throw new TaskManagerError(
118
+ 'command-receipt-mismatch',
119
+ `command ${commit.commandId} digest does not match the submitted canonical command`,
120
+ );
121
+ }
122
+ return commandDigest;
123
+ }
124
+
108
125
  function validateCommit(
109
126
  commit: TaskSpaceCommit,
110
127
  current: MemoryTaskSpaceState | undefined,
package/src/processors.ts CHANGED
@@ -31,6 +31,7 @@ import type {
31
31
  WaitTaskCommand,
32
32
  } from 'task-manager-contract';
33
33
  import { canonicalOwnDataDigest } from 'task-manager-contract';
34
+ import { buildTaskCompletionGraph, type TaskCompletionGraph } from './completionGraph.js';
34
35
  import { TaskManagerError } from './errors.js';
35
36
  import { assertTaskTransition, isTerminal } from './statusMachine.js';
36
37
  import {
@@ -40,7 +41,6 @@ import {
40
41
  assertArtifactRef,
41
42
  deepImmutableClone,
42
43
  normalizeTaskProcessorConfig,
43
- predecessorIds,
44
44
  taskRecordById,
45
45
  validateTaskSpaceDefinition,
46
46
  validateTimestamp,
@@ -144,10 +144,16 @@ export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
144
144
  ...(patch.description === undefined ? {} : { description: patch.description }),
145
145
  };
146
146
  } else {
147
- const hasArtifact = current.definition.inputArtifacts.some(({ digest }) => digest === patch.artifact.digest);
147
+ const priorArtifact = current.definition.inputArtifacts.find(({ digest }) => digest === patch.artifact.digest);
148
+ if (priorArtifact && canonicalOwnDataDigest(priorArtifact) !== canonicalOwnDataDigest(patch.artifact)) {
149
+ throw new TaskManagerError(
150
+ 'artifact-conflict',
151
+ `artifact ${patch.artifact.digest} conflicts with the attached reference`,
152
+ );
153
+ }
148
154
  nextDefinition = {
149
155
  ...current.definition,
150
- inputArtifacts: hasArtifact
156
+ inputArtifacts: priorArtifact
151
157
  ? current.definition.inputArtifacts
152
158
  : [...current.definition.inputArtifacts, patch.artifact],
153
159
  };
@@ -518,9 +524,10 @@ export async function replanTask(
518
524
  updatedAt: input.replannedAt,
519
525
  };
520
526
  });
527
+ const completionGraph = buildTaskCompletionGraph(definition);
521
528
  const nextTasks: TaskRecord[] = provisional.map((task) => {
522
529
  if (isTerminal(task.status) || task.activeClaim || task.status === 'Running' || task.status === 'Waiting') return task;
523
- const status: TaskStatus = dependenciesSucceeded(definition, provisional, task.taskId) ? 'Ready' : 'Pending';
530
+ const status: TaskStatus = dependenciesSucceeded(completionGraph, provisional, task.taskId) ? 'Ready' : 'Pending';
524
531
  return { ...task, status };
525
532
  });
526
533
  const affectedTaskIds = [...new Set([...snapshot.tasks.map(({ taskId }) => taskId), ...nextTasks.map(({ taskId }) => taskId)])].sort();
@@ -708,11 +715,11 @@ function replaceTask(snapshot: TaskSpaceSnapshot, replacement: TaskRecord): Task
708
715
  }
709
716
 
710
717
  function dependenciesSucceeded(
711
- definition: TaskSpaceDefinition,
718
+ completionGraph: TaskCompletionGraph,
712
719
  tasks: readonly TaskRecord[],
713
720
  taskId: TaskId,
714
721
  ): boolean {
715
- const predecessors = effectivePredecessorIds(definition, taskId);
722
+ const predecessors = completionGraph.predecessorIdsByTask.get(taskId) ?? [];
716
723
  return predecessors.length === 0 || predecessors.every((predecessorId) =>
717
724
  tasks.find((task) => task.taskId === predecessorId)?.status === 'Succeeded');
718
725
  }
@@ -831,41 +838,12 @@ async function assertArtifactAuthority(
831
838
  }
832
839
  }
833
840
 
834
- function effectivePredecessorIds(definition: TaskSpaceDefinition, taskId: TaskId): readonly TaskId[] {
835
- const result = new Set(predecessorIds(definition, taskId));
836
- const parentByChild = new Map<TaskId, TaskId | null>();
837
- const childrenByParent = new Map<TaskId, { readonly taskId: TaskId; readonly order: number }[]>();
838
- for (const relation of definition.relations) {
839
- if (relation.kind !== 'parent-child') continue;
840
- parentByChild.set(relation.childTaskId, relation.parentTaskId);
841
- if (relation.parentTaskId !== null) {
842
- const children = childrenByParent.get(relation.parentTaskId) ?? [];
843
- children.push({ taskId: relation.childTaskId, order: relation.order });
844
- childrenByParent.set(relation.parentTaskId, children);
845
- }
846
- }
847
- let current: TaskId | null | undefined = taskId;
848
- while (current !== null && current !== undefined) {
849
- const parent = parentByChild.get(current);
850
- if (parent === null || parent === undefined) break;
851
- for (const predecessor of predecessorIds(definition, parent)) result.add(predecessor);
852
- const parentDefinition = definition.tasks.find(({ taskId: candidate }) => candidate === parent);
853
- if (parentDefinition?.kind === 'task-group' && parentDefinition.mode === 'sequential') {
854
- const siblings = [...(childrenByParent.get(parent) ?? [])]
855
- .sort((left, right) => left.order - right.order || left.taskId.localeCompare(right.taskId));
856
- const index = siblings.findIndex(({ taskId: candidate }) => candidate === current);
857
- if (index > 0) result.add(siblings[index - 1]!.taskId);
858
- }
859
- current = parent;
860
- }
861
- return [...result];
862
- }
863
-
864
841
  function stabilizeTaskProjection(
865
842
  definition: TaskSpaceDefinition,
866
843
  inputTasks: readonly TaskRecord[],
867
844
  projectedAt?: TaskTimestamp,
868
845
  ): TaskRecord[] {
846
+ const completionGraph = buildTaskCompletionGraph(definition);
869
847
  let tasks = inputTasks.map((task) => ({ ...task }));
870
848
  const childrenByParent = new Map<TaskId, TaskId[]>();
871
849
  for (const relation of definition.relations) {
@@ -880,13 +858,13 @@ function stabilizeTaskProjection(
880
858
  let status = task.status;
881
859
  if (task.definition.kind === 'task') {
882
860
  if (status === 'Pending' || status === 'Ready') {
883
- status = dependenciesSucceeded(definition, tasks, task.taskId) ? 'Ready' : 'Pending';
861
+ status = dependenciesSucceeded(completionGraph, tasks, task.taskId) ? 'Ready' : 'Pending';
884
862
  }
885
863
  } else {
886
864
  const childStatuses = (childrenByParent.get(task.taskId) ?? [])
887
865
  .map((childId) => tasks.find(({ taskId }) => taskId === childId)!.status);
888
866
  status = projectGroupStatus(task.definition.mode, childStatuses,
889
- dependenciesSucceeded(definition, tasks, task.taskId));
867
+ dependenciesSucceeded(completionGraph, tasks, task.taskId));
890
868
  if (isTerminal(task.status) && status !== task.status) {
891
869
  throw new TaskManagerError('terminal-task-immutable', `task-group ${task.taskId} is terminal (${task.status})`);
892
870
  }
@@ -12,18 +12,18 @@ export const TASK_TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]
12
12
  Cancelled: Object.freeze([] as const),
13
13
  });
14
14
 
15
- export const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
15
+ const TERMINAL_STATUSES = Object.freeze([
16
16
  'Succeeded',
17
17
  'Failed',
18
18
  'Cancelled',
19
- ]);
19
+ ] as const);
20
20
 
21
21
  export function canTransition(from: TaskStatus, to: TaskStatus): boolean {
22
22
  return TASK_TRANSITIONS[from].includes(to);
23
23
  }
24
24
 
25
25
  export function isTerminal(status: TaskStatus): status is Extract<TaskStatus, 'Succeeded' | 'Failed' | 'Cancelled'> {
26
- return TERMINAL_STATUSES.has(status);
26
+ return (TERMINAL_STATUSES as readonly TaskStatus[]).includes(status);
27
27
  }
28
28
 
29
29
  export function assertTaskTransition(taskId: string, from: TaskStatus, to: TaskStatus): void {
package/src/validation.ts CHANGED
@@ -7,12 +7,12 @@ import type {
7
7
  TaskNodeDefinition,
8
8
  TaskProfileEnvelope,
9
9
  TaskRecord,
10
- TaskRelation,
11
10
  TaskSpaceDefinition,
12
11
  TaskSpaceSnapshot,
13
12
  TaskTimestamp,
14
13
  } from 'task-manager-contract';
15
14
  import { deepFrozenCanonicalOwnDataClone, sha256Hex } from 'task-manager-contract';
15
+ import { buildTaskCompletionGraph } from './completionGraph.js';
16
16
  import { TaskManagerError } from './errors.js';
17
17
 
18
18
  export interface TaskProcessorConfig {
@@ -104,27 +104,8 @@ export function validateTaskSpaceDefinition(
104
104
  invalid(`task-group ${task.taskId} must contain at least one child`);
105
105
  }
106
106
  }
107
- const authorityEdges = new Map<TaskId, TaskId[]>();
108
- const addAuthorityEdge = (from: TaskId, to: TaskId): void => {
109
- const outgoing = authorityEdges.get(from) ?? [];
110
- outgoing.push(to);
111
- authorityEdges.set(from, outgoing);
112
- };
113
- for (const [parent, children] of childrenByParent) {
114
- for (const child of children) addAuthorityEdge(child, parent);
115
- }
116
- const descendants = (taskId: TaskId): TaskId[] => (childrenByParent.get(taskId) ?? [])
117
- .flatMap((child) => [child, ...descendants(child)]);
118
- for (const relation of definition.relations) {
119
- if (relation.kind !== 'dependency') continue;
120
- addAuthorityEdge(relation.predecessorTaskId, relation.successorTaskId);
121
- if (tasks.get(relation.successorTaskId)?.kind === 'task-group') {
122
- for (const descendant of descendants(relation.successorTaskId)) {
123
- addAuthorityEdge(relation.predecessorTaskId, descendant);
124
- }
125
- }
126
- }
127
- assertAcyclic(tasks.keys(), (id) => authorityEdges.get(id) ?? []);
107
+ const completionGraph = buildTaskCompletionGraph(definition);
108
+ assertAcyclic(tasks.keys(), (id) => completionGraph.authoritySuccessorIdsByTask.get(id) ?? []);
128
109
  }
129
110
 
130
111
  export function taskDefinitionById(
@@ -139,10 +120,7 @@ export function taskRecordById(snapshot: TaskSpaceSnapshot, taskId: TaskId): Tas
139
120
  }
140
121
 
141
122
  export function predecessorIds(definition: TaskSpaceDefinition, taskId: TaskId): readonly TaskId[] {
142
- return definition.relations
143
- .filter((relation): relation is Extract<TaskRelation, { kind: 'dependency' }> =>
144
- relation.kind === 'dependency' && relation.successorTaskId === taskId)
145
- .map((relation) => relation.predecessorTaskId);
123
+ return buildTaskCompletionGraph(definition).predecessorIdsByTask.get(taskId) ?? [];
146
124
  }
147
125
 
148
126
  export function validateTimestamp(value: TaskTimestamp, field: string): number {