stagent 0.1.6 → 0.1.9

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 (40) hide show
  1. package/dist/cli.js +16 -24
  2. package/package.json +1 -1
  3. package/src/app/api/documents/route.ts +21 -2
  4. package/src/app/api/tasks/route.ts +16 -3
  5. package/src/app/api/uploads/route.ts +17 -3
  6. package/src/app/globals.css +156 -120
  7. package/src/app/inbox/page.tsx +1 -1
  8. package/src/app/layout.tsx +6 -6
  9. package/src/app/page.tsx +1 -1
  10. package/src/app/profiles/[id]/edit/page.tsx +1 -1
  11. package/src/app/profiles/[id]/page.tsx +1 -1
  12. package/src/app/profiles/new/page.tsx +1 -1
  13. package/src/app/profiles/page.tsx +1 -1
  14. package/src/app/projects/page.tsx +1 -1
  15. package/src/app/settings/loading.tsx +1 -1
  16. package/src/app/settings/page.tsx +1 -1
  17. package/src/app/tasks/new/page.tsx +10 -2
  18. package/src/app/workflows/[id]/edit/page.tsx +1 -1
  19. package/src/app/workflows/new/page.tsx +1 -1
  20. package/src/components/costs/cost-dashboard.tsx +1 -1
  21. package/src/components/shared/theme-toggle.tsx +1 -1
  22. package/src/components/tasks/__tests__/kanban-board-persistence.test.tsx +124 -0
  23. package/src/components/tasks/__tests__/task-create-panel.test.tsx +58 -0
  24. package/src/components/tasks/ai-assist-panel.tsx +50 -12
  25. package/src/components/tasks/kanban-board.tsx +201 -5
  26. package/src/components/tasks/kanban-column.tsx +156 -5
  27. package/src/components/tasks/task-card.tsx +186 -44
  28. package/src/components/tasks/task-create-panel.tsx +3 -2
  29. package/src/components/tasks/task-detail-view.tsx +58 -1
  30. package/src/components/tasks/task-edit-dialog.tsx +277 -0
  31. package/src/hooks/__tests__/use-persisted-state.test.ts +57 -0
  32. package/src/hooks/use-persisted-state.ts +40 -0
  33. package/src/lib/agents/claude-agent.ts +17 -7
  34. package/src/lib/agents/runtime/claude-sdk.ts +20 -6
  35. package/src/lib/agents/runtime/claude.ts +23 -5
  36. package/src/lib/agents/runtime/openai-codex.ts +14 -1
  37. package/src/lib/db/bootstrap.ts +17 -32
  38. package/src/lib/documents/cleanup.ts +3 -2
  39. package/src/lib/notifications/permissions.ts +4 -2
  40. package/src/lib/workflows/engine.ts +2 -2
@@ -0,0 +1,277 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Input } from "@/components/ui/input";
13
+ import { Textarea } from "@/components/ui/textarea";
14
+ import { Label } from "@/components/ui/label";
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from "@/components/ui/select";
22
+ import { Bot, FileText, AlignLeft } from "lucide-react";
23
+ import { toast } from "sonner";
24
+ import {
25
+ type AgentRuntimeId,
26
+ DEFAULT_AGENT_RUNTIME,
27
+ listRuntimeCatalog,
28
+ } from "@/lib/agents/runtime/catalog";
29
+ import {
30
+ getSupportedRuntimes,
31
+ profileSupportsRuntime,
32
+ } from "@/lib/agents/profiles/compatibility";
33
+ import type { AgentProfile } from "@/lib/agents/profiles/types";
34
+ import type { TaskItem } from "./task-card";
35
+
36
+ type ProfileOption = Pick<
37
+ AgentProfile,
38
+ "id" | "name" | "description" | "supportedRuntimes"
39
+ >;
40
+
41
+ const PRIORITY_COLORS: Record<string, string> = {
42
+ "0": "bg-[var(--priority-critical)]",
43
+ "1": "bg-[var(--priority-high)]",
44
+ "2": "bg-[var(--priority-medium)]",
45
+ "3": "bg-[var(--priority-low)]",
46
+ };
47
+
48
+ interface TaskEditDialogProps {
49
+ task: TaskItem | null;
50
+ open: boolean;
51
+ onOpenChange: (open: boolean) => void;
52
+ onUpdated: () => void;
53
+ }
54
+
55
+ export function TaskEditDialog({
56
+ task,
57
+ open,
58
+ onOpenChange,
59
+ onUpdated,
60
+ }: TaskEditDialogProps) {
61
+ const runtimeOptions = listRuntimeCatalog();
62
+ const runtimeLabelMap = new Map(
63
+ runtimeOptions.map((runtime) => [runtime.id, runtime.label])
64
+ );
65
+
66
+ const [title, setTitle] = useState("");
67
+ const [description, setDescription] = useState("");
68
+ const [priority, setPriority] = useState("2");
69
+ const [assignedAgent, setAssignedAgent] = useState("");
70
+ const [agentProfile, setAgentProfile] = useState("");
71
+ const [profiles, setProfiles] = useState<ProfileOption[]>([]);
72
+ const [loading, setLoading] = useState(false);
73
+
74
+ useEffect(() => {
75
+ fetch("/api/profiles")
76
+ .then((r) => r.json())
77
+ .then((data: ProfileOption[]) => setProfiles(data))
78
+ .catch(() => {});
79
+ }, []);
80
+
81
+ useEffect(() => {
82
+ if (task) {
83
+ setTitle(task.title);
84
+ setDescription(task.description ?? "");
85
+ setPriority(String(task.priority));
86
+ setAssignedAgent(task.assignedAgent ?? "");
87
+ setAgentProfile(task.agentProfile ?? "");
88
+ }
89
+ }, [task]);
90
+
91
+ const selectedRuntimeId = (assignedAgent ||
92
+ DEFAULT_AGENT_RUNTIME) as AgentRuntimeId;
93
+ const selectedProfile = profiles.find((p) => p.id === agentProfile);
94
+ const profileCompatibilityError =
95
+ selectedProfile && !profileSupportsRuntime(selectedProfile, selectedRuntimeId)
96
+ ? `${selectedProfile.name} does not support ${
97
+ runtimeLabelMap.get(selectedRuntimeId) ?? selectedRuntimeId
98
+ }`
99
+ : null;
100
+
101
+ async function handleSubmit(e: React.FormEvent) {
102
+ e.preventDefault();
103
+ if (!task || !title.trim()) return;
104
+ if (profileCompatibilityError) return;
105
+ setLoading(true);
106
+ try {
107
+ const res = await fetch(`/api/tasks/${task.id}`, {
108
+ method: "PATCH",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({
111
+ title: title.trim(),
112
+ description: description.trim() || undefined,
113
+ priority: parseInt(priority, 10),
114
+ assignedAgent: assignedAgent || undefined,
115
+ agentProfile: agentProfile || undefined,
116
+ }),
117
+ });
118
+ if (res.ok) {
119
+ toast.success("Task updated");
120
+ onOpenChange(false);
121
+ onUpdated();
122
+ } else {
123
+ const data = await res.json().catch(() => null);
124
+ toast.error(data?.error ?? "Failed to update task");
125
+ }
126
+ } catch {
127
+ toast.error("Network error — could not reach server");
128
+ } finally {
129
+ setLoading(false);
130
+ }
131
+ }
132
+
133
+ return (
134
+ <Dialog open={open} onOpenChange={onOpenChange}>
135
+ <DialogContent>
136
+ <DialogHeader>
137
+ <DialogTitle>Edit Task</DialogTitle>
138
+ <DialogDescription>
139
+ Update task details. Only planned and queued tasks can be edited.
140
+ </DialogDescription>
141
+ </DialogHeader>
142
+ <form onSubmit={handleSubmit} className="space-y-4">
143
+ <div className="space-y-2">
144
+ <Label htmlFor="edit-task-title" className="flex items-center gap-1.5">
145
+ <FileText className="h-3.5 w-3.5 text-muted-foreground" />
146
+ Title
147
+ </Label>
148
+ <Input
149
+ id="edit-task-title"
150
+ value={title}
151
+ onChange={(e) => setTitle(e.target.value)}
152
+ required
153
+ maxLength={200}
154
+ />
155
+ </div>
156
+ <div className="space-y-2">
157
+ <Label htmlFor="edit-task-desc" className="flex items-center gap-1.5">
158
+ <AlignLeft className="h-3.5 w-3.5 text-muted-foreground" />
159
+ Description
160
+ </Label>
161
+ <Textarea
162
+ id="edit-task-desc"
163
+ value={description}
164
+ onChange={(e) => setDescription(e.target.value)}
165
+ rows={3}
166
+ placeholder="Detailed instructions for the agent"
167
+ />
168
+ </div>
169
+ <div className="space-y-2">
170
+ <Label>Priority</Label>
171
+ <Select value={priority} onValueChange={setPriority}>
172
+ <SelectTrigger>
173
+ <SelectValue />
174
+ </SelectTrigger>
175
+ <SelectContent>
176
+ {[
177
+ { value: "0", label: "P0 - Critical" },
178
+ { value: "1", label: "P1 - High" },
179
+ { value: "2", label: "P2 - Medium" },
180
+ { value: "3", label: "P3 - Low" },
181
+ ].map((p) => (
182
+ <SelectItem key={p.value} value={p.value}>
183
+ <span className="flex items-center gap-1.5">
184
+ <span className={`h-2 w-2 rounded-full ${PRIORITY_COLORS[p.value]} inline-block`} />
185
+ {p.label}
186
+ </span>
187
+ </SelectItem>
188
+ ))}
189
+ </SelectContent>
190
+ </Select>
191
+ </div>
192
+ <div className="space-y-2">
193
+ <Label className="flex items-center gap-1.5">
194
+ <Bot className="h-3.5 w-3.5 text-muted-foreground" />
195
+ Runtime
196
+ </Label>
197
+ <Select
198
+ value={assignedAgent || "default"}
199
+ onValueChange={(value) =>
200
+ setAssignedAgent(value === "default" ? "" : value)
201
+ }
202
+ >
203
+ <SelectTrigger>
204
+ <SelectValue placeholder="Default runtime" />
205
+ </SelectTrigger>
206
+ <SelectContent>
207
+ <SelectItem value="default">Default runtime</SelectItem>
208
+ {runtimeOptions.map((runtime) => (
209
+ <SelectItem key={runtime.id} value={runtime.id}>
210
+ {runtime.label}
211
+ </SelectItem>
212
+ ))}
213
+ </SelectContent>
214
+ </Select>
215
+ </div>
216
+ {profiles.length > 0 && (
217
+ <div className="space-y-2">
218
+ <Label className="flex items-center gap-1.5">
219
+ <Bot className="h-3.5 w-3.5 text-muted-foreground" />
220
+ Agent Profile
221
+ </Label>
222
+ <Select
223
+ value={agentProfile || "auto"}
224
+ onValueChange={(value) =>
225
+ setAgentProfile(value === "auto" ? "" : value)
226
+ }
227
+ >
228
+ <SelectTrigger>
229
+ <SelectValue placeholder="Auto-detect" />
230
+ </SelectTrigger>
231
+ <SelectContent>
232
+ <SelectItem value="auto">Auto-detect</SelectItem>
233
+ {profiles.map((p) => (
234
+ <SelectItem
235
+ key={p.id}
236
+ value={p.id}
237
+ disabled={!profileSupportsRuntime(p, selectedRuntimeId)}
238
+ >
239
+ <span className="flex items-center gap-1.5">
240
+ <Bot className="h-3 w-3" />
241
+ {p.name}
242
+ </span>
243
+ </SelectItem>
244
+ ))}
245
+ </SelectContent>
246
+ </Select>
247
+ {selectedProfile && (
248
+ <p
249
+ className={`text-xs ${
250
+ profileCompatibilityError
251
+ ? "text-destructive"
252
+ : "text-muted-foreground"
253
+ }`}
254
+ >
255
+ {profileCompatibilityError ??
256
+ `Supports ${getSupportedRuntimes(selectedProfile)
257
+ .map(
258
+ (runtimeId) =>
259
+ runtimeLabelMap.get(runtimeId) ?? runtimeId
260
+ )
261
+ .join(", ")}`}
262
+ </p>
263
+ )}
264
+ </div>
265
+ )}
266
+ <Button
267
+ type="submit"
268
+ disabled={loading || !title.trim() || !!profileCompatibilityError}
269
+ className="w-full"
270
+ >
271
+ {loading ? "Saving..." : "Save Changes"}
272
+ </Button>
273
+ </form>
274
+ </DialogContent>
275
+ </Dialog>
276
+ );
277
+ }
@@ -0,0 +1,57 @@
1
+ import { renderHook, act } from "@testing-library/react";
2
+ import { usePersistedState } from "@/hooks/use-persisted-state";
3
+
4
+ describe("usePersistedState", () => {
5
+ beforeEach(() => {
6
+ localStorage.clear();
7
+ });
8
+
9
+ it("returns default value when localStorage is empty", () => {
10
+ const { result } = renderHook(() => usePersistedState("test-key", "default"));
11
+ expect(result.current[0]).toBe("default");
12
+ });
13
+
14
+ it("reads stored value from localStorage on mount", () => {
15
+ localStorage.setItem("test-key", "stored-value");
16
+ const { result } = renderHook(() => usePersistedState("test-key", "default"));
17
+ // After effect runs, value should be the stored one
18
+ expect(result.current[0]).toBe("stored-value");
19
+ });
20
+
21
+ it("writes to localStorage when setter is called", () => {
22
+ const { result } = renderHook(() => usePersistedState("test-key", "default"));
23
+
24
+ act(() => {
25
+ result.current[1]("new-value");
26
+ });
27
+
28
+ expect(result.current[0]).toBe("new-value");
29
+ expect(localStorage.getItem("test-key")).toBe("new-value");
30
+ });
31
+
32
+ it("handles localStorage errors gracefully", () => {
33
+ const origGetItem = Storage.prototype.getItem;
34
+ Storage.prototype.getItem = () => {
35
+ throw new Error("Quota exceeded");
36
+ };
37
+
38
+ const { result } = renderHook(() => usePersistedState("test-key", "fallback"));
39
+ expect(result.current[0]).toBe("fallback");
40
+
41
+ Storage.prototype.getItem = origGetItem;
42
+ });
43
+
44
+ it("maintains independent values for different keys", () => {
45
+ const { result: hookA } = renderHook(() => usePersistedState("key-a", "a-default"));
46
+ const { result: hookB } = renderHook(() => usePersistedState("key-b", "b-default"));
47
+
48
+ act(() => {
49
+ hookA.current[1]("a-updated");
50
+ });
51
+
52
+ expect(hookA.current[0]).toBe("a-updated");
53
+ expect(hookB.current[0]).toBe("b-default");
54
+ expect(localStorage.getItem("key-a")).toBe("a-updated");
55
+ expect(localStorage.getItem("key-b")).toBeNull();
56
+ });
57
+ });
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+
5
+ /**
6
+ * useState wrapper that persists to localStorage.
7
+ * Reads from storage in useEffect to avoid SSR hydration mismatch.
8
+ */
9
+ export function usePersistedState<T extends string>(
10
+ key: string,
11
+ defaultValue: T
12
+ ): [T, (value: T) => void] {
13
+ const [value, setValue] = useState<T>(defaultValue);
14
+
15
+ // Restore from localStorage on mount (client-only)
16
+ useEffect(() => {
17
+ try {
18
+ const stored = localStorage.getItem(key);
19
+ if (stored !== null) {
20
+ setValue(stored as T);
21
+ }
22
+ } catch {
23
+ // localStorage unavailable (e.g. private browsing)
24
+ }
25
+ }, [key]);
26
+
27
+ const setPersisted = useCallback(
28
+ (next: T) => {
29
+ setValue(next);
30
+ try {
31
+ localStorage.setItem(key, next);
32
+ } catch {
33
+ // localStorage unavailable
34
+ }
35
+ },
36
+ [key]
37
+ );
38
+
39
+ return [value, setPersisted];
40
+ }
@@ -1,4 +1,5 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import { z } from "zod";
2
3
  import { db } from "@/lib/db";
3
4
  import { tasks, projects, agentLogs, notifications } from "@/lib/db/schema";
4
5
  import { eq } from "drizzle-orm";
@@ -49,11 +50,13 @@ interface TaskUsageState extends UsageSnapshot {
49
50
  scheduleId?: string | null;
50
51
  }
51
52
 
52
- interface ToolPermissionResponse {
53
- behavior: "allow" | "deny";
54
- updatedInput?: unknown;
55
- message?: string;
56
- }
53
+ const toolPermissionResponseSchema = z.object({
54
+ behavior: z.enum(["allow", "deny"]),
55
+ updatedInput: z.unknown().optional(),
56
+ message: z.string().optional(),
57
+ });
58
+
59
+ type ToolPermissionResponse = z.infer<typeof toolPermissionResponseSchema>;
57
60
 
58
61
  const inFlightPermissionRequests = new Map<
59
62
  string,
@@ -149,8 +152,15 @@ async function waitForToolPermissionResponse(
149
152
 
150
153
  if (notification?.response) {
151
154
  try {
152
- return JSON.parse(notification.response) as ToolPermissionResponse;
153
- } catch {
155
+ const parsed = JSON.parse(notification.response);
156
+ const validated = toolPermissionResponseSchema.safeParse(parsed);
157
+ if (validated.success) {
158
+ return validated.data;
159
+ }
160
+ console.error("[claude-agent] Invalid permission response shape:", validated.error.message);
161
+ return { behavior: "deny", message: "Invalid response format" };
162
+ } catch (err) {
163
+ console.error("[claude-agent] Failed to parse permission response:", err);
154
164
  return { behavior: "deny", message: "Invalid response format" };
155
165
  }
156
166
  }
@@ -1,12 +1,26 @@
1
1
  /**
2
2
  * Build the environment for the Claude Agent SDK subprocess.
3
- * Returns undefined when no changes are needed.
3
+ *
4
+ * Always strips CLAUDECODE (prevents nested-session issues) and
5
+ * ANTHROPIC_API_KEY (prevents SDK from using API-key auth when
6
+ * OAuth mode is intended).
7
+ *
8
+ * - API-key mode: authEnv is provided → key gets merged back in via spread.
9
+ * - OAuth mode: authEnv is undefined → key stays stripped, SDK falls
10
+ * through to cached OAuth tokens from `claude login`.
4
11
  */
5
12
  export function buildClaudeSdkEnv(
6
13
  authEnv?: Record<string, string>
7
- ): Record<string, string> | undefined {
8
- const isNested = "CLAUDECODE" in process.env;
9
- if (!authEnv && !isNested) return undefined;
10
- const { CLAUDECODE, ...cleanEnv } = process.env as Record<string, string>;
11
- return { ...cleanEnv, ...authEnv };
14
+ ): Record<string, string> {
15
+ const { CLAUDECODE, ANTHROPIC_API_KEY, ...cleanEnv } =
16
+ process.env as Record<string, string>;
17
+
18
+ if (authEnv) {
19
+ // API key mode — merge the provided key into clean env
20
+ return { ...cleanEnv, ...authEnv };
21
+ }
22
+
23
+ // OAuth mode — return env WITHOUT ANTHROPIC_API_KEY
24
+ // so the SDK subprocess uses cached OAuth tokens from Claude CLI
25
+ return cleanEnv;
12
26
  }
@@ -39,11 +39,20 @@ async function collectResultText(
39
39
 
40
40
  for await (const raw of response) {
41
41
  usage = mergeUsageSnapshot(usage, extractUsageSnapshot(raw));
42
- if (raw.type === "result" && "result" in raw) {
43
- resultText =
44
- typeof raw.result === "string"
45
- ? raw.result
46
- : JSON.stringify(raw.result);
42
+
43
+ if (raw.type === "content_block_delta") {
44
+ const delta = raw.delta as Record<string, unknown> | undefined;
45
+ if (delta?.type === "text_delta" && typeof delta.text === "string") {
46
+ resultText += delta.text;
47
+ }
48
+ } else if (raw.type === "result" && "result" in raw) {
49
+ if (raw.is_error) {
50
+ throw new Error(typeof raw.result === "string" ? raw.result : "Agent SDK returned an error");
51
+ }
52
+ const result = raw.result;
53
+ if (typeof result === "string" && result.length > 0) {
54
+ resultText = result;
55
+ }
47
56
  break;
48
57
  }
49
58
  }
@@ -230,12 +239,19 @@ async function runClaudeTaskAssist(
230
239
  const startedAt = new Date();
231
240
  let usage: UsageSnapshot = {};
232
241
 
242
+ const abortController = new AbortController();
243
+ const timeout = setTimeout(() => abortController.abort(), 30_000);
244
+
233
245
  try {
234
246
  const response = query({
235
247
  prompt,
236
248
  options: {
249
+ abortController,
250
+ includePartialMessages: true,
237
251
  cwd: process.cwd(),
238
252
  env: buildClaudeSdkEnv(authEnv),
253
+ allowedTools: [], // No tool use — pure text completion
254
+ maxTurns: 1, // Single turn only — no agentic loop
239
255
  },
240
256
  });
241
257
 
@@ -283,6 +299,8 @@ async function runClaudeTaskAssist(
283
299
  finishedAt: new Date(),
284
300
  });
285
301
  throw error;
302
+ } finally {
303
+ clearTimeout(timeout);
286
304
  }
287
305
  }
288
306
 
@@ -643,7 +643,13 @@ async function runAssistTurn({
643
643
  ephemeral: true,
644
644
  })) as { thread: { id: string } };
645
645
 
646
+ const ASSIST_TIMEOUT_MS = 60_000;
647
+
646
648
  const completion = new Promise<void>((resolve, reject) => {
649
+ client!.onProcessError = (error: Error) => {
650
+ reject(new Error(`Codex process died: ${error.message}`));
651
+ };
652
+
647
653
  client!.onNotification = (notification: JsonRpcLikeNotification) => {
648
654
  const params = asRecord(notification.params) ?? {};
649
655
  applyUsageSnapshot(usage, params);
@@ -669,6 +675,13 @@ async function runAssistTurn({
669
675
  };
670
676
  });
671
677
 
678
+ const timeout = new Promise<never>((_, reject) => {
679
+ setTimeout(
680
+ () => reject(new Error("Codex task assist timed out after 60s")),
681
+ ASSIST_TIMEOUT_MS
682
+ );
683
+ });
684
+
672
685
  await client.request("turn/start", {
673
686
  threadId: threadResponse.thread.id,
674
687
  input: buildTurnInput(prompt),
@@ -676,7 +689,7 @@ async function runAssistTurn({
676
689
  outputSchema: TASK_ASSIST_OUTPUT_SCHEMA,
677
690
  });
678
691
 
679
- await completion;
692
+ await Promise.race([completion, timeout]);
680
693
 
681
694
  return { text: text.trim(), usage };
682
695
  } finally {
@@ -193,44 +193,29 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
193
193
  CREATE INDEX IF NOT EXISTS idx_learned_context_change_type ON learned_context(change_type);
194
194
  `);
195
195
 
196
- try {
197
- sqlite.exec(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
198
- } catch {
199
- // Column already exists.
200
- }
196
+ const addColumnIfMissing = (ddl: string) => {
197
+ try {
198
+ sqlite.exec(ddl);
199
+ } catch (err: unknown) {
200
+ const msg = err instanceof Error ? err.message : String(err);
201
+ if (!msg.includes("duplicate column")) {
202
+ console.error("[bootstrap] ALTER TABLE failed:", msg);
203
+ }
204
+ }
205
+ };
206
+
207
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
201
208
  sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_agent_profile ON tasks(agent_profile);`);
202
209
 
203
- try {
204
- sqlite.exec(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
205
- } catch {
206
- // Column already exists.
207
- }
210
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
208
211
  sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id);`);
209
212
 
210
- try {
211
- sqlite.exec(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
212
- } catch {
213
- // Column already exists.
214
- }
213
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
215
214
  sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_schedule_id ON tasks(schedule_id);`);
216
215
 
217
- try {
218
- sqlite.exec(`ALTER TABLE projects ADD COLUMN working_directory TEXT;`);
219
- } catch {
220
- // Column already exists.
221
- }
222
-
223
- try {
224
- sqlite.exec(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
225
- } catch {
226
- // Column already exists.
227
- }
228
-
229
- try {
230
- sqlite.exec(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
231
- } catch {
232
- // Column already exists.
233
- }
216
+ addColumnIfMissing(`ALTER TABLE projects ADD COLUMN working_directory TEXT;`);
217
+ addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
218
+ addColumnIfMissing(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
234
219
  }
235
220
 
236
221
  export function hasLegacyStagentTables(sqlite: Database.Database): boolean {
@@ -42,8 +42,9 @@ export async function cleanupOrphanedUploads(): Promise<{
42
42
  errors.push(`${filename}: ${err instanceof Error ? err.message : "unknown error"}`);
43
43
  }
44
44
  }
45
- } catch {
46
- // Upload directory may not exist yet
45
+ } catch (err) {
46
+ // Upload directory may not exist yet — log for visibility
47
+ console.error("[cleanup] Failed to read upload directory:", err);
47
48
  }
48
49
 
49
50
  return { deleted, errors };
@@ -23,7 +23,8 @@ export function parseNotificationToolInput(
23
23
  return parsed && typeof parsed === "object"
24
24
  ? (parsed as PermissionToolInput)
25
25
  : null;
26
- } catch {
26
+ } catch (err) {
27
+ console.error("[permissions] Failed to parse notification tool input:", err);
27
28
  return null;
28
29
  }
29
30
  }
@@ -163,7 +164,8 @@ export function getPermissionResponseLabel(response: string | null): string | nu
163
164
  }
164
165
 
165
166
  return null;
166
- } catch {
167
+ } catch (err) {
168
+ console.error("[permissions] Failed to parse permission response:", err);
167
169
  return null;
168
170
  }
169
171
  }
@@ -705,8 +705,8 @@ export async function executeChildTask(
705
705
 
706
706
  try {
707
707
  await executeTaskWithRuntime(taskId);
708
- } catch {
709
- // Runtime adapter handles its own error logging
708
+ } catch (err) {
709
+ console.error(`[workflow-engine] Runtime execution failed for task ${taskId}:`, err);
710
710
  }
711
711
 
712
712
  const [completedTask] = await db