@umudik/task-bridge 0.0.1

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/apps/backend/dist/config.js +26 -0
  4. package/apps/backend/dist/db/epic-workflow-db.js +125 -0
  5. package/apps/backend/dist/db/library-db.js +123 -0
  6. package/apps/backend/dist/db/projects-db.js +110 -0
  7. package/apps/backend/dist/db/tasks-db.js +282 -0
  8. package/apps/backend/dist/db/users-db.js +117 -0
  9. package/apps/backend/dist/db/workflow-db.js +186 -0
  10. package/apps/backend/dist/db/workflow-template-db.js +715 -0
  11. package/apps/backend/dist/domain/project-member.js +15 -0
  12. package/apps/backend/dist/domain/task-template-graph.js +63 -0
  13. package/apps/backend/dist/domain/task.js +93 -0
  14. package/apps/backend/dist/domain/work-status.js +30 -0
  15. package/apps/backend/dist/domain/workflow-stage.js +186 -0
  16. package/apps/backend/dist/domain/workflow-state.js +73 -0
  17. package/apps/backend/dist/domain/workflow-template-id.js +6 -0
  18. package/apps/backend/dist/errors/app-error.js +24 -0
  19. package/apps/backend/dist/index.js +67 -0
  20. package/apps/backend/dist/lib/bridge-project.js +24 -0
  21. package/apps/backend/dist/lib/inbox-cursor.js +34 -0
  22. package/apps/backend/dist/lib/strings.js +15 -0
  23. package/apps/backend/dist/logger.js +29 -0
  24. package/apps/backend/dist/mappers/task-response.js +261 -0
  25. package/apps/backend/dist/middleware/auth.js +29 -0
  26. package/apps/backend/dist/openapi.js +716 -0
  27. package/apps/backend/dist/routes/admin-users.js +79 -0
  28. package/apps/backend/dist/routes/auth.js +81 -0
  29. package/apps/backend/dist/routes/connect.js +1 -0
  30. package/apps/backend/dist/routes/docs.js +13 -0
  31. package/apps/backend/dist/routes/health.js +6 -0
  32. package/apps/backend/dist/routes/library.js +139 -0
  33. package/apps/backend/dist/routes/projects.js +95 -0
  34. package/apps/backend/dist/routes/tasks.js +522 -0
  35. package/apps/backend/dist/routes/web.js +79 -0
  36. package/apps/backend/dist/routes/workflow-templates.js +152 -0
  37. package/apps/backend/dist/routes/workflow.js +165 -0
  38. package/apps/backend/dist/services/connect-target.js +4 -0
  39. package/apps/backend/dist/services/epic-service.js +269 -0
  40. package/apps/backend/dist/services/library-service.js +222 -0
  41. package/apps/backend/dist/services/project-registry.js +122 -0
  42. package/apps/backend/dist/services/task-assignee-service.js +42 -0
  43. package/apps/backend/dist/services/task-claim-policy.js +310 -0
  44. package/apps/backend/dist/services/task-queue.js +105 -0
  45. package/apps/backend/dist/services/task-service.js +198 -0
  46. package/apps/backend/dist/services/workflow-rules.js +18 -0
  47. package/apps/backend/dist/services/workflow-service.js +418 -0
  48. package/apps/backend/dist/services/workflow-spawn-service.js +179 -0
  49. package/apps/backend/dist/services/workflow-state-service.js +157 -0
  50. package/apps/backend/dist/services/workflow-template-service.js +204 -0
  51. package/apps/backend/public/assets/index-Bl1ciVpY.js +409 -0
  52. package/apps/backend/public/assets/index-ByKECv-I.css +1 -0
  53. package/apps/backend/public/index.html +13 -0
  54. package/bin/task-bridge.mjs +86 -0
  55. package/package.json +41 -0
@@ -0,0 +1,418 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { countWorkflowStages, deleteProjectMemberRow, deleteWorkflowStagesForProject, insertProjectMemberRow, insertWorkflowStageRow, listProjectMemberRows, listProjectWorkflowSettingsRows, listWorkflowStageRows, updateProjectMemberRow, upsertProjectWorkflowSettingsRow, } from "../db/workflow-db.js";
3
+ import { SUBTASK_SPAWN_STAGE_ID, countSpawnableTemplates, parseRulesJson, resolveEpicDescription, resolveStageTaskTemplates, serializeTaskTemplates, stageHasActionableTemplates, } from "../domain/workflow-stage.js";
4
+ import { isDoneStage, listSubtasks } from "../domain/task.js";
5
+ import { AppError } from "../errors/app-error.js";
6
+ import { deleteEpicSubtasks, mutateTaskRow } from "../db/tasks-db.js";
7
+ import { insertEpicRow, listEpicRows } from "../db/epic-workflow-db.js";
8
+ import { allocateTaskId, getBridgeTask, listBridgeTasks, upsertBridgeTask } from "./task-service.js";
9
+ import { pickMemberByProjectRole, resolveTaskAssignee } from "./task-assignee-service.js";
10
+ import { DEFAULT_WORKFLOW_TEMPLATE_ID } from "../domain/workflow-template-id.js";
11
+ import { copyTemplateStagesToProject, getWorkflowTemplate } from "./workflow-template-service.js";
12
+ import { validateStageTransition } from "./workflow-rules.js";
13
+ import { rebuildEpicWorkflowState } from "./workflow-state-service.js";
14
+ function normalizeStageForSave(stage, projectRoles = null) {
15
+ const taskTemplates = (stage.taskTemplates || []).map((template) => {
16
+ let assigneeRole = null;
17
+ let rawRole = "";
18
+ if (template.assigneeRole) {
19
+ rawRole = template.assigneeRole;
20
+ }
21
+ if (rawRole && (!projectRoles || projectRoles.has(rawRole))) {
22
+ assigneeRole = rawRole;
23
+ }
24
+ return Object.assign({}, template, { assigneeRole });
25
+ });
26
+ let autoAssignRole = null;
27
+ let rawRole = "";
28
+ if (stage.autoAssignRole) {
29
+ rawRole = stage.autoAssignRole;
30
+ }
31
+ if (rawRole && (!projectRoles || projectRoles.has(rawRole))) {
32
+ autoAssignRole = rawRole;
33
+ }
34
+ return Object.assign({}, stage, {
35
+ taskTemplates,
36
+ autoAssignRole,
37
+ spawnTaskCount: countSpawnableTemplates(taskTemplates),
38
+ });
39
+ }
40
+ function normalizeProjectRoles(roles) {
41
+ const seen = new Set();
42
+ const result = [];
43
+ for (const role of roles) {
44
+ if (!role || seen.has(role))
45
+ continue;
46
+ seen.add(role);
47
+ result.push(role);
48
+ }
49
+ return result;
50
+ }
51
+ function loadProjectRoles(projectId) {
52
+ const settingsRows = listProjectWorkflowSettingsRows({ projectId });
53
+ const firstSettingsRow = settingsRows[0];
54
+ if (!firstSettingsRow)
55
+ return [];
56
+ return normalizeProjectRoles(parseRulesJson(firstSettingsRow.roles_json));
57
+ }
58
+ function rowToStage(row) {
59
+ const taskTemplates = resolveStageTaskTemplates({
60
+ taskTemplatesJson: row.task_templates_json,
61
+ stageId: row.id,
62
+ stageTitle: row.title,
63
+ });
64
+ const autoAssignRole = row.auto_assign_role || null;
65
+ return {
66
+ id: row.id,
67
+ title: row.title,
68
+ description: resolveEpicDescription({
69
+ description: row.description,
70
+ purpose: row.purpose,
71
+ rulesJson: row.rules_json,
72
+ }),
73
+ position: row.position,
74
+ autoAssignRole,
75
+ layoutX: row.layout_x,
76
+ layoutY: row.layout_y,
77
+ spawnTaskCount: countSpawnableTemplates(taskTemplates),
78
+ taskTemplates,
79
+ activeTaskCount: null,
80
+ };
81
+ }
82
+ function countOpenTasksForMember(tasks, memberName) {
83
+ return tasks.filter((task) => task.assignee === memberName && !isDoneStage(task.stageId)).length;
84
+ }
85
+ function memberRowToProjectMember(row, tasks) {
86
+ return {
87
+ id: row.id,
88
+ projectId: row.project_id,
89
+ name: row.name,
90
+ role: row.role,
91
+ openTasks: countOpenTasksForMember(tasks.filter((task) => task.projectId === row.project_id), row.name),
92
+ };
93
+ }
94
+ export function ensureProjectWorkflow(projectId) {
95
+ const id = projectId;
96
+ if (!id)
97
+ return;
98
+ if (countWorkflowStages(id) > 0)
99
+ return;
100
+ copyTemplateStagesToProject(id, DEFAULT_WORKFLOW_TEMPLATE_ID);
101
+ }
102
+ export function applyWorkflowTemplateToProject(projectId, templateId) {
103
+ const template = getWorkflowTemplate(templateId);
104
+ if (!template) {
105
+ throw new AppError("Workflow template not found", 404);
106
+ }
107
+ copyTemplateStagesToProject(projectId, templateId);
108
+ resetProjectEpicWorkflows(projectId);
109
+ return getProjectWorkflow(projectId);
110
+ }
111
+ function resetProjectEpicWorkflows(projectId) {
112
+ const id = projectId;
113
+ const firstStageId = getFirstStageId(id);
114
+ const epicIds = new Set();
115
+ for (const row of listEpicRows({ id: 0, projectId: id })) {
116
+ epicIds.add(row.id);
117
+ }
118
+ const tasks = listBridgeTasks();
119
+ for (const task of tasks) {
120
+ if (task.projectId === id && task.parentId === null) {
121
+ epicIds.add(task.id);
122
+ }
123
+ }
124
+ for (const epicId of epicIds) {
125
+ deleteEpicSubtasks(epicId);
126
+ mutateTaskRow(epicId, (task) => {
127
+ task.stageId = firstStageId;
128
+ task.claimedBy = null;
129
+ task.claimedAt = null;
130
+ });
131
+ if (listEpicRows({ id: epicId, projectId: "" }).length === 0) {
132
+ const epicTask = tasks.find((task) => task.id === epicId);
133
+ if (epicTask) {
134
+ insertEpicRow({
135
+ id: epicId,
136
+ projectId: id,
137
+ title: epicTask.title,
138
+ description: epicTask.description,
139
+ stageId: firstStageId,
140
+ createdBy: epicTask.createdBy,
141
+ });
142
+ }
143
+ }
144
+ rebuildEpicWorkflowState(epicId, id, firstStageId);
145
+ }
146
+ }
147
+ export function getFirstStageId(projectId) {
148
+ ensureProjectWorkflow(projectId);
149
+ const rows = listWorkflowStageRows({ projectId, stageId: "" }).sort((a, b) => a.position - b.position);
150
+ if (rows.length === 0)
151
+ return null;
152
+ for (const row of rows) {
153
+ if (stageHasActionableTemplates({
154
+ taskTemplatesJson: row.task_templates_json,
155
+ stageId: row.id,
156
+ stageTitle: row.title,
157
+ })) {
158
+ return row.id;
159
+ }
160
+ }
161
+ const firstRow = rows[0];
162
+ if (!firstRow)
163
+ return null;
164
+ return firstRow.id;
165
+ }
166
+ export function resolveNewTaskPlacement(projectId) {
167
+ ensureProjectWorkflow(projectId);
168
+ const stageId = getFirstStageId(projectId);
169
+ if (!stageId) {
170
+ const assignee = pickMemberByProjectRole(projectId, "");
171
+ return { stageId: null, assignee, assigneeRole: null };
172
+ }
173
+ const stageRows = listWorkflowStageRows({ projectId, stageId });
174
+ const row = stageRows[0];
175
+ if (!row) {
176
+ const assignee = pickMemberByProjectRole(projectId, "");
177
+ return { stageId, assignee, assigneeRole: null };
178
+ }
179
+ const autoAssignRole = row.auto_assign_role;
180
+ const resolved = resolveTaskAssignee({
181
+ projectId,
182
+ assignee: null,
183
+ assigneeRole: autoAssignRole || null,
184
+ stageId,
185
+ });
186
+ return { stageId, assignee: resolved.assignee, assigneeRole: resolved.assigneeRole };
187
+ }
188
+ export function getStageTitleLookup(projectId) {
189
+ const map = new Map();
190
+ for (const row of listWorkflowStageRows({ projectId, stageId: "" })) {
191
+ map.set(row.id, row.title);
192
+ }
193
+ return map;
194
+ }
195
+ function countActiveTasksForStage(tasks, stageId) {
196
+ return tasks.filter((task) => task.parentId === null && task.stageId === stageId).length;
197
+ }
198
+ export function getProjectWorkflow(projectId) {
199
+ ensureProjectWorkflow(projectId);
200
+ const tasks = listBridgeTasks();
201
+ const projectTasks = tasks.filter((task) => task.projectId === projectId);
202
+ const stages = listWorkflowStageRows({ projectId, stageId: "" }).map((row) => {
203
+ const stage = rowToStage(row);
204
+ return Object.assign({}, stage, {
205
+ activeTaskCount: countActiveTasksForStage(projectTasks, stage.id),
206
+ });
207
+ });
208
+ const roles = loadProjectRoles(projectId);
209
+ const members = listProjectMemberRows({ projectId, id: "" }).map((row) => memberRowToProjectMember(row, tasks));
210
+ return { projectId, roles, stages, members };
211
+ }
212
+ export function replaceProjectWorkflow(projectId, stages, roles = []) {
213
+ const id = projectId;
214
+ if (stages.length < 1) {
215
+ throw new AppError("At least one workflow stage is required", 400);
216
+ }
217
+ const normalizedRoles = normalizeProjectRoles(roles);
218
+ const roleSet = new Set(normalizedRoles);
219
+ deleteWorkflowStagesForProject(id);
220
+ stages.forEach((stage, index) => {
221
+ const normalized = normalizeStageForSave(stage, roleSet);
222
+ let position = index;
223
+ if (normalized.position > 0 || index === 0) {
224
+ position = normalized.position;
225
+ }
226
+ const autoAssignRole = normalized.autoAssignRole || "";
227
+ insertWorkflowStageRow({
228
+ id: normalized.id,
229
+ projectId: id,
230
+ title: normalized.title,
231
+ description: normalized.description,
232
+ purpose: "",
233
+ rulesJson: "[]",
234
+ position,
235
+ autoAssignRole,
236
+ layoutX: normalized.layoutX,
237
+ layoutY: normalized.layoutY,
238
+ spawnTaskCount: normalized.spawnTaskCount,
239
+ taskTemplatesJson: serializeTaskTemplates(normalized.taskTemplates),
240
+ });
241
+ });
242
+ upsertProjectWorkflowSettingsRow(id, JSON.stringify(normalizedRoles));
243
+ resetProjectEpicWorkflows(id);
244
+ return getProjectWorkflow(id);
245
+ }
246
+ export { pickMemberByProjectRole } from "./task-assignee-service.js";
247
+ export function applyStageToTask(task, stageId) {
248
+ validateStageTransition(task, stageId);
249
+ const stageRows = listWorkflowStageRows({ projectId: task.projectId, stageId });
250
+ if (stageRows.length === 0) {
251
+ throw new AppError("Unknown stage", 400);
252
+ }
253
+ const stage = stageRows[0];
254
+ if (!stage)
255
+ throw new AppError("Unknown stage", 400);
256
+ const autoAssignRole = stage.auto_assign_role;
257
+ return resolveTaskAssignee({
258
+ projectId: task.projectId,
259
+ assignee: null,
260
+ assigneeRole: autoAssignRole || task.assigneeRole,
261
+ stageId,
262
+ });
263
+ }
264
+ export function spawnStageSubtasks(parent, stageId) {
265
+ if (parent.parentId !== null)
266
+ return [];
267
+ if (stageId !== SUBTASK_SPAWN_STAGE_ID)
268
+ return [];
269
+ const stageRows = listWorkflowStageRows({ projectId: parent.projectId, stageId });
270
+ if (stageRows.length === 0)
271
+ return [];
272
+ const row = stageRows[0];
273
+ if (!row)
274
+ return [];
275
+ const templates = resolveStageTaskTemplates({
276
+ taskTemplatesJson: row.task_templates_json,
277
+ stageId: row.id,
278
+ stageTitle: row.title,
279
+ });
280
+ if (templates.length === 0)
281
+ return [];
282
+ const tasks = listBridgeTasks();
283
+ const existing = listSubtasks(tasks, parent.id);
284
+ if (existing.length >= templates.length)
285
+ return [];
286
+ const created = [];
287
+ for (let i = existing.length; i < templates.length; i += 1) {
288
+ const template = templates[i];
289
+ if (!template)
290
+ continue;
291
+ let assignee = null;
292
+ let templateRole = "";
293
+ if (template.assigneeRole) {
294
+ templateRole = template.assigneeRole;
295
+ }
296
+ if (templateRole) {
297
+ assignee = pickMemberByProjectRole(parent.projectId, templateRole);
298
+ }
299
+ else {
300
+ const autoAssignRole = row.auto_assign_role;
301
+ if (autoAssignRole) {
302
+ assignee = pickMemberByProjectRole(parent.projectId, autoAssignRole);
303
+ }
304
+ }
305
+ const id = allocateTaskId();
306
+ const task = upsertBridgeTask({
307
+ id,
308
+ projectId: parent.projectId,
309
+ projectName: parent.projectName,
310
+ title: template.title,
311
+ description: template.description,
312
+ createdBy: "workflow",
313
+ createdAt: null,
314
+ parentId: parent.id,
315
+ epicId: null,
316
+ stageId,
317
+ assignee,
318
+ assigneeRole: template.assigneeRole,
319
+ assigneeKind: null,
320
+ templateId: template.id,
321
+ workStatus: "todo",
322
+ });
323
+ created.push(task);
324
+ }
325
+ return created;
326
+ }
327
+ export function getStageSnapshot(projectId, stageId) {
328
+ if (!stageId)
329
+ return null;
330
+ ensureProjectWorkflow(projectId);
331
+ const stageRows = listWorkflowStageRows({ projectId, stageId });
332
+ if (stageRows.length === 0)
333
+ return null;
334
+ const row = stageRows[0];
335
+ if (!row)
336
+ return null;
337
+ const stage = rowToStage(row);
338
+ return {
339
+ id: stage.id,
340
+ title: stage.title,
341
+ description: stage.description,
342
+ spawnTaskCount: stage.spawnTaskCount,
343
+ taskTemplates: stage.taskTemplates,
344
+ };
345
+ }
346
+ export function createProjectMember(input) {
347
+ const id = randomUUID();
348
+ insertProjectMemberRow({
349
+ id,
350
+ projectId: input.projectId,
351
+ name: input.name,
352
+ role: input.role,
353
+ });
354
+ const memberRows = listProjectMemberRows({ projectId: "", id });
355
+ if (memberRows.length === 0)
356
+ throw new Error("Failed to create member");
357
+ const row = memberRows[0];
358
+ if (!row)
359
+ throw new Error("Failed to create member");
360
+ const tasks = listBridgeTasks();
361
+ return memberRowToProjectMember(row, tasks);
362
+ }
363
+ export function updateProjectMember(id, patch) {
364
+ if (!updateProjectMemberRow(id, patch)) {
365
+ return null;
366
+ }
367
+ const memberRows = listProjectMemberRows({ projectId: "", id });
368
+ if (memberRows.length === 0)
369
+ return null;
370
+ const row = memberRows[0];
371
+ if (!row)
372
+ return null;
373
+ const tasks = listBridgeTasks();
374
+ return memberRowToProjectMember(row, tasks);
375
+ }
376
+ export function removeProjectMember(id) {
377
+ return deleteProjectMemberRow(id);
378
+ }
379
+ export function exportWorkflowReadable(projectId) {
380
+ const workflow = getProjectWorkflow(projectId);
381
+ return {
382
+ projectId: workflow.projectId,
383
+ roles: workflow.roles,
384
+ stages: workflow.stages.map((stage) => ({
385
+ id: stage.id,
386
+ title: stage.title,
387
+ description: stage.description,
388
+ autoAssignRole: stage.autoAssignRole || "",
389
+ spawnTaskCount: stage.spawnTaskCount,
390
+ taskTemplates: stage.taskTemplates,
391
+ })),
392
+ members: workflow.members.map((member) => ({
393
+ id: member.id,
394
+ name: member.name,
395
+ role: member.role,
396
+ openTasks: member.openTasks,
397
+ })),
398
+ };
399
+ }
400
+ export function enrichTaskWithWorkflow(task) {
401
+ if (!task)
402
+ return null;
403
+ const stage = getStageSnapshot(task.projectId, task.stageId);
404
+ return {
405
+ stageId: task.stageId,
406
+ stage,
407
+ assignee: task.assignee,
408
+ };
409
+ }
410
+ export function validateTaskBelongsToProject(taskId, projectId) {
411
+ const task = getBridgeTask(taskId);
412
+ if (!task)
413
+ return null;
414
+ if (task.projectId !== projectId) {
415
+ throw new AppError("Task not found", 404);
416
+ }
417
+ return task;
418
+ }
@@ -0,0 +1,179 @@
1
+ import { listWorkflowStageRows } from "../db/workflow-db.js";
2
+ import { collectSpawnableTemplates, } from "../domain/task-template-graph.js";
3
+ import { isWorkDone } from "../domain/work-status.js";
4
+ import { listEpicWorkflowTasks } from "../domain/task.js";
5
+ import { resolveStageTaskTemplateRoots, stageHasActionableTemplates, } from "../domain/workflow-stage.js";
6
+ import { emptyToNull } from "../lib/strings.js";
7
+ import { mutateTaskRow } from "../db/tasks-db.js";
8
+ import { applyWorkflowStateNodeToTaskInput, linkSpawnedTemplateTask, loadEpicWorkflowState, spawnContextFromWorkflowState, syncTaskIntoWorkflowState, } from "./workflow-state-service.js";
9
+ import { computeEpicStageId } from "./epic-service.js";
10
+ import { allocateTaskId, listBridgeTasks, upsertBridgeTask } from "./task-service.js";
11
+ import { pickMemberByProjectRole } from "./workflow-service.js";
12
+ function buildSpawnContext(stageRow, workflowTasks, stageRows, epicId) {
13
+ const state = loadEpicWorkflowState(epicId);
14
+ let activeStageId;
15
+ if (state !== null && state.stageId !== null) {
16
+ activeStageId = state.stageId;
17
+ }
18
+ else {
19
+ activeStageId = computeEpicStageId(stageRows, workflowTasks);
20
+ }
21
+ let activeStage = stageRow;
22
+ for (const stage of stageRows) {
23
+ if (stage.id === activeStageId) {
24
+ activeStage = stage;
25
+ break;
26
+ }
27
+ }
28
+ let spawnedTemplateIds;
29
+ let doneTemplateIds;
30
+ if (state) {
31
+ const fromState = spawnContextFromWorkflowState(state);
32
+ spawnedTemplateIds = fromState.spawnedTemplateIds;
33
+ doneTemplateIds = fromState.doneTemplateIds;
34
+ }
35
+ else {
36
+ spawnedTemplateIds = new Set(workflowTasks
37
+ .map((task) => task.templateId)
38
+ .filter((templateId) => Boolean(templateId)));
39
+ doneTemplateIds = new Set(workflowTasks
40
+ .filter((task) => task.templateId !== null && isWorkDone(task))
41
+ .map((task) => task.templateId));
42
+ }
43
+ return {
44
+ stageId: stageRow.id,
45
+ stagePosition: stageRow.position,
46
+ activeStagePosition: activeStage.position,
47
+ spawnedTemplateIds,
48
+ doneTemplateIds,
49
+ };
50
+ }
51
+ function spawnTemplateTask(input) {
52
+ const templateRole = emptyToNull(input.template.assigneeRole);
53
+ const stageRole = emptyToNull(input.stageRow.auto_assign_role);
54
+ let assigneeRole;
55
+ if (templateRole !== null) {
56
+ assigneeRole = templateRole;
57
+ }
58
+ else {
59
+ assigneeRole = stageRole;
60
+ }
61
+ let assignee;
62
+ if (assigneeRole !== null) {
63
+ assignee = pickMemberByProjectRole(input.epic.projectId, assigneeRole);
64
+ }
65
+ else {
66
+ assignee = null;
67
+ }
68
+ const id = allocateTaskId();
69
+ let description;
70
+ if (input.template.description !== null) {
71
+ description = input.template.description;
72
+ }
73
+ else {
74
+ description = "";
75
+ }
76
+ const nodeDefaults = applyWorkflowStateNodeToTaskInput(input.epic.id, input.template.id, {
77
+ workStatus: "todo",
78
+ comments: [],
79
+ });
80
+ const task = upsertBridgeTask({
81
+ id,
82
+ projectId: input.epic.projectId,
83
+ projectName: input.epic.projectName,
84
+ title: input.template.title,
85
+ description,
86
+ createdBy: "workflow",
87
+ createdAt: null,
88
+ parentId: input.parentTaskId,
89
+ epicId: input.epic.id,
90
+ templateId: input.template.id,
91
+ stageId: input.stageRow.id,
92
+ assignee,
93
+ assigneeRole,
94
+ assigneeKind: null,
95
+ workStatus: nodeDefaults.workStatus,
96
+ });
97
+ if (nodeDefaults.comments.length > 0 || nodeDefaults.workStatus !== "todo") {
98
+ mutateTaskRow(id, (row) => {
99
+ row.comments = nodeDefaults.comments.slice();
100
+ row.workStatus = nodeDefaults.workStatus;
101
+ });
102
+ }
103
+ linkSpawnedTemplateTask(input.epic.id, input.template.id, id);
104
+ const refreshed = listBridgeTasks().find((entry) => entry.id === id);
105
+ if (refreshed) {
106
+ syncTaskIntoWorkflowState(refreshed);
107
+ return refreshed;
108
+ }
109
+ return task;
110
+ }
111
+ function resolveParentTaskId(epic, workflowTasks, templateParentId) {
112
+ if (!templateParentId)
113
+ return epic.id;
114
+ for (const task of workflowTasks) {
115
+ if (task.templateId === templateParentId) {
116
+ return task.id;
117
+ }
118
+ }
119
+ return epic.id;
120
+ }
121
+ function findTemplateParentId(nodes, templateId, parentTemplateId = null) {
122
+ for (const node of nodes) {
123
+ if (node.id === templateId)
124
+ return parentTemplateId;
125
+ if (node.children.length > 0) {
126
+ const nextParent = node.id;
127
+ const found = findTemplateParentId(node.children, templateId, nextParent);
128
+ if (found !== null)
129
+ return found;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+ export function spawnUnlockedWorkflowTasks(epic) {
135
+ if (epic.parentId !== null)
136
+ return [];
137
+ const rows = listWorkflowStageRows({ projectId: epic.projectId, stageId: "" }).sort((a, b) => a.position - b.position);
138
+ const allTasks = listBridgeTasks();
139
+ const workflowTasks = listEpicWorkflowTasks(allTasks, epic.id);
140
+ const created = [];
141
+ for (const row of rows) {
142
+ const templateInput = {
143
+ taskTemplatesJson: row.task_templates_json,
144
+ stageId: row.id,
145
+ stageTitle: row.title,
146
+ };
147
+ if (!stageHasActionableTemplates(templateInput))
148
+ continue;
149
+ const roots = resolveStageTaskTemplateRoots(templateInput);
150
+ const ctx = buildSpawnContext(row, workflowTasks.concat(created), rows, epic.id);
151
+ let progressed = true;
152
+ while (progressed) {
153
+ progressed = false;
154
+ const spawnable = collectSpawnableTemplates(roots, ctx);
155
+ for (const template of spawnable) {
156
+ const templateParentId = findTemplateParentId(roots, template.id);
157
+ const parentTaskId = resolveParentTaskId(epic, workflowTasks.concat(created), templateParentId);
158
+ const task = spawnTemplateTask({
159
+ epic,
160
+ stageRow: row,
161
+ template: {
162
+ id: template.id,
163
+ title: template.title,
164
+ description: template.description,
165
+ assigneeRole: template.assigneeRole,
166
+ },
167
+ parentTaskId,
168
+ });
169
+ created.push(task);
170
+ ctx.spawnedTemplateIds.add(template.id);
171
+ progressed = true;
172
+ }
173
+ }
174
+ }
175
+ return created;
176
+ }
177
+ export function spawnEpicWorkflowGraph(epic) {
178
+ return spawnUnlockedWorkflowTasks(epic);
179
+ }