@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,152 @@
1
+ import { z } from "zod";
2
+ import { AppError } from "../errors/app-error.js";
3
+ import { assertAuth } from "../middleware/auth.js";
4
+ import { createWorkflowTemplate, deleteWorkflowTemplate, getWorkflowTemplate, importWorkflowTemplate, listWorkflowTemplates, replaceWorkflowTemplate, } from "../services/workflow-template-service.js";
5
+ const templateIdParamsSchema = z.object({
6
+ templateId: z.string().trim().min(1),
7
+ });
8
+ const taskTemplateSchema = z.lazy(() => z.object({
9
+ id: z.string().trim().min(1),
10
+ title: z.string().trim().min(1),
11
+ description: z.string().trim().optional().default(""),
12
+ assigneeRole: z.string().trim().optional().default(""),
13
+ dependsOn: z.array(z.string().trim()).optional().default([]),
14
+ children: z.array(taskTemplateSchema).optional().default([]),
15
+ }));
16
+ const stageSchema = z.object({
17
+ id: z.string().trim().min(1),
18
+ title: z.string().trim().min(1),
19
+ description: z.string().trim().optional().default(""),
20
+ position: z.number().int().nonnegative(),
21
+ autoAssignRole: z.string().trim().optional().default(""),
22
+ layoutX: z.number().nullable().optional(),
23
+ layoutY: z.number().nullable().optional(),
24
+ spawnTaskCount: z.number().int().nonnegative().optional().default(0),
25
+ taskTemplates: z.array(taskTemplateSchema).optional().default([]),
26
+ });
27
+ const replaceTemplateSchema = z.object({
28
+ stages: z.array(stageSchema).min(1),
29
+ });
30
+ const createTemplateSchema = z.object({
31
+ id: z.string().trim().min(1).nullable().default(null),
32
+ title: z.string().trim().min(1),
33
+ description: z.string().trim().optional().default(""),
34
+ });
35
+ const importSchema = z.object({
36
+ id: z.string().trim().min(1).nullable().default(null),
37
+ title: z.string().trim().min(1),
38
+ description: z.string().trim().optional().default(""),
39
+ stages: z.array(stageSchema).min(1),
40
+ }).passthrough();
41
+ export function workflowTemplateRoutes(app) {
42
+ app.get("/workflow-templates", (request) => {
43
+ assertAuth(request);
44
+ return { items: listWorkflowTemplates() };
45
+ });
46
+ // POST /api/workflow-templates/import — must be before /:templateId routes
47
+ app.post("/workflow-templates/import", async (request, reply) => {
48
+ assertAuth(request);
49
+ let importBody = request.body;
50
+ if (importBody === null) {
51
+ importBody = {};
52
+ }
53
+ const body = importSchema.parse(importBody);
54
+ const template = importWorkflowTemplate({
55
+ id: body.id,
56
+ title: body.title,
57
+ description: body.description,
58
+ stages: body.stages.map((stage) => {
59
+ let layoutX = null;
60
+ if (Number(stage.layoutX) === stage.layoutX) {
61
+ layoutX = stage.layoutX;
62
+ }
63
+ let layoutY = null;
64
+ if (Number(stage.layoutY) === stage.layoutY) {
65
+ layoutY = stage.layoutY;
66
+ }
67
+ let autoAssignRole = null;
68
+ if (stage.autoAssignRole) {
69
+ autoAssignRole = stage.autoAssignRole;
70
+ }
71
+ return Object.assign({}, stage, { layoutX, layoutY, autoAssignRole, activeTaskCount: null });
72
+ }),
73
+ });
74
+ return reply.status(201).send(template);
75
+ });
76
+ app.post("/workflow-templates", async (request, reply) => {
77
+ assertAuth(request);
78
+ let createTemplateBody = request.body;
79
+ if (createTemplateBody === null) {
80
+ createTemplateBody = {};
81
+ }
82
+ const body = createTemplateSchema.parse(createTemplateBody);
83
+ const template = createWorkflowTemplate({
84
+ id: body.id,
85
+ title: body.title,
86
+ description: body.description,
87
+ });
88
+ return reply.status(201).send(template);
89
+ });
90
+ app.get("/workflow-templates/:templateId", async (request, reply) => {
91
+ assertAuth(request);
92
+ const { templateId } = templateIdParamsSchema.parse(request.params);
93
+ const template = getWorkflowTemplate(templateId);
94
+ if (!template) {
95
+ return reply.status(404).send({ error: "Workflow template not found" });
96
+ }
97
+ return template;
98
+ });
99
+ // GET /api/workflow-templates/:templateId/export — download as JSON file
100
+ app.get("/workflow-templates/:templateId/export", async (request, reply) => {
101
+ assertAuth(request);
102
+ const { templateId } = templateIdParamsSchema.parse(request.params);
103
+ const template = getWorkflowTemplate(templateId);
104
+ if (!template) {
105
+ throw new AppError("Workflow template not found", 404);
106
+ }
107
+ const payload = {
108
+ exportedFrom: "task-bridge",
109
+ version: 1,
110
+ id: template.id,
111
+ title: template.title,
112
+ description: template.description,
113
+ stages: template.stages,
114
+ };
115
+ const filename = `${template.id}.json`;
116
+ return reply
117
+ .header("Content-Type", "application/json; charset=utf-8")
118
+ .header("Content-Disposition", `attachment; filename="${filename}"`)
119
+ .send(JSON.stringify(payload, null, 2));
120
+ });
121
+ app.put("/workflow-templates/:templateId", async (request, reply) => {
122
+ assertAuth(request);
123
+ const { templateId } = templateIdParamsSchema.parse(request.params);
124
+ let replaceTemplateBody = request.body;
125
+ if (replaceTemplateBody === null) {
126
+ replaceTemplateBody = {};
127
+ }
128
+ const body = replaceTemplateSchema.parse(replaceTemplateBody);
129
+ const template = replaceWorkflowTemplate(templateId, body.stages.map((stage) => {
130
+ let layoutX = null;
131
+ if (Number(stage.layoutX) === stage.layoutX) {
132
+ layoutX = stage.layoutX;
133
+ }
134
+ let layoutY = null;
135
+ if (Number(stage.layoutY) === stage.layoutY) {
136
+ layoutY = stage.layoutY;
137
+ }
138
+ let autoAssignRole = null;
139
+ if (stage.autoAssignRole) {
140
+ autoAssignRole = stage.autoAssignRole;
141
+ }
142
+ return Object.assign({}, stage, { layoutX, layoutY, autoAssignRole, activeTaskCount: null });
143
+ }));
144
+ return reply.status(200).send(template);
145
+ });
146
+ app.delete("/workflow-templates/:templateId", async (request, reply) => {
147
+ assertAuth(request);
148
+ const { templateId } = templateIdParamsSchema.parse(request.params);
149
+ deleteWorkflowTemplate(templateId);
150
+ return reply.status(204).send();
151
+ });
152
+ }
@@ -0,0 +1,165 @@
1
+ import { z } from "zod";
2
+ import { AppError } from "../errors/app-error.js";
3
+ import { assertAuth } from "../middleware/auth.js";
4
+ import { getProjectById } from "../services/project-registry.js";
5
+ import { createProjectMember, ensureProjectWorkflow, applyWorkflowTemplateToProject, exportWorkflowReadable, getProjectWorkflow, removeProjectMember, replaceProjectWorkflow, updateProjectMember, } from "../services/workflow-service.js";
6
+ const projectIdParamsSchema = z.object({
7
+ projectId: z.string().trim().min(1),
8
+ });
9
+ const memberIdParamsSchema = z.object({
10
+ projectId: z.string().trim().min(1),
11
+ memberId: z.string().trim().min(1),
12
+ });
13
+ const taskTemplateSchema = z.lazy(() => z.object({
14
+ id: z.string().trim().min(1),
15
+ title: z.string().trim().min(1),
16
+ description: z.string().trim().optional().default(""),
17
+ assigneeRole: z.string().trim().optional().default(""),
18
+ dependsOn: z.array(z.string().trim()).optional().default([]),
19
+ children: z.array(taskTemplateSchema).optional().default([]),
20
+ }));
21
+ const stageSchema = z.object({
22
+ id: z.string().trim().min(1),
23
+ title: z.string().trim().min(1),
24
+ description: z.string().trim().optional().default(""),
25
+ position: z.number().int().nonnegative(),
26
+ autoAssignRole: z.string().trim().optional().default(""),
27
+ layoutX: z.number().nullable().optional(),
28
+ layoutY: z.number().nullable().optional(),
29
+ spawnTaskCount: z.number().int().nonnegative().optional().default(0),
30
+ taskTemplates: z.array(taskTemplateSchema).optional().default([]),
31
+ });
32
+ const replaceWorkflowSchema = z.object({
33
+ stages: z.array(stageSchema).min(1),
34
+ roles: z.array(z.string().trim()).optional().default([]),
35
+ });
36
+ const applyTemplateSchema = z.object({
37
+ templateId: z.string().trim().min(1),
38
+ });
39
+ const createMemberSchema = z.object({
40
+ name: z.string().trim().min(1),
41
+ role: z.string().trim().optional().default(""),
42
+ });
43
+ const updateMemberSchema = z.object({
44
+ name: z.string().trim().min(1).optional(),
45
+ role: z.string().trim().optional(),
46
+ });
47
+ function assertProject(projectId) {
48
+ const project = getProjectById(projectId);
49
+ if (!project) {
50
+ throw new AppError("Project not found", 404);
51
+ }
52
+ ensureProjectWorkflow(projectId);
53
+ return project;
54
+ }
55
+ export function workflowRoutes(app) {
56
+ app.get("/projects/:projectId/workflow", (request) => {
57
+ assertAuth(request);
58
+ const { projectId } = projectIdParamsSchema.parse(request.params);
59
+ assertProject(projectId);
60
+ return getProjectWorkflow(projectId);
61
+ });
62
+ app.get("/projects/:projectId/workflow/export", (request) => {
63
+ assertAuth(request);
64
+ const { projectId } = projectIdParamsSchema.parse(request.params);
65
+ assertProject(projectId);
66
+ return exportWorkflowReadable(projectId);
67
+ });
68
+ app.post("/projects/:projectId/workflow/apply-template", async (request, reply) => {
69
+ assertAuth(request);
70
+ const { projectId } = projectIdParamsSchema.parse(request.params);
71
+ assertProject(projectId);
72
+ let applyTemplateBody = request.body;
73
+ if (applyTemplateBody === null) {
74
+ applyTemplateBody = {};
75
+ }
76
+ const body = applyTemplateSchema.parse(applyTemplateBody);
77
+ const workflow = applyWorkflowTemplateToProject(projectId, body.templateId);
78
+ return reply.status(200).send(workflow);
79
+ });
80
+ app.put("/projects/:projectId/workflow", async (request, reply) => {
81
+ assertAuth(request);
82
+ const { projectId } = projectIdParamsSchema.parse(request.params);
83
+ assertProject(projectId);
84
+ let replaceWorkflowBody = request.body;
85
+ if (replaceWorkflowBody === null) {
86
+ replaceWorkflowBody = {};
87
+ }
88
+ const body = replaceWorkflowSchema.parse(replaceWorkflowBody);
89
+ const workflow = replaceProjectWorkflow(projectId, body.stages.map((stage) => {
90
+ let layoutX = null;
91
+ if (Number(stage.layoutX) === stage.layoutX) {
92
+ layoutX = stage.layoutX;
93
+ }
94
+ let layoutY = null;
95
+ if (Number(stage.layoutY) === stage.layoutY) {
96
+ layoutY = stage.layoutY;
97
+ }
98
+ let autoAssignRole = null;
99
+ if (stage.autoAssignRole) {
100
+ autoAssignRole = stage.autoAssignRole;
101
+ }
102
+ return Object.assign({}, stage, { layoutX, layoutY, autoAssignRole, activeTaskCount: null });
103
+ }), body.roles);
104
+ return reply.status(200).send(workflow);
105
+ });
106
+ app.get("/projects/:projectId/members", (request) => {
107
+ assertAuth(request);
108
+ const { projectId } = projectIdParamsSchema.parse(request.params);
109
+ assertProject(projectId);
110
+ const workflow = getProjectWorkflow(projectId);
111
+ return { items: workflow.members };
112
+ });
113
+ app.post("/projects/:projectId/members", async (request, reply) => {
114
+ assertAuth(request);
115
+ const { projectId } = projectIdParamsSchema.parse(request.params);
116
+ assertProject(projectId);
117
+ let createMemberBody = request.body;
118
+ if (createMemberBody === null) {
119
+ createMemberBody = {};
120
+ }
121
+ const body = createMemberSchema.parse(createMemberBody);
122
+ const member = createProjectMember({
123
+ projectId,
124
+ name: body.name,
125
+ role: body.role,
126
+ });
127
+ return reply.status(201).send(member);
128
+ });
129
+ app.patch("/projects/:projectId/members/:memberId", async (request, reply) => {
130
+ assertAuth(request);
131
+ const { projectId, memberId } = memberIdParamsSchema.parse(request.params);
132
+ assertProject(projectId);
133
+ let updateMemberBody = request.body;
134
+ if (updateMemberBody === null) {
135
+ updateMemberBody = {};
136
+ }
137
+ const body = updateMemberSchema.parse(updateMemberBody);
138
+ let updateName = null;
139
+ if ("name" in body) {
140
+ updateName = body.name;
141
+ }
142
+ let updateRole = null;
143
+ if ("role" in body) {
144
+ updateRole = body.role;
145
+ }
146
+ const member = updateProjectMember(memberId, {
147
+ name: updateName,
148
+ role: updateRole,
149
+ });
150
+ if (!member || member.projectId !== projectId) {
151
+ return reply.status(404).send({ error: "Member not found" });
152
+ }
153
+ return member;
154
+ });
155
+ app.delete("/projects/:projectId/members/:memberId", async (request, reply) => {
156
+ assertAuth(request);
157
+ const { projectId, memberId } = memberIdParamsSchema.parse(request.params);
158
+ assertProject(projectId);
159
+ const removed = removeProjectMember(memberId);
160
+ if (!removed) {
161
+ return reply.status(404).send({ error: "Member not found" });
162
+ }
163
+ return reply.status(204).send();
164
+ });
165
+ }
@@ -0,0 +1,4 @@
1
+ // Ngrok-based connect target removed.
2
+ export function resolveConnectTarget() {
3
+ return null;
4
+ }
@@ -0,0 +1,269 @@
1
+ import { listWorkflowStageRows, } from "../db/workflow-db.js";
2
+ import { isWorkDone } from "../domain/work-status.js";
3
+ import { assertCanAdvanceWorkStatus, listDescendantIds, listEpicWorkflowTasks, resolveEpicId, resolveTaskStageId, } from "../domain/task.js";
4
+ import { stageHasActionableTemplates } from "../domain/workflow-stage.js";
5
+ import { buildEpicClaimIndex, normalizeClaimActor, workflowUpdateBlockReason, } from "./task-claim-policy.js";
6
+ import { AppError } from "../errors/app-error.js";
7
+ import { getBridgeTask, listBridgeTasks, transitionBridgeTask } from "./task-service.js";
8
+ import { deleteTaskRows, mutateTaskRow } from "../db/tasks-db.js";
9
+ import { touchTask } from "../domain/task.js";
10
+ import { spawnEpicWorkflowGraph, spawnUnlockedWorkflowTasks } from "./workflow-spawn-service.js";
11
+ import { resetWorkflowStateNodesForTasks, rollbackEpicWorkflowFromStage, syncEpicWorkflowStage, syncTaskIntoWorkflowState, } from "./workflow-state-service.js";
12
+ export function isEpic(task) {
13
+ return task.parentId === null;
14
+ }
15
+ function stageTemplateInput(row) {
16
+ return {
17
+ taskTemplatesJson: row.task_templates_json,
18
+ stageId: row.id,
19
+ stageTitle: row.title,
20
+ };
21
+ }
22
+ export function computeEpicStageId(stageRows, subtasks) {
23
+ const ordered = stageRows.slice().sort((a, b) => a.position - b.position);
24
+ if (ordered.length === 0)
25
+ return null;
26
+ for (const row of ordered) {
27
+ if (!stageHasActionableTemplates(stageTemplateInput(row)))
28
+ continue;
29
+ const stageTasks = subtasks.filter((task) => resolveTaskStageId(subtasks, task) === row.id);
30
+ const hasIncomplete = stageTasks.some((task) => !isWorkDone(task));
31
+ if (hasIncomplete || stageTasks.length === 0)
32
+ return row.id;
33
+ }
34
+ if (ordered.length > 0) {
35
+ const last = ordered[ordered.length - 1];
36
+ if (last) {
37
+ return last.id;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ export function syncEpicStage(epicId) {
43
+ const epic = getBridgeTask(epicId);
44
+ if (!epic || epic.parentId !== null)
45
+ return epic;
46
+ const rows = listWorkflowStageRows({ projectId: epic.projectId, stageId: "" }).sort((a, b) => a.position - b.position);
47
+ let current = epic;
48
+ for (let pass = 0; pass < rows.length + 1; pass += 1) {
49
+ const allTasks = listBridgeTasks();
50
+ const subtasks = listEpicWorkflowTasks(allTasks, epicId);
51
+ const nextStageId = computeEpicStageId(rows, subtasks);
52
+ if (!nextStageId || nextStageId === current.stageId)
53
+ break;
54
+ const transitioned = transitionBridgeTask(epicId, {
55
+ stageId: nextStageId,
56
+ assignee: null,
57
+ by: "workflow",
58
+ });
59
+ if (transitioned !== null) {
60
+ current = transitioned;
61
+ syncEpicWorkflowStage(epicId, nextStageId);
62
+ }
63
+ }
64
+ spawnUnlockedWorkflowTasks(current);
65
+ return current;
66
+ }
67
+ export function spawnEpicWorkflow(epic) {
68
+ return spawnEpicWorkflowGraph(epic);
69
+ }
70
+ export function collectLaterStageTodoCascadeIds(tasks, source, stageRows) {
71
+ const stageId = resolveTaskStageId(tasks, source);
72
+ if (!stageId)
73
+ return [];
74
+ let epicId;
75
+ if (source.epicId !== null && source.epicId !== null) {
76
+ epicId = source.epicId;
77
+ }
78
+ else {
79
+ epicId = resolveEpicId(tasks, source);
80
+ }
81
+ if (!epicId)
82
+ return [];
83
+ const ordered = stageRows.slice().sort((a, b) => a.position - b.position);
84
+ const stageIndex = ordered.findIndex((row) => row.id === stageId);
85
+ if (stageIndex < 0)
86
+ return [];
87
+ const ids = new Set();
88
+ const laterStageIds = new Set(ordered.slice(stageIndex + 1).map((row) => row.id));
89
+ for (const task of listEpicWorkflowTasks(tasks, epicId)) {
90
+ if (task.id === source.id)
91
+ continue;
92
+ const taskStageId = resolveTaskStageId(tasks, task);
93
+ if (!taskStageId || !laterStageIds.has(taskStageId))
94
+ continue;
95
+ ids.add(task.id);
96
+ for (const descendantId of listDescendantIds(tasks, task.id)) {
97
+ ids.add(descendantId);
98
+ }
99
+ }
100
+ return Array.from(ids);
101
+ }
102
+ export function applyTodoCascadeFromTask(source, by, options) {
103
+ const includeLaterStages = options === null || options.laterStages !== false;
104
+ const includeDescendants = options === null || options.descendants !== false;
105
+ const tasks = listBridgeTasks();
106
+ let epicId;
107
+ if (source.epicId !== null && source.epicId !== null) {
108
+ epicId = source.epicId;
109
+ }
110
+ else {
111
+ epicId = resolveEpicId(tasks, source);
112
+ }
113
+ let epicTask = null;
114
+ if (epicId !== null) {
115
+ for (const task of tasks) {
116
+ if (task.id === epicId) {
117
+ epicTask = task;
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ let projectId;
123
+ if (epicTask !== null) {
124
+ projectId = epicTask.projectId;
125
+ }
126
+ else {
127
+ projectId = source.projectId;
128
+ }
129
+ let stageRows;
130
+ if (epicId !== null) {
131
+ stageRows = listWorkflowStageRows({ projectId, stageId: "" });
132
+ }
133
+ else {
134
+ stageRows = [];
135
+ }
136
+ const sourceStageId = resolveTaskStageId(tasks, source);
137
+ const deleteIds = new Set();
138
+ const resetIds = new Set();
139
+ if (includeDescendants) {
140
+ for (const descendantId of listDescendantIds(tasks, source.id)) {
141
+ resetIds.add(descendantId);
142
+ }
143
+ }
144
+ if (includeLaterStages) {
145
+ for (const laterId of collectLaterStageTodoCascadeIds(tasks, source, stageRows)) {
146
+ deleteIds.add(laterId);
147
+ resetIds.delete(laterId);
148
+ }
149
+ }
150
+ deleteTaskRows(Array.from(deleteIds));
151
+ const at = new Date().toISOString();
152
+ for (const cascadeId of resetIds) {
153
+ mutateTaskRow(cascadeId, (task) => {
154
+ if (task.workStatus === "todo")
155
+ return;
156
+ task.workStatus = "todo";
157
+ task.claimedBy = null;
158
+ task.claimedAt = null;
159
+ task.events.push({
160
+ type: "spec_updated",
161
+ at,
162
+ by,
163
+ note: `work_status:todo:cascade:${source.id}`,
164
+ });
165
+ touchTask(task);
166
+ });
167
+ }
168
+ if (epicId !== null && sourceStageId) {
169
+ if (deleteIds.size > 0) {
170
+ rollbackEpicWorkflowFromStage(epicId, sourceStageId, projectId);
171
+ }
172
+ resetWorkflowStateNodesForTasks(epicId, resetIds);
173
+ }
174
+ }
175
+ export function updateTaskWorkStatus(taskId, workStatus, by, actor) {
176
+ const existing = getBridgeTask(taskId);
177
+ if (!existing || existing.parentId === null) {
178
+ return existing;
179
+ }
180
+ const tasksForPolicy = listBridgeTasks();
181
+ assertCanAdvanceWorkStatus(tasksForPolicy, existing, workStatus);
182
+ const normalized = normalizeClaimActor(actor);
183
+ const index = buildEpicClaimIndex(tasksForPolicy);
184
+ const blockReason = workflowUpdateBlockReason(existing, index, normalized, workStatus);
185
+ if (blockReason) {
186
+ throw new AppError(blockReason, 409);
187
+ }
188
+ const updated = mutateTaskRow(taskId, (task) => {
189
+ if (task.parentId === null)
190
+ return;
191
+ task.workStatus = workStatus;
192
+ const at = new Date().toISOString();
193
+ task.events.push({
194
+ type: "spec_updated",
195
+ at,
196
+ by,
197
+ note: `work_status:${workStatus}`,
198
+ });
199
+ touchTask(task);
200
+ });
201
+ if (!updated || updated.parentId === null) {
202
+ return updated;
203
+ }
204
+ if (workStatus === "todo" || workStatus === "in_progress") {
205
+ const reopening = isWorkDone(existing);
206
+ applyTodoCascadeFromTask(updated, by, {
207
+ laterStages: workStatus === "todo" || reopening,
208
+ descendants: true,
209
+ });
210
+ }
211
+ syncTaskIntoWorkflowState(updated);
212
+ const allTasks = listBridgeTasks();
213
+ let epicId;
214
+ if (updated.epicId !== null && updated.epicId !== null) {
215
+ epicId = updated.epicId;
216
+ }
217
+ else {
218
+ epicId = resolveEpicId(allTasks, updated);
219
+ }
220
+ if (!epicId)
221
+ return updated;
222
+ const epic = getBridgeTask(epicId);
223
+ if (!epic)
224
+ return updated;
225
+ spawnUnlockedWorkflowTasks(epic);
226
+ syncEpicStage(epicId);
227
+ return updated;
228
+ }
229
+ export function listEpicSubtasks(epicId) {
230
+ const tasks = listBridgeTasks();
231
+ return listEpicWorkflowTasks(tasks, epicId);
232
+ }
233
+ export function getEpicWithStage(epicId) {
234
+ const epic = getBridgeTask(epicId);
235
+ if (!epic || epic.parentId !== null)
236
+ return null;
237
+ const rawSynced = syncEpicStage(epicId);
238
+ let synced;
239
+ if (rawSynced !== null) {
240
+ synced = rawSynced;
241
+ }
242
+ else {
243
+ synced = epic;
244
+ }
245
+ let stageRows;
246
+ if (synced.stageId !== null && synced.stageId !== "") {
247
+ stageRows = listWorkflowStageRows({ projectId: synced.projectId, stageId: synced.stageId });
248
+ }
249
+ else {
250
+ stageRows = [];
251
+ }
252
+ let stageTitle;
253
+ if (stageRows.length > 0) {
254
+ const firstRow = stageRows[0];
255
+ if (firstRow) {
256
+ stageTitle = firstRow.title;
257
+ }
258
+ else {
259
+ stageTitle = synced.stageId;
260
+ }
261
+ }
262
+ else {
263
+ stageTitle = synced.stageId;
264
+ }
265
+ return {
266
+ epic: synced,
267
+ stageTitle,
268
+ };
269
+ }