stagent 0.6.2 → 0.6.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.
Files changed (68) hide show
  1. package/dist/cli.js +47 -1
  2. package/package.json +1 -2
  3. package/src/app/api/documents/[id]/route.ts +5 -1
  4. package/src/app/api/documents/[id]/versions/route.ts +53 -0
  5. package/src/app/api/documents/route.ts +5 -1
  6. package/src/app/api/projects/[id]/documents/route.ts +124 -0
  7. package/src/app/api/projects/[id]/route.ts +35 -3
  8. package/src/app/api/projects/__tests__/delete-project.test.ts +1 -0
  9. package/src/app/api/schedules/route.ts +19 -1
  10. package/src/app/api/tasks/[id]/route.ts +37 -2
  11. package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
  12. package/src/app/api/tasks/route.ts +8 -9
  13. package/src/app/api/workflows/[id]/documents/route.ts +209 -0
  14. package/src/app/api/workflows/[id]/execute/route.ts +6 -2
  15. package/src/app/api/workflows/[id]/route.ts +16 -3
  16. package/src/app/api/workflows/[id]/status/route.ts +18 -2
  17. package/src/app/api/workflows/route.ts +13 -2
  18. package/src/app/documents/page.tsx +5 -1
  19. package/src/app/layout.tsx +0 -1
  20. package/src/app/manifest.ts +3 -3
  21. package/src/app/projects/[id]/page.tsx +62 -2
  22. package/src/components/documents/document-chip-bar.tsx +17 -1
  23. package/src/components/documents/document-detail-view.tsx +51 -0
  24. package/src/components/documents/document-grid.tsx +5 -0
  25. package/src/components/documents/document-table.tsx +4 -0
  26. package/src/components/documents/types.ts +3 -0
  27. package/src/components/projects/project-form-sheet.tsx +133 -2
  28. package/src/components/schedules/schedule-form.tsx +113 -1
  29. package/src/components/shared/document-picker-sheet.tsx +283 -0
  30. package/src/components/tasks/task-card.tsx +8 -1
  31. package/src/components/tasks/task-create-panel.tsx +137 -14
  32. package/src/components/tasks/task-detail-view.tsx +47 -0
  33. package/src/components/tasks/task-edit-dialog.tsx +125 -2
  34. package/src/components/workflows/workflow-form-view.tsx +231 -7
  35. package/src/components/workflows/workflow-kanban-card.tsx +8 -1
  36. package/src/components/workflows/workflow-list.tsx +90 -45
  37. package/src/components/workflows/workflow-status-view.tsx +167 -22
  38. package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
  39. package/src/lib/agents/profiles/registry.ts +6 -3
  40. package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
  41. package/src/lib/book/chapter-generator.ts +4 -19
  42. package/src/lib/book/chapter-mapping.ts +17 -0
  43. package/src/lib/book/content.ts +5 -16
  44. package/src/lib/book/update-detector.ts +3 -16
  45. package/src/lib/chat/engine.ts +1 -0
  46. package/src/lib/chat/system-prompt.ts +9 -1
  47. package/src/lib/chat/tool-catalog.ts +1 -0
  48. package/src/lib/chat/tools/settings-tools.ts +109 -0
  49. package/src/lib/chat/tools/workflow-tools.ts +145 -2
  50. package/src/lib/data/clear.ts +12 -0
  51. package/src/lib/db/bootstrap.ts +48 -0
  52. package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
  53. package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
  54. package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
  55. package/src/lib/db/schema.ts +77 -0
  56. package/src/lib/docs/reader.ts +2 -3
  57. package/src/lib/documents/context-builder.ts +75 -2
  58. package/src/lib/documents/document-resolver.ts +119 -0
  59. package/src/lib/documents/processors/spreadsheet.ts +2 -1
  60. package/src/lib/schedules/scheduler.ts +31 -1
  61. package/src/lib/utils/app-root.ts +20 -0
  62. package/src/lib/validators/__tests__/task.test.ts +43 -10
  63. package/src/lib/validators/task.ts +7 -1
  64. package/src/lib/workflows/blueprints/registry.ts +3 -3
  65. package/src/lib/workflows/engine.ts +24 -8
  66. package/src/lib/workflows/types.ts +14 -0
  67. package/public/icon.svg +0 -13
  68. package/src/components/tasks/file-upload.tsx +0 -120
package/dist/cli.js CHANGED
@@ -112,7 +112,10 @@ var STAGENT_TABLES = [
112
112
  "agent_memory",
113
113
  "channel_configs",
114
114
  "channel_bindings",
115
- "agent_messages"
115
+ "agent_messages",
116
+ "workflow_document_inputs",
117
+ "schedule_document_inputs",
118
+ "project_document_defaults"
116
119
  ];
117
120
  function bootstrapStagentDatabase(sqlite2) {
118
121
  sqlite2.exec(`
@@ -139,6 +142,7 @@ function bootstrapStagentDatabase(sqlite2) {
139
142
  result TEXT,
140
143
  session_id TEXT,
141
144
  resume_count INTEGER DEFAULT 0 NOT NULL,
145
+ workflow_run_number INTEGER,
142
146
  created_at INTEGER NOT NULL,
143
147
  updated_at INTEGER NOT NULL,
144
148
  FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
@@ -152,6 +156,7 @@ function bootstrapStagentDatabase(sqlite2) {
152
156
  name TEXT NOT NULL,
153
157
  definition TEXT NOT NULL,
154
158
  status TEXT DEFAULT 'draft' NOT NULL,
159
+ run_number INTEGER DEFAULT 0 NOT NULL,
155
160
  created_at INTEGER NOT NULL,
156
161
  updated_at INTEGER NOT NULL,
157
162
  FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
@@ -347,6 +352,8 @@ function bootstrapStagentDatabase(sqlite2) {
347
352
  addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN heartbeat_spent_today INTEGER DEFAULT 0 NOT NULL;`);
348
353
  addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN heartbeat_budget_reset_at INTEGER;`);
349
354
  addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN source_type TEXT;`);
355
+ addColumnIfMissing(`ALTER TABLE workflows ADD COLUMN run_number INTEGER DEFAULT 0 NOT NULL;`);
356
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN workflow_run_number INTEGER;`);
350
357
  addColumnIfMissing(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
351
358
  addColumnIfMissing(`ALTER TABLE documents ADD COLUMN source TEXT DEFAULT 'upload';`);
352
359
  addColumnIfMissing(`ALTER TABLE documents ADD COLUMN conversation_id TEXT REFERENCES conversations(id);`);
@@ -619,6 +626,45 @@ function bootstrapStagentDatabase(sqlite2) {
619
626
  CREATE INDEX IF NOT EXISTS idx_agent_messages_to_status ON agent_messages(to_profile_id, status);
620
627
  CREATE INDEX IF NOT EXISTS idx_agent_messages_task ON agent_messages(task_id);
621
628
  `);
629
+ sqlite2.exec(`
630
+ CREATE TABLE IF NOT EXISTS workflow_document_inputs (
631
+ id TEXT PRIMARY KEY NOT NULL,
632
+ workflow_id TEXT NOT NULL,
633
+ document_id TEXT NOT NULL,
634
+ step_id TEXT,
635
+ created_at INTEGER NOT NULL,
636
+ FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
637
+ FOREIGN KEY (document_id) REFERENCES documents(id) ON UPDATE NO ACTION ON DELETE NO ACTION
638
+ );
639
+
640
+ CREATE INDEX IF NOT EXISTS idx_wdi_workflow ON workflow_document_inputs(workflow_id);
641
+ CREATE INDEX IF NOT EXISTS idx_wdi_document ON workflow_document_inputs(document_id);
642
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_wdi_workflow_doc_step ON workflow_document_inputs(workflow_id, document_id, step_id);
643
+
644
+ CREATE TABLE IF NOT EXISTS schedule_document_inputs (
645
+ id TEXT PRIMARY KEY NOT NULL,
646
+ schedule_id TEXT NOT NULL,
647
+ document_id TEXT NOT NULL,
648
+ created_at INTEGER NOT NULL,
649
+ FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
650
+ FOREIGN KEY (document_id) REFERENCES documents(id) ON UPDATE NO ACTION ON DELETE NO ACTION
651
+ );
652
+
653
+ CREATE INDEX IF NOT EXISTS idx_sdi_schedule ON schedule_document_inputs(schedule_id);
654
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_sdi_schedule_doc ON schedule_document_inputs(schedule_id, document_id);
655
+
656
+ CREATE TABLE IF NOT EXISTS project_document_defaults (
657
+ id TEXT PRIMARY KEY NOT NULL,
658
+ project_id TEXT NOT NULL,
659
+ document_id TEXT NOT NULL,
660
+ created_at INTEGER NOT NULL,
661
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
662
+ FOREIGN KEY (document_id) REFERENCES documents(id) ON UPDATE NO ACTION ON DELETE NO ACTION
663
+ );
664
+
665
+ CREATE INDEX IF NOT EXISTS idx_pdd_project ON project_document_defaults(project_id);
666
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_pdd_project_doc ON project_document_defaults(project_id, document_id);
667
+ `);
622
668
  }
623
669
  function hasLegacyStagentTables(sqlite2) {
624
670
  const placeholders = STAGENT_TABLES.map(() => "?").join(", ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stagent",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "AI Business Operating System — run your business with AI agents. Local-first, multi-provider, governed.",
5
5
  "keywords": [
6
6
  "ai",
@@ -27,7 +27,6 @@
27
27
  "dist/",
28
28
  "docs/",
29
29
  "src/",
30
- "public/icon.svg",
31
30
  "public/icon-512.png",
32
31
  "public/stagent-s-64.png",
33
32
  "public/stagent-s-128.png",
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db } from "@/lib/db";
3
- import { documents, tasks, projects } from "@/lib/db/schema";
3
+ import { documents, tasks, projects, workflows } from "@/lib/db/schema";
4
4
  import { eq } from "drizzle-orm";
5
5
  import { unlink } from "fs/promises";
6
6
  import { z } from "zod/v4";
@@ -42,9 +42,13 @@ export async function GET(
42
42
  updatedAt: documents.updatedAt,
43
43
  taskTitle: tasks.title,
44
44
  projectName: projects.name,
45
+ workflowId: workflows.id,
46
+ workflowName: workflows.name,
47
+ workflowRunNumber: tasks.workflowRunNumber,
45
48
  })
46
49
  .from(documents)
47
50
  .leftJoin(tasks, eq(documents.taskId, tasks.id))
51
+ .leftJoin(workflows, eq(tasks.workflowId, workflows.id))
48
52
  .leftJoin(projects, eq(documents.projectId, projects.id))
49
53
  .where(eq(documents.id, id));
50
54
 
@@ -0,0 +1,53 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { documents, tasks } from "@/lib/db/schema";
4
+ import { eq, and, desc } from "drizzle-orm";
5
+
6
+ export async function GET(
7
+ _req: NextRequest,
8
+ { params }: { params: Promise<{ id: string }> }
9
+ ) {
10
+ const { id } = await params;
11
+
12
+ // First, get the document to find its originalName and projectId
13
+ const [doc] = await db
14
+ .select({
15
+ originalName: documents.originalName,
16
+ projectId: documents.projectId,
17
+ direction: documents.direction,
18
+ })
19
+ .from(documents)
20
+ .where(eq(documents.id, id));
21
+
22
+ if (!doc) {
23
+ return NextResponse.json({ error: "Document not found" }, { status: 404 });
24
+ }
25
+
26
+ // Only output documents have version history
27
+ if (doc.direction !== "output" || !doc.projectId) {
28
+ return NextResponse.json([]);
29
+ }
30
+
31
+ // Find all output documents with same originalName + projectId
32
+ const versions = await db
33
+ .select({
34
+ id: documents.id,
35
+ version: documents.version,
36
+ size: documents.size,
37
+ status: documents.status,
38
+ createdAt: documents.createdAt,
39
+ workflowRunNumber: tasks.workflowRunNumber,
40
+ })
41
+ .from(documents)
42
+ .leftJoin(tasks, eq(documents.taskId, tasks.id))
43
+ .where(
44
+ and(
45
+ eq(documents.originalName, doc.originalName),
46
+ eq(documents.projectId, doc.projectId),
47
+ eq(documents.direction, "output")
48
+ )
49
+ )
50
+ .orderBy(desc(documents.version));
51
+
52
+ return NextResponse.json(versions);
53
+ }
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db } from "@/lib/db";
3
- import { documents, tasks, projects } from "@/lib/db/schema";
3
+ import { documents, tasks, projects, workflows } from "@/lib/db/schema";
4
4
  import { eq, and, like, or, desc } from "drizzle-orm";
5
5
  import { access, stat, copyFile, mkdir } from "fs/promises";
6
6
  import path, { basename, extname, join } from "path";
@@ -74,9 +74,13 @@ export async function GET(req: NextRequest) {
74
74
  updatedAt: documents.updatedAt,
75
75
  taskTitle: tasks.title,
76
76
  projectName: projects.name,
77
+ workflowId: workflows.id,
78
+ workflowName: workflows.name,
79
+ workflowRunNumber: tasks.workflowRunNumber,
77
80
  })
78
81
  .from(documents)
79
82
  .leftJoin(tasks, eq(documents.taskId, tasks.id))
83
+ .leftJoin(workflows, eq(tasks.workflowId, workflows.id))
80
84
  .leftJoin(projects, eq(documents.projectId, projects.id))
81
85
  .where(conditions.length > 0 ? and(...conditions) : undefined)
82
86
  .orderBy(desc(documents.createdAt));
@@ -0,0 +1,124 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import {
4
+ projectDocumentDefaults,
5
+ documents,
6
+ projects,
7
+ } from "@/lib/db/schema";
8
+ import { eq, inArray } from "drizzle-orm";
9
+
10
+ type RouteContext = { params: Promise<{ id: string }> };
11
+
12
+ /**
13
+ * GET /api/projects/[id]/documents
14
+ * List all default document bindings for a project, with document metadata.
15
+ */
16
+ export async function GET(
17
+ _request: NextRequest,
18
+ context: RouteContext
19
+ ) {
20
+ const { id: projectId } = await context.params;
21
+
22
+ try {
23
+ const bindings = await db
24
+ .select()
25
+ .from(projectDocumentDefaults)
26
+ .where(eq(projectDocumentDefaults.projectId, projectId));
27
+
28
+ if (bindings.length === 0) {
29
+ return NextResponse.json([]);
30
+ }
31
+
32
+ const docIds = bindings.map((b) => b.documentId);
33
+ const docs = await db
34
+ .select()
35
+ .from(documents)
36
+ .where(inArray(documents.id, docIds));
37
+
38
+ // Return flat document list (same shape as /api/documents)
39
+ return NextResponse.json(
40
+ docs.map((doc) => ({
41
+ id: doc.id,
42
+ originalName: doc.originalName,
43
+ filename: doc.filename,
44
+ mimeType: doc.mimeType,
45
+ size: doc.size,
46
+ direction: doc.direction,
47
+ status: doc.status,
48
+ category: doc.category,
49
+ }))
50
+ );
51
+ } catch (error) {
52
+ console.error("[project-documents] GET failed:", error);
53
+ return NextResponse.json(
54
+ { error: "Failed to fetch project documents" },
55
+ { status: 500 }
56
+ );
57
+ }
58
+ }
59
+
60
+ /**
61
+ * PUT /api/projects/[id]/documents
62
+ * Replace all default document bindings for a project.
63
+ * Body: { documentIds: string[] }
64
+ */
65
+ export async function PUT(
66
+ request: NextRequest,
67
+ context: RouteContext
68
+ ) {
69
+ const { id: projectId } = await context.params;
70
+
71
+ try {
72
+ const body = await request.json();
73
+ const { documentIds } = body as { documentIds: string[] };
74
+
75
+ if (!Array.isArray(documentIds)) {
76
+ return NextResponse.json(
77
+ { error: "documentIds must be an array" },
78
+ { status: 400 }
79
+ );
80
+ }
81
+
82
+ // Verify project exists
83
+ const [project] = await db
84
+ .select({ id: projects.id })
85
+ .from(projects)
86
+ .where(eq(projects.id, projectId));
87
+
88
+ if (!project) {
89
+ return NextResponse.json(
90
+ { error: "Project not found" },
91
+ { status: 404 }
92
+ );
93
+ }
94
+
95
+ // Remove all existing bindings
96
+ await db
97
+ .delete(projectDocumentDefaults)
98
+ .where(eq(projectDocumentDefaults.projectId, projectId));
99
+
100
+ // Insert new bindings
101
+ const now = new Date();
102
+ for (const docId of documentIds) {
103
+ try {
104
+ await db.insert(projectDocumentDefaults).values({
105
+ id: crypto.randomUUID(),
106
+ projectId,
107
+ documentId: docId,
108
+ createdAt: now,
109
+ });
110
+ } catch (err) {
111
+ const msg = err instanceof Error ? err.message : "";
112
+ if (!msg.includes("UNIQUE constraint")) throw err;
113
+ }
114
+ }
115
+
116
+ return NextResponse.json({ updated: documentIds.length, projectId });
117
+ } catch (error) {
118
+ console.error("[project-documents] PUT failed:", error);
119
+ return NextResponse.json(
120
+ { error: "Failed to update project documents" },
121
+ { status: 500 }
122
+ );
123
+ }
124
+ }
@@ -16,6 +16,7 @@ import {
16
16
  environmentScans,
17
17
  chatMessages,
18
18
  conversations,
19
+ projectDocumentDefaults,
19
20
  } from "@/lib/db/schema";
20
21
  import { eq, inArray } from "drizzle-orm";
21
22
  import { updateProjectSchema } from "@/lib/validators/project";
@@ -42,16 +43,44 @@ export async function PATCH(
42
43
  ) {
43
44
  const { id } = await params;
44
45
  const body = await req.json();
45
- const parsed = updateProjectSchema.safeParse(body);
46
+ // Extract documentIds before validation (not a project column)
47
+ const { documentIds, ...projectBody } = body as Record<string, unknown> & { documentIds?: string[] };
48
+ const parsed = updateProjectSchema.safeParse(projectBody);
46
49
  if (!parsed.success) {
47
50
  return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
48
51
  }
49
52
 
53
+ const now = new Date();
50
54
  await db
51
55
  .update(projects)
52
- .set({ ...parsed.data, updatedAt: new Date() })
56
+ .set({ ...parsed.data, updatedAt: now })
53
57
  .where(eq(projects.id, id));
54
58
 
59
+ // Handle default document bindings
60
+ if (documentIds !== undefined) {
61
+ try {
62
+ // Replace all bindings
63
+ await db
64
+ .delete(projectDocumentDefaults)
65
+ .where(eq(projectDocumentDefaults.projectId, id));
66
+ for (const docId of documentIds) {
67
+ try {
68
+ await db.insert(projectDocumentDefaults).values({
69
+ id: crypto.randomUUID(),
70
+ projectId: id,
71
+ documentId: docId,
72
+ createdAt: now,
73
+ });
74
+ } catch (err) {
75
+ const msg = err instanceof Error ? err.message : "";
76
+ if (!msg.includes("UNIQUE constraint")) throw err;
77
+ }
78
+ }
79
+ } catch (err) {
80
+ console.error("[projects] Document defaults update failed:", err);
81
+ }
82
+ }
83
+
55
84
  const [updated] = await db
56
85
  .select()
57
86
  .from(projects)
@@ -160,7 +189,10 @@ export async function DELETE(
160
189
  .run();
161
190
  }
162
191
 
163
- // 6. Direct project children
192
+ // 6. Project document defaults (junction table)
193
+ db.delete(projectDocumentDefaults).where(eq(projectDocumentDefaults.projectId, id)).run();
194
+
195
+ // 7. Direct project children
164
196
  db.delete(documents).where(eq(documents.projectId, id)).run();
165
197
  db.delete(tasks).where(eq(tasks.projectId, id)).run();
166
198
  if (workflowIds.length > 0) {
@@ -27,6 +27,7 @@ describe("project DELETE cascade coverage", () => {
27
27
  "environmentScans",
28
28
  "environmentCheckpoints",
29
29
  "conversations",
30
+ "projectDocumentDefaults",
30
31
  ];
31
32
 
32
33
  // Tables that are indirect children (FK to a table that has projectId)
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db } from "@/lib/db";
3
- import { schedules } from "@/lib/db/schema";
3
+ import { schedules, scheduleDocumentInputs } from "@/lib/db/schema";
4
4
  import { desc, eq } from "drizzle-orm";
5
5
  import { parseInterval, computeNextFireTime } from "@/lib/schedules/interval-parser";
6
6
  import { parseNaturalLanguage } from "@/lib/schedules/nlp-parser";
@@ -34,6 +34,7 @@ export async function POST(req: NextRequest) {
34
34
  activeHoursEnd,
35
35
  activeTimezone,
36
36
  heartbeatBudgetPerDay,
37
+ documentIds,
37
38
  } =
38
39
  body as {
39
40
  name?: string;
@@ -51,6 +52,7 @@ export async function POST(req: NextRequest) {
51
52
  activeHoursEnd?: number;
52
53
  activeTimezone?: string;
53
54
  heartbeatBudgetPerDay?: number;
55
+ documentIds?: string[];
54
56
  };
55
57
 
56
58
  const scheduleType = type ?? "scheduled";
@@ -167,6 +169,22 @@ export async function POST(req: NextRequest) {
167
169
  updatedAt: now,
168
170
  });
169
171
 
172
+ // Link documents to schedule
173
+ if (documentIds && documentIds.length > 0) {
174
+ try {
175
+ for (const docId of documentIds) {
176
+ await db.insert(scheduleDocumentInputs).values({
177
+ id: crypto.randomUUID(),
178
+ scheduleId: id,
179
+ documentId: docId,
180
+ createdAt: now,
181
+ });
182
+ }
183
+ } catch (err) {
184
+ console.error("[schedules] Document association failed:", err);
185
+ }
186
+ }
187
+
170
188
  const [created] = await db
171
189
  .select()
172
190
  .from(schedules)
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db } from "@/lib/db";
3
- import { tasks, projects, workflows, schedules, usageLedger } from "@/lib/db/schema";
3
+ import { tasks, projects, workflows, schedules, usageLedger, documents } from "@/lib/db/schema";
4
4
  import { eq, sum, min, max } from "drizzle-orm";
5
5
  import { updateTaskSchema } from "@/lib/validators/task";
6
6
  import { isValidTransition, type TaskStatus } from "@/lib/constants/task-status";
@@ -106,11 +106,46 @@ export async function PATCH(
106
106
  }
107
107
  }
108
108
 
109
+ // Extract documentIds before spreading into task update (not a task column)
110
+ const { documentIds, ...taskFields } = parsed.data;
111
+ const now = new Date();
112
+
109
113
  await db
110
114
  .update(tasks)
111
- .set({ ...parsed.data, updatedAt: new Date() })
115
+ .set({ ...taskFields, updatedAt: now })
112
116
  .where(eq(tasks.id, id));
113
117
 
118
+ // Handle document linking/unlinking
119
+ if (documentIds !== undefined) {
120
+ try {
121
+ // Unlink documents previously linked to this task that are no longer selected
122
+ const currentDocs = await db
123
+ .select({ id: documents.id })
124
+ .from(documents)
125
+ .where(eq(documents.taskId, id));
126
+ const newDocSet = new Set(documentIds);
127
+ for (const doc of currentDocs) {
128
+ if (!newDocSet.has(doc.id)) {
129
+ await db.update(documents)
130
+ .set({ taskId: null, updatedAt: now })
131
+ .where(eq(documents.id, doc.id));
132
+ }
133
+ }
134
+ // Link newly selected documents
135
+ for (const docId of documentIds) {
136
+ await db.update(documents)
137
+ .set({
138
+ taskId: id,
139
+ projectId: existing.projectId,
140
+ updatedAt: now,
141
+ })
142
+ .where(eq(documents.id, docId));
143
+ }
144
+ } catch (err) {
145
+ console.error("[tasks] Document association failed:", err);
146
+ }
147
+ }
148
+
114
149
  const [updated] = await db.select().from(tasks).where(eq(tasks.id, id));
115
150
  return NextResponse.json(updated);
116
151
  }
@@ -0,0 +1,48 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { tasks } from "@/lib/db/schema";
4
+ import { eq, and, ne, isNull } from "drizzle-orm";
5
+
6
+ export async function GET(
7
+ _req: NextRequest,
8
+ { params }: { params: Promise<{ id: string }> }
9
+ ) {
10
+ const { id } = await params;
11
+
12
+ const [task] = await db
13
+ .select({
14
+ workflowId: tasks.workflowId,
15
+ workflowRunNumber: tasks.workflowRunNumber,
16
+ })
17
+ .from(tasks)
18
+ .where(eq(tasks.id, id));
19
+
20
+ if (!task || !task.workflowId) {
21
+ return NextResponse.json([]);
22
+ }
23
+ // Match siblings by workflowId + workflowRunNumber.
24
+ // For pre-existing tasks (workflowRunNumber is NULL), match all tasks
25
+ // in the same workflow that also have NULL workflowRunNumber.
26
+ const runCondition = task.workflowRunNumber != null
27
+ ? eq(tasks.workflowRunNumber, task.workflowRunNumber)
28
+ : isNull(tasks.workflowRunNumber);
29
+
30
+ const siblings = await db
31
+ .select({
32
+ id: tasks.id,
33
+ title: tasks.title,
34
+ status: tasks.status,
35
+ createdAt: tasks.createdAt,
36
+ })
37
+ .from(tasks)
38
+ .where(
39
+ and(
40
+ eq(tasks.workflowId, task.workflowId),
41
+ runCondition,
42
+ ne(tasks.id, id)
43
+ )
44
+ )
45
+ .orderBy(tasks.createdAt);
46
+
47
+ return NextResponse.json(siblings);
48
+ }
@@ -66,27 +66,26 @@ export async function POST(req: NextRequest) {
66
66
  updatedAt: now,
67
67
  });
68
68
 
69
- // Link already-uploaded documents to this task
70
- if (parsed.data.fileIds && parsed.data.fileIds.length > 0) {
69
+ // Link documents to this task (from document picker or legacy fileIds)
70
+ if (parsed.data.documentIds && parsed.data.documentIds.length > 0) {
71
71
  try {
72
- for (const fileId of parsed.data.fileIds) {
73
- // Update existing document record (created by /api/uploads) to link to this task
72
+ for (const docId of parsed.data.documentIds) {
74
73
  await db.update(documents)
75
74
  .set({
76
75
  taskId: id,
77
76
  projectId: parsed.data.projectId ?? null,
78
77
  updatedAt: now,
79
78
  })
80
- .where(eq(documents.id, fileId));
79
+ .where(eq(documents.id, docId));
81
80
 
82
81
  // Trigger processing if not already done (fire-and-forget)
83
- processDocument(fileId).catch((err) => {
84
- console.error(`[tasks] processDocument failed for ${fileId}:`, err);
82
+ processDocument(docId).catch((err) => {
83
+ console.error(`[tasks] processDocument failed for ${docId}:`, err);
85
84
  });
86
85
  }
87
86
  } catch (err) {
88
- // File association is best-effort — don't fail task creation
89
- console.error("[tasks] File association failed:", err);
87
+ // Document association is best-effort — don't fail task creation
88
+ console.error("[tasks] Document association failed:", err);
90
89
  }
91
90
  }
92
91