task-manager-logic 0.1.1 → 0.1.3

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.
@@ -1,43 +1,36 @@
1
- /**
2
- * 任务状态机(BP-1):转移合法性 + started_at/ended_at 语义。
3
- */
4
- import {
5
- OPERATION_TARGET,
6
- TASK_TRANSITIONS,
7
- TERMINAL_STATUSES,
8
- type TaskDTO,
9
- type TaskOperation,
10
- type TaskStatus,
11
- } from 'task-manager-contract';
1
+ import type { TaskStatus } from 'task-manager-contract';
2
+ import { TaskManagerError } from './errors.js';
12
3
 
13
- export class TaskTransitionError extends Error {
14
- constructor(public taskId: string, public from: TaskStatus, public to: TaskStatus) {
15
- super(`illegal task transition ${from} -> ${to} (task ${taskId})`);
16
- }
17
- }
4
+ export const TASK_TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = Object.freeze({
5
+ Pending: Object.freeze(['Ready', 'Cancelled'] as const),
6
+ Ready: Object.freeze(['Claimed', 'Cancelled', 'Pending'] as const),
7
+ Claimed: Object.freeze(['Running', 'Ready', 'Cancelled'] as const),
8
+ Running: Object.freeze(['Waiting', 'Ready', 'Succeeded', 'Failed', 'Cancelled'] as const),
9
+ Waiting: Object.freeze(['Running', 'Ready', 'Succeeded', 'Failed', 'Cancelled'] as const),
10
+ Succeeded: Object.freeze([] as const),
11
+ Failed: Object.freeze([] as const),
12
+ Cancelled: Object.freeze([] as const),
13
+ });
14
+
15
+ export const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
16
+ 'Succeeded',
17
+ 'Failed',
18
+ 'Cancelled',
19
+ ]);
18
20
 
19
21
  export function canTransition(from: TaskStatus, to: TaskStatus): boolean {
20
22
  return TASK_TRANSITIONS[from].includes(to);
21
23
  }
22
24
 
23
- export function isTerminal(status: TaskStatus): boolean {
25
+ export function isTerminal(status: TaskStatus): status is Extract<TaskStatus, 'Succeeded' | 'Failed' | 'Cancelled'> {
24
26
  return TERMINAL_STATUSES.has(status);
25
27
  }
26
28
 
27
- /** 应用一次人工操作;非法转移抛 TaskTransitionError。返回同一 task 对象(原地更新)。 */
28
- export function applyOperation(
29
- task: TaskDTO,
30
- operation: TaskOperation,
31
- operatorAccount: string,
32
- nowMs: number,
33
- ): TaskDTO {
34
- const to = OPERATION_TARGET[operation];
35
- if (!canTransition(task.status, to)) throw new TaskTransitionError(task.id, task.status, to);
36
- const from = task.status;
37
- task.status = to;
38
- task.operatorAccount = operatorAccount;
39
- if (to === 'Active' && from !== 'Active') task.startedAtMs ??= nowMs;
40
- if (isTerminal(to)) task.endedAtMs = nowMs;
41
- if (operation === 'delegate' || operation === 'forward') task.transferredBy = operatorAccount;
42
- return task;
29
+ export function assertTaskTransition(taskId: string, from: TaskStatus, to: TaskStatus): void {
30
+ if (isTerminal(from)) {
31
+ throw new TaskManagerError('terminal-task-immutable', `task ${taskId} is terminal (${from})`);
32
+ }
33
+ if (!canTransition(from, to)) {
34
+ throw new TaskManagerError('invalid-task-transition', `task ${taskId} cannot transition ${from} -> ${to}`);
35
+ }
43
36
  }
@@ -0,0 +1,278 @@
1
+ import type {
2
+ TaskArtifactBody,
3
+ TaskArtifactRef,
4
+ TaskClaimToken,
5
+ TaskDefinition,
6
+ TaskId,
7
+ TaskNodeDefinition,
8
+ TaskProfileEnvelope,
9
+ TaskRecord,
10
+ TaskRelation,
11
+ TaskSpaceDefinition,
12
+ TaskSpaceSnapshot,
13
+ TaskTimestamp,
14
+ } from 'task-manager-contract';
15
+ import { deepFrozenCanonicalOwnDataClone, sha256Hex } from 'task-manager-contract';
16
+ import { TaskManagerError } from './errors.js';
17
+
18
+ export interface TaskProcessorConfig {
19
+ readonly maxTasks?: number;
20
+ readonly maxRelations?: number;
21
+ readonly maxLeaseDurationMs?: number;
22
+ }
23
+
24
+ export interface NormalizedTaskProcessorConfig {
25
+ readonly maxTasks: number;
26
+ readonly maxRelations: number;
27
+ readonly maxLeaseDurationMs: number;
28
+ }
29
+
30
+ export function normalizeTaskProcessorConfig(config: TaskProcessorConfig): NormalizedTaskProcessorConfig {
31
+ return {
32
+ maxTasks: positiveInteger(config.maxTasks ?? 10_000, 'maxTasks'),
33
+ maxRelations: positiveInteger(config.maxRelations ?? 50_000, 'maxRelations'),
34
+ maxLeaseDurationMs: positiveInteger(config.maxLeaseDurationMs ?? 86_400_000, 'maxLeaseDurationMs'),
35
+ };
36
+ }
37
+
38
+ export function validateTaskSpaceDefinition(
39
+ definition: TaskSpaceDefinition,
40
+ config: NormalizedTaskProcessorConfig,
41
+ ): void {
42
+ assertNonEmpty(definition.taskSpaceId, 'taskSpaceId');
43
+ assertNonEmpty(definition.name, 'TaskSpace name');
44
+ if (!Number.isSafeInteger(definition.schemaVersion) || definition.schemaVersion < 1) {
45
+ invalid('schemaVersion must be a positive safe integer');
46
+ }
47
+ if (definition.tasks.length === 0) invalid('TaskSpace must contain at least one task');
48
+ if (definition.tasks.length > config.maxTasks) invalid(`TaskSpace exceeds maxTasks=${config.maxTasks}`);
49
+ if (definition.relations.length > config.maxRelations) invalid(`TaskSpace exceeds maxRelations=${config.maxRelations}`);
50
+
51
+ const tasks = new Map<TaskId, TaskNodeDefinition>();
52
+ for (const task of definition.tasks) {
53
+ assertTaskDefinition(task);
54
+ if (tasks.has(task.taskId)) {
55
+ throw new TaskManagerError('duplicate-task-identity', `duplicate taskId ${task.taskId}`);
56
+ }
57
+ tasks.set(task.taskId, task);
58
+ }
59
+
60
+ const relationIds = new Set<string>();
61
+ const parentByChild = new Map<TaskId, TaskId | null>();
62
+ const childrenByParent = new Map<TaskId, TaskId[]>();
63
+ const dependencyEdges = new Map<TaskId, TaskId[]>();
64
+ for (const relation of definition.relations) {
65
+ assertNonEmpty(relation.relationId, 'relationId');
66
+ if (relationIds.has(relation.relationId)) invalid(`duplicate relationId ${relation.relationId}`);
67
+ relationIds.add(relation.relationId);
68
+ if (relation.kind === 'parent-child') {
69
+ if (!tasks.has(relation.childTaskId)) invalid(`parent relation references missing child ${relation.childTaskId}`);
70
+ if (parentByChild.has(relation.childTaskId)) invalid(`task ${relation.childTaskId} has multiple parents`);
71
+ if (!Number.isSafeInteger(relation.order) || relation.order < 0) invalid(`invalid order for ${relation.relationId}`);
72
+ if (relation.parentTaskId !== null) {
73
+ const parent = tasks.get(relation.parentTaskId);
74
+ if (!parent) invalid(`parent relation references missing parent ${relation.parentTaskId}`);
75
+ if (parent.kind !== 'task-group') invalid(`parent ${relation.parentTaskId} is not a task-group`);
76
+ if (relation.parentTaskId === relation.childTaskId) cyclic(`task ${relation.childTaskId} parents itself`);
77
+ const children = childrenByParent.get(relation.parentTaskId) ?? [];
78
+ children.push(relation.childTaskId);
79
+ childrenByParent.set(relation.parentTaskId, children);
80
+ }
81
+ parentByChild.set(relation.childTaskId, relation.parentTaskId);
82
+ continue;
83
+ }
84
+ if (!tasks.has(relation.predecessorTaskId) || !tasks.has(relation.successorTaskId)) {
85
+ invalid(`dependency ${relation.relationId} references a missing task`);
86
+ }
87
+ if (relation.predecessorTaskId === relation.successorTaskId) cyclic(`task ${relation.successorTaskId} depends on itself`);
88
+ const successors = dependencyEdges.get(relation.predecessorTaskId) ?? [];
89
+ successors.push(relation.successorTaskId);
90
+ dependencyEdges.set(relation.predecessorTaskId, successors);
91
+ }
92
+
93
+ for (const taskId of tasks.keys()) {
94
+ if (!parentByChild.has(taskId)) invalid(`task ${taskId} has no parent-child relation`);
95
+ }
96
+ assertAcyclic(tasks.keys(), (id) => {
97
+ const parent = parentByChild.get(id);
98
+ return parent === null || parent === undefined ? [] : [parent];
99
+ });
100
+ assertAcyclic(tasks.keys(), (id) => dependencyEdges.get(id) ?? []);
101
+
102
+ for (const task of tasks.values()) {
103
+ if (task.kind === 'task-group' && (childrenByParent.get(task.taskId)?.length ?? 0) === 0) {
104
+ invalid(`task-group ${task.taskId} must contain at least one child`);
105
+ }
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) ?? []);
128
+ }
129
+
130
+ export function taskDefinitionById(
131
+ definition: TaskSpaceDefinition,
132
+ taskId: TaskId,
133
+ ): TaskNodeDefinition | undefined {
134
+ return definition.tasks.find((task) => task.taskId === taskId);
135
+ }
136
+
137
+ export function taskRecordById(snapshot: TaskSpaceSnapshot, taskId: TaskId): TaskRecord | undefined {
138
+ return snapshot.tasks.find((task) => task.taskId === taskId);
139
+ }
140
+
141
+ 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);
146
+ }
147
+
148
+ export function validateTimestamp(value: TaskTimestamp, field: string): number {
149
+ const millis = Date.parse(value);
150
+ if (!Number.isFinite(millis) || new Date(millis).toISOString() !== value) {
151
+ invalid(`${field} must be a canonical ISO-8601 timestamp`);
152
+ }
153
+ return millis;
154
+ }
155
+
156
+ export function assertClaimIdentity(
157
+ snapshot: TaskSpaceSnapshot,
158
+ claim: TaskClaimToken,
159
+ taskId?: TaskId,
160
+ ): TaskRecord {
161
+ if (claim.taskSpaceId !== snapshot.taskSpaceId) {
162
+ throw new TaskManagerError('cross-space-identity', `claim belongs to ${claim.taskSpaceId}, not ${snapshot.taskSpaceId}`);
163
+ }
164
+ if (taskId !== undefined && claim.taskId !== taskId) {
165
+ throw new TaskManagerError('cross-space-identity', `claim task ${claim.taskId} does not match ${taskId}`);
166
+ }
167
+ const record = taskRecordById(snapshot, claim.taskId);
168
+ if (!record) throw new TaskManagerError('task-not-found', `task ${claim.taskId} not found`);
169
+ if (!record.activeClaim || !sameClaim(record.activeClaim, claim)) {
170
+ throw new TaskManagerError('claim-token-mismatch', `claim ${claim.claimId} is not the exact live token`);
171
+ }
172
+ return record;
173
+ }
174
+
175
+ export function assertClaimLive(claim: TaskClaimToken, at: TaskTimestamp): void {
176
+ const atMs = validateTimestamp(at, 'occurredAt');
177
+ const expiresAtMs = validateTimestamp(claim.expiresAt, 'claim.expiresAt');
178
+ if (atMs >= expiresAtMs) {
179
+ throw new TaskManagerError('claim-expired', `claim ${claim.claimId} expired at ${claim.expiresAt}`);
180
+ }
181
+ }
182
+
183
+ export function sameClaim(left: TaskClaimToken, right: TaskClaimToken): boolean {
184
+ return left.kind === right.kind
185
+ && left.claimId === right.claimId
186
+ && left.taskSpaceId === right.taskSpaceId
187
+ && left.taskId === right.taskId
188
+ && left.assigneeRef === right.assigneeRef
189
+ && left.attempt === right.attempt
190
+ && left.leaseEpoch === right.leaseEpoch
191
+ && left.claimedAt === right.claimedAt
192
+ && left.heartbeatAt === right.heartbeatAt
193
+ && left.expiresAt === right.expiresAt;
194
+ }
195
+
196
+ export function deepImmutableClone<Value>(value: Value): Value {
197
+ try {
198
+ return deepFrozenCanonicalOwnDataClone(value);
199
+ } catch (error) {
200
+ throw new TaskManagerError('canonical-data-invalid', String(error));
201
+ }
202
+ }
203
+
204
+ function assertTaskDefinition(task: TaskNodeDefinition): asserts task is TaskDefinition | Exclude<TaskNodeDefinition, TaskDefinition> {
205
+ assertNonEmpty(task.taskId, 'taskId');
206
+ assertNonEmpty(task.name, `task ${task.taskId} name`);
207
+ if (!Number.isSafeInteger(task.order) || task.order < 0) invalid(`task ${task.taskId} has invalid order`);
208
+ if (task.kind === 'task-group' && !['sequential', 'all', 'any'].includes(task.mode)) {
209
+ invalid(`task-group ${task.taskId} has invalid mode`);
210
+ }
211
+ assertProfile(task.profile, task.taskId);
212
+ for (const artifact of task.inputArtifacts) assertArtifactRef(artifact, `task ${task.taskId} input artifact`);
213
+ }
214
+
215
+ export function assertArtifactRef(artifact: TaskArtifactRef, field: string): void {
216
+ if (artifact.kind !== 'task-artifact-ref' || !/^sha256:[a-f0-9]{64}$/.test(artifact.digest)) {
217
+ invalid(`${field} has invalid digest`);
218
+ }
219
+ assertNonEmpty(artifact.mediaType, `${field} mediaType`);
220
+ if (!Number.isSafeInteger(artifact.sizeBytes) || artifact.sizeBytes < 0) invalid(`${field} has invalid size`);
221
+ if (artifact.name !== undefined) assertNonEmpty(artifact.name, `${field} name`);
222
+ }
223
+
224
+ export function assertArtifactBody(body: TaskArtifactBody, field: string): void {
225
+ if (body.kind !== 'task-artifact-body' || body.encoding !== 'base64') invalid(`${field} has invalid encoding`);
226
+ assertNonEmpty(body.mediaType, `${field} mediaType`);
227
+ if (!Number.isSafeInteger(body.sizeBytes) || body.sizeBytes < 0) invalid(`${field} has invalid size`);
228
+ let binary: string;
229
+ try {
230
+ binary = atob(body.data);
231
+ } catch {
232
+ invalid(`${field} has invalid base64`);
233
+ }
234
+ if (btoa(binary) !== body.data) invalid(`${field} base64 is not canonical`);
235
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
236
+ if (bytes.byteLength !== body.sizeBytes || `sha256:${sha256Hex(bytes)}` !== body.digest) {
237
+ invalid(`${field} digest or size mismatch`);
238
+ }
239
+ }
240
+
241
+ function assertProfile(profile: TaskProfileEnvelope, taskId: string): void {
242
+ if (profile.kind !== 'task-profile') invalid(`task ${taskId} has invalid profile envelope`);
243
+ assertNonEmpty(profile.profileKind, `task ${taskId} profileKind`);
244
+ if (!Number.isSafeInteger(profile.schemaVersion) || profile.schemaVersion < 1) {
245
+ invalid(`task ${taskId} has invalid profile schemaVersion`);
246
+ }
247
+ }
248
+
249
+ function assertAcyclic(ids: Iterable<TaskId>, outgoing: (id: TaskId) => readonly TaskId[]): void {
250
+ const visiting = new Set<TaskId>();
251
+ const visited = new Set<TaskId>();
252
+ const visit = (id: TaskId): void => {
253
+ if (visiting.has(id)) cyclic(`cycle includes task ${id}`);
254
+ if (visited.has(id)) return;
255
+ visiting.add(id);
256
+ for (const successor of outgoing(id)) visit(successor);
257
+ visiting.delete(id);
258
+ visited.add(id);
259
+ };
260
+ for (const id of ids) visit(id);
261
+ }
262
+
263
+ function positiveInteger(value: number, field: string): number {
264
+ if (!Number.isSafeInteger(value) || value <= 0) invalid(`${field} must be a positive safe integer`);
265
+ return value;
266
+ }
267
+
268
+ function assertNonEmpty(value: string, field: string): void {
269
+ if (value.length === 0 || value.trim() !== value) invalid(`${field} must be non-empty and trimmed`);
270
+ }
271
+
272
+ function invalid(message: string): never {
273
+ throw new TaskManagerError('invalid-task-definition', message);
274
+ }
275
+
276
+ function cyclic(message: string): never {
277
+ throw new TaskManagerError('cyclic-task-forest', message);
278
+ }
package/src/stores.ts DELETED
@@ -1,42 +0,0 @@
1
- /**
2
- * 任务树外置存储:内存 + 文件(JSON)。
3
- */
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import type { TaskSpaceDTO, TaskSpaceStore } from 'task-manager-contract';
7
-
8
- export class InMemoryTaskSpaceStore implements TaskSpaceStore {
9
- private spaces = new Map<string, TaskSpaceDTO>();
10
- async load(spaceId: string): Promise<TaskSpaceDTO | undefined> {
11
- const s = this.spaces.get(spaceId);
12
- return s ? structuredClone(s) : undefined;
13
- }
14
- async save(spaceId: string, space: TaskSpaceDTO): Promise<void> {
15
- this.spaces.set(spaceId, structuredClone(space));
16
- }
17
- async remove(spaceId: string): Promise<void> {
18
- this.spaces.delete(spaceId);
19
- }
20
- }
21
-
22
- export class FileTaskSpaceStore implements TaskSpaceStore {
23
- constructor(private dir: string) {
24
- fs.mkdirSync(dir, { recursive: true });
25
- }
26
- private file(spaceId: string) {
27
- return path.join(this.dir, `${encodeURIComponent(spaceId)}.task-space.json`);
28
- }
29
- async load(spaceId: string): Promise<TaskSpaceDTO | undefined> {
30
- const f = this.file(spaceId);
31
- if (!fs.existsSync(f)) return undefined;
32
- return JSON.parse(fs.readFileSync(f, 'utf8')) as TaskSpaceDTO;
33
- }
34
- async save(spaceId: string, space: TaskSpaceDTO): Promise<void> {
35
- const f = this.file(spaceId);
36
- fs.writeFileSync(f + '.tmp', JSON.stringify(space, null, 2));
37
- fs.renameSync(f + '.tmp', f);
38
- }
39
- async remove(spaceId: string): Promise<void> {
40
- fs.rmSync(this.file(spaceId), { force: true });
41
- }
42
- }
package/src/tree.ts DELETED
@@ -1,100 +0,0 @@
1
- /**
2
- * 任务树查找与组聚合(executionMode 语义,BP-2/BP-4)。
3
- */
4
- import type { ExecutionMode, TaskDTO, TaskGroupDTO, TaskSpaceDTO, TaskStatus, TaskTreeNode } from 'task-manager-contract';
5
- import { isTerminal } from './statusMachine.js';
6
-
7
- export function findNode(space: TaskSpaceDTO, id: string): TaskTreeNode | undefined {
8
- const walk = (nodes: TaskTreeNode[]): TaskTreeNode | undefined => {
9
- for (const n of nodes) {
10
- if (n.id === id) return n;
11
- if (n.kind === 'group') {
12
- const hit = walk(n.children);
13
- if (hit) return hit;
14
- }
15
- }
16
- return undefined;
17
- };
18
- return walk(space.children);
19
- }
20
-
21
- export function findTask(space: TaskSpaceDTO, id: string): TaskDTO | undefined {
22
- const n = findNode(space, id);
23
- return n?.kind === 'task' ? n : undefined;
24
- }
25
-
26
- export function listTasks(space: TaskSpaceDTO): TaskDTO[] {
27
- const out: TaskDTO[] = [];
28
- const walk = (nodes: TaskTreeNode[]) => {
29
- for (const n of nodes) (n.kind === 'task' ? out.push(n) : walk(n.children));
30
- };
31
- walk(space.children);
32
- return out;
33
- }
34
-
35
- /** 直接子节点 id 集合(TaskStep 只能绑定它们,BP-2) */
36
- export function directChildIds(space: TaskSpaceDTO): Set<string> {
37
- return new Set(space.children.map((c) => c.id));
38
- }
39
-
40
- /**
41
- * 节点终态判定:Task 用自身状态;Group 按 executionMode 聚合。
42
- * 返回 undefined 表示尚未终态。
43
- * - SEQUENTIAL / ALL:全部子节点终态才终态;全 DONE(FORWARDED 视同 DONE)→ DONE,否则 REFUSED。
44
- * - ANY:任一 DONE/FORWARDED → DONE;全部终态且无 DONE → REFUSED。
45
- */
46
- export function terminalStatusOf(node: TaskTreeNode): TaskStatus | undefined {
47
- if (node.kind === 'task') return isTerminal(node.status) ? node.status : undefined;
48
- return aggregateGroup(node);
49
- }
50
-
51
- const doneLike = (s: TaskStatus) => s === 'Done' || s === 'Forwarded';
52
-
53
- function aggregateGroup(group: TaskGroupDTO): TaskStatus | undefined {
54
- const childStatuses = group.children.map((c) => terminalStatusOf(c));
55
- const mode: ExecutionMode = group.executionMode;
56
- if (mode === 'ANY') {
57
- if (childStatuses.some((s) => s !== undefined && doneLike(s))) return 'Done';
58
- if (childStatuses.every((s) => s !== undefined)) return 'Refused';
59
- return undefined;
60
- }
61
- // SEQUENTIAL / ALL
62
- if (childStatuses.some((s) => s === undefined)) {
63
- // 提前失败:任一 REFUSED/ABANDONED 即整体 REFUSED(不必等其余)
64
- if (childStatuses.some((s) => s !== undefined && !doneLike(s))) return 'Refused';
65
- return undefined;
66
- }
67
- return childStatuses.every((s) => doneLike(s!)) ? 'Done' : 'Refused';
68
- }
69
-
70
- /** SEQUENTIAL 组内可操作性:前序兄弟(按 order)必须 done-like */
71
- export function isOperable(space: TaskSpaceDTO, taskId: string): boolean {
72
- const check = (nodes: TaskTreeNode[], mode: ExecutionMode | null): boolean => {
73
- const sorted = [...nodes].sort((a, b) => a.order - b.order);
74
- for (let i = 0; i < sorted.length; i++) {
75
- const n = sorted[i];
76
- if (n.id === taskId || (n.kind === 'group' && findInGroup(n, taskId))) {
77
- if (mode === 'SEQUENTIAL') {
78
- for (let j = 0; j < i; j++) {
79
- const prev = terminalStatusOf(sorted[j]);
80
- if (prev === undefined || !doneLike(prev)) return false;
81
- }
82
- }
83
- return n.id === taskId ? true : check((n as TaskGroupDTO).children, (n as TaskGroupDTO).executionMode);
84
- }
85
- }
86
- return false;
87
- };
88
- const findInGroup = (g: TaskGroupDTO, id: string): boolean => !!findNodeIn(g.children, id);
89
- const findNodeIn = (nodes: TaskTreeNode[], id: string): TaskTreeNode | undefined => {
90
- for (const n of nodes) {
91
- if (n.id === id) return n;
92
- if (n.kind === 'group') {
93
- const hit = findNodeIn(n.children, id);
94
- if (hit) return hit;
95
- }
96
- }
97
- return undefined;
98
- };
99
- return check(space.children, null);
100
- }