stagent 0.1.7 → 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.
package/dist/cli.js CHANGED
@@ -276,33 +276,25 @@ function bootstrapStagentDatabase(sqlite2) {
276
276
  CREATE INDEX IF NOT EXISTS idx_learned_context_profile_version ON learned_context(profile_id, version);
277
277
  CREATE INDEX IF NOT EXISTS idx_learned_context_change_type ON learned_context(change_type);
278
278
  `);
279
- try {
280
- sqlite2.exec(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
281
- } catch {
282
- }
279
+ const addColumnIfMissing = (ddl) => {
280
+ try {
281
+ sqlite2.exec(ddl);
282
+ } catch (err) {
283
+ const msg = err instanceof Error ? err.message : String(err);
284
+ if (!msg.includes("duplicate column")) {
285
+ console.error("[bootstrap] ALTER TABLE failed:", msg);
286
+ }
287
+ }
288
+ };
289
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
283
290
  sqlite2.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_agent_profile ON tasks(agent_profile);`);
284
- try {
285
- sqlite2.exec(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
286
- } catch {
287
- }
291
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
288
292
  sqlite2.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id);`);
289
- try {
290
- sqlite2.exec(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
291
- } catch {
292
- }
293
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
293
294
  sqlite2.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_schedule_id ON tasks(schedule_id);`);
294
- try {
295
- sqlite2.exec(`ALTER TABLE projects ADD COLUMN working_directory TEXT;`);
296
- } catch {
297
- }
298
- try {
299
- sqlite2.exec(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
300
- } catch {
301
- }
302
- try {
303
- sqlite2.exec(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
304
- } catch {
305
- }
295
+ addColumnIfMissing(`ALTER TABLE projects ADD COLUMN working_directory TEXT;`);
296
+ addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
297
+ addColumnIfMissing(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
306
298
  }
307
299
  function hasLegacyStagentTables(sqlite2) {
308
300
  const placeholders = STAGENT_TABLES.map(() => "?").join(", ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stagent",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Governed AI agent workspace for supervised local execution, workflows, documents, and provider runtimes.",
5
5
  "keywords": [
6
6
  "ai",
@@ -3,6 +3,11 @@ import { db } from "@/lib/db";
3
3
  import { documents, tasks, projects } from "@/lib/db/schema";
4
4
  import { eq, and, like, or, desc, sql } from "drizzle-orm";
5
5
 
6
+ const VALID_DOC_STATUSES = ["uploaded", "processing", "ready", "error"] as const;
7
+ const VALID_DOC_DIRECTIONS = ["input", "output"] as const;
8
+ type DocStatus = typeof VALID_DOC_STATUSES[number];
9
+ type DocDirection = typeof VALID_DOC_DIRECTIONS[number];
10
+
6
11
  export async function GET(req: NextRequest) {
7
12
  const url = new URL(req.url);
8
13
  const taskId = url.searchParams.get("taskId");
@@ -11,12 +16,26 @@ export async function GET(req: NextRequest) {
11
16
  const direction = url.searchParams.get("direction");
12
17
  const search = url.searchParams.get("search");
13
18
 
19
+ if (status && !VALID_DOC_STATUSES.includes(status as DocStatus)) {
20
+ return NextResponse.json(
21
+ { error: `Invalid status. Must be one of: ${VALID_DOC_STATUSES.join(", ")}` },
22
+ { status: 400 }
23
+ );
24
+ }
25
+
26
+ if (direction && !VALID_DOC_DIRECTIONS.includes(direction as DocDirection)) {
27
+ return NextResponse.json(
28
+ { error: `Invalid direction. Must be one of: ${VALID_DOC_DIRECTIONS.join(", ")}` },
29
+ { status: 400 }
30
+ );
31
+ }
32
+
14
33
  const conditions = [];
15
34
 
16
35
  if (taskId) conditions.push(eq(documents.taskId, taskId));
17
36
  if (projectId) conditions.push(eq(documents.projectId, projectId));
18
- if (status) conditions.push(eq(documents.status, status as "uploaded" | "processing" | "ready" | "error"));
19
- if (direction) conditions.push(eq(documents.direction, direction as "input" | "output"));
37
+ if (status) conditions.push(eq(documents.status, status as DocStatus));
38
+ if (direction) conditions.push(eq(documents.direction, direction as DocDirection));
20
39
 
21
40
  if (search) {
22
41
  conditions.push(
@@ -6,14 +6,24 @@ import { createTaskSchema } from "@/lib/validators/task";
6
6
  import { processDocument } from "@/lib/documents/processor";
7
7
  import { validateRuntimeProfileAssignment } from "@/lib/agents/profiles/assignment-validation";
8
8
 
9
+ const VALID_TASK_STATUSES = ["planned", "queued", "running", "completed", "failed", "cancelled"] as const;
10
+ type TaskStatus = typeof VALID_TASK_STATUSES[number];
11
+
9
12
  export async function GET(req: NextRequest) {
10
13
  const url = new URL(req.url);
11
14
  const projectId = url.searchParams.get("projectId");
12
15
  const status = url.searchParams.get("status");
13
16
 
17
+ if (status && !VALID_TASK_STATUSES.includes(status as TaskStatus)) {
18
+ return NextResponse.json(
19
+ { error: `Invalid status. Must be one of: ${VALID_TASK_STATUSES.join(", ")}` },
20
+ { status: 400 }
21
+ );
22
+ }
23
+
14
24
  const conditions = [];
15
25
  if (projectId) conditions.push(eq(tasks.projectId, projectId));
16
- if (status) conditions.push(eq(tasks.status, status as typeof tasks.status.enumValues[number]));
26
+ if (status) conditions.push(eq(tasks.status, status as TaskStatus));
17
27
 
18
28
  const result = await db
19
29
  .select()
@@ -70,10 +80,13 @@ export async function POST(req: NextRequest) {
70
80
  .where(eq(documents.id, fileId));
71
81
 
72
82
  // Trigger processing if not already done (fire-and-forget)
73
- processDocument(fileId).catch(() => {});
83
+ processDocument(fileId).catch((err) => {
84
+ console.error(`[tasks] processDocument failed for ${fileId}:`, err);
85
+ });
74
86
  }
75
- } catch {
87
+ } catch (err) {
76
88
  // File association is best-effort — don't fail task creation
89
+ console.error("[tasks] File association failed:", err);
77
90
  }
78
91
  }
79
92
 
@@ -3,6 +3,7 @@ import { writeFile, mkdir } from "fs/promises";
3
3
  import { join } from "path";
4
4
  import { db } from "@/lib/db";
5
5
  import { documents } from "@/lib/db/schema";
6
+ import { eq } from "drizzle-orm";
6
7
  import { processDocument } from "@/lib/documents/processor";
7
8
  import { getStagentUploadsDir } from "@/lib/utils/stagent-paths";
8
9
 
@@ -48,9 +49,22 @@ export async function POST(req: NextRequest) {
48
49
  });
49
50
 
50
51
  // Fire-and-forget: trigger async document processing
51
- processDocument(id).catch((err) =>
52
- console.error(`[upload] Processing failed for ${id}:`, err)
53
- );
52
+ processDocument(id).catch(async (err) => {
53
+ console.error(`[upload] Processing failed for ${id}:`, err);
54
+ // Ensure document doesn't stay stuck in "processing" state
55
+ try {
56
+ await db
57
+ .update(documents)
58
+ .set({
59
+ status: "error",
60
+ processingError: err instanceof Error ? err.message : String(err),
61
+ updatedAt: new Date(),
62
+ })
63
+ .where(eq(documents.id, id));
64
+ } catch (dbErr) {
65
+ console.error(`[upload] Failed to update error status for ${id}:`, dbErr);
66
+ }
67
+ });
54
68
 
55
69
  return NextResponse.json(
56
70
  {
@@ -659,6 +659,40 @@
659
659
  border: 1px solid color-mix(in oklab, var(--border) 75%, transparent);
660
660
  }
661
661
 
662
+ /* --- Progress slide animation (AI Assist) --- */
663
+ @keyframes progress-slide {
664
+ 0% { transform: translateX(-100%); }
665
+ 50% { transform: translateX(0%); }
666
+ 100% { transform: translateX(100%); }
667
+ }
668
+
669
+ /* --- Card exit animation (ghost card deletion) --- */
670
+ @keyframes card-exit {
671
+ 0% {
672
+ opacity: 1;
673
+ transform: scale(1);
674
+ max-height: 300px;
675
+ }
676
+ 40% {
677
+ opacity: 0;
678
+ transform: scale(0.97);
679
+ max-height: 300px;
680
+ }
681
+ 100% {
682
+ opacity: 0;
683
+ transform: scale(0.95);
684
+ max-height: 0;
685
+ margin-top: 0;
686
+ margin-bottom: 0;
687
+ overflow: hidden;
688
+ }
689
+ }
690
+
691
+ .animate-card-exit {
692
+ animation: card-exit 500ms ease-out forwards;
693
+ overflow: hidden;
694
+ }
695
+
662
696
  /* --- Glass shimmer animation --- */
663
697
  @keyframes glass-shimmer {
664
698
  0% { background-position: -200% 0; }
@@ -7,7 +7,12 @@ import { TaskCreatePanel } from "@/components/tasks/task-create-panel";
7
7
 
8
8
  export const dynamic = "force-dynamic";
9
9
 
10
- export default async function NewTaskPage() {
10
+ export default async function NewTaskPage({
11
+ searchParams,
12
+ }: {
13
+ searchParams: Promise<{ project?: string }>;
14
+ }) {
15
+ const params = await searchParams;
11
16
  const allProjects = await db
12
17
  .select({ id: projects.id, name: projects.name })
13
18
  .from(projects)
@@ -21,7 +26,10 @@ export default async function NewTaskPage() {
21
26
  Back to Dashboard
22
27
  </Button>
23
28
  </Link>
24
- <TaskCreatePanel projects={allProjects} />
29
+ <TaskCreatePanel
30
+ projects={allProjects}
31
+ defaultProjectId={params.project}
32
+ />
25
33
  </div>
26
34
  );
27
35
  }
@@ -0,0 +1,124 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { KanbanBoard, compareTasks } from "@/components/tasks/kanban-board";
3
+ import type { TaskItem } from "@/components/tasks/task-card";
4
+
5
+ vi.mock("next/navigation", () => ({
6
+ useRouter: () => ({
7
+ push: vi.fn(),
8
+ refresh: vi.fn(),
9
+ }),
10
+ }));
11
+
12
+ vi.mock("sonner", () => ({
13
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
14
+ }));
15
+
16
+ class ResizeObserverMock {
17
+ observe() {}
18
+ disconnect() {}
19
+ unobserve() {}
20
+ }
21
+
22
+ const makeTask = (overrides: Partial<TaskItem> = {}): TaskItem => ({
23
+ id: "task-1",
24
+ title: "Test task",
25
+ description: "A test task",
26
+ status: "planned",
27
+ priority: 2,
28
+ assignedAgent: null,
29
+ agentProfile: null,
30
+ projectId: "project-1",
31
+ projectName: "Project A",
32
+ result: null,
33
+ sessionId: null,
34
+ resumeCount: 0,
35
+ createdAt: new Date("2026-03-12T09:00:00Z").toISOString(),
36
+ updatedAt: new Date("2026-03-12T09:00:00Z").toISOString(),
37
+ ...overrides,
38
+ });
39
+
40
+ const defaultProjects = [
41
+ { id: "project-1", name: "Project A" },
42
+ { id: "project-2", name: "Project B" },
43
+ ];
44
+
45
+ describe("kanban board persistence", () => {
46
+ beforeAll(() => {
47
+ vi.stubGlobal("ResizeObserver", ResizeObserverMock);
48
+ });
49
+
50
+ afterAll(() => {
51
+ vi.unstubAllGlobals();
52
+ });
53
+
54
+ beforeEach(() => {
55
+ localStorage.clear();
56
+ sessionStorage.clear();
57
+ });
58
+
59
+ it("resets stale project filter to 'all'", () => {
60
+ // Pre-set a project ID that doesn't exist in the projects list
61
+ localStorage.setItem("stagent-project-filter", "deleted-project-999");
62
+
63
+ render(
64
+ <KanbanBoard
65
+ initialTasks={[makeTask()]}
66
+ projects={defaultProjects}
67
+ />
68
+ );
69
+
70
+ // The task should be visible (not filtered out by a stale project ID)
71
+ expect(screen.getByText("Test task")).toBeInTheDocument();
72
+ });
73
+
74
+ it("includes ?project= in New Task link when project is filtered", () => {
75
+ localStorage.setItem("stagent-project-filter", "project-1");
76
+
77
+ render(
78
+ <KanbanBoard
79
+ initialTasks={[makeTask()]}
80
+ projects={defaultProjects}
81
+ />
82
+ );
83
+
84
+ const newTaskLink = screen.getByRole("link", { name: /new task/i });
85
+ expect(newTaskLink).toHaveAttribute("href", "/tasks/new?project=project-1");
86
+ });
87
+
88
+ it("has plain /tasks/new link when filter is 'all'", () => {
89
+ render(
90
+ <KanbanBoard
91
+ initialTasks={[makeTask()]}
92
+ projects={defaultProjects}
93
+ />
94
+ );
95
+
96
+ const newTaskLink = screen.getByRole("link", { name: /new task/i });
97
+ expect(newTaskLink).toHaveAttribute("href", "/tasks/new");
98
+ });
99
+ });
100
+
101
+ describe("compareTasks", () => {
102
+ const taskA = makeTask({ id: "a", title: "Alpha", priority: 0, createdAt: new Date("2026-03-10").toISOString() });
103
+ const taskB = makeTask({ id: "b", title: "Bravo", priority: 2, createdAt: new Date("2026-03-12").toISOString() });
104
+
105
+ it("sorts by priority ascending", () => {
106
+ expect(compareTasks(taskA, taskB, "priority")).toBeLessThan(0);
107
+ expect(compareTasks(taskB, taskA, "priority")).toBeGreaterThan(0);
108
+ });
109
+
110
+ it("sorts by created-desc (newer first)", () => {
111
+ expect(compareTasks(taskB, taskA, "created-desc")).toBeLessThan(0);
112
+ expect(compareTasks(taskA, taskB, "created-desc")).toBeGreaterThan(0);
113
+ });
114
+
115
+ it("sorts by created-asc (older first)", () => {
116
+ expect(compareTasks(taskA, taskB, "created-asc")).toBeLessThan(0);
117
+ expect(compareTasks(taskB, taskA, "created-asc")).toBeGreaterThan(0);
118
+ });
119
+
120
+ it("sorts by title-asc (alphabetical)", () => {
121
+ expect(compareTasks(taskA, taskB, "title-asc")).toBeLessThan(0);
122
+ expect(compareTasks(taskB, taskA, "title-asc")).toBeGreaterThan(0);
123
+ });
124
+ });
@@ -0,0 +1,58 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { TaskCreatePanel } from "@/components/tasks/task-create-panel";
3
+
4
+ vi.mock("next/navigation", () => ({
5
+ useRouter: () => ({
6
+ push: vi.fn(),
7
+ refresh: vi.fn(),
8
+ }),
9
+ }));
10
+
11
+ vi.mock("sonner", () => ({
12
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
13
+ }));
14
+
15
+ const projects = [
16
+ { id: "proj-1", name: "Alpha Project" },
17
+ { id: "proj-2", name: "Beta Project" },
18
+ ];
19
+
20
+ describe("TaskCreatePanel defaultProjectId", () => {
21
+ beforeEach(() => {
22
+ // Mock the /api/profiles fetch
23
+ global.fetch = vi.fn().mockResolvedValue({
24
+ ok: true,
25
+ json: () => Promise.resolve([]),
26
+ });
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ it("pre-selects project when defaultProjectId is provided", () => {
34
+ render(
35
+ <TaskCreatePanel projects={projects} defaultProjectId="proj-1" />
36
+ );
37
+
38
+ // The select-value span should show the project name
39
+ const selectValues = screen.getAllByText("Alpha Project");
40
+ const triggerValue = selectValues.find(
41
+ (el) => el.getAttribute("data-slot") === "select-value"
42
+ );
43
+ expect(triggerValue).toBeDefined();
44
+ });
45
+
46
+ it("shows 'None' when no defaultProjectId is provided", () => {
47
+ render(
48
+ <TaskCreatePanel projects={projects} />
49
+ );
50
+
51
+ // The select-value span should show "None"
52
+ const noneElements = screen.getAllByText("None");
53
+ const triggerValue = noneElements.find(
54
+ (el) => el.getAttribute("data-slot") === "select-value"
55
+ );
56
+ expect(triggerValue).toBeDefined();
57
+ });
58
+ });
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useState, useEffect } from "react";
4
4
  import { Button } from "@/components/ui/button";
5
5
  import { Card, CardContent } from "@/components/ui/card";
6
6
  import { Badge } from "@/components/ui/badge";
7
- import { Sparkles, Loader2, Check, X } from "lucide-react";
7
+ import { Sparkles, Check, X } from "lucide-react";
8
8
 
9
9
  interface TaskSuggestion {
10
10
  title: string;
@@ -42,6 +42,45 @@ const complexityColors: Record<string, string> = {
42
42
  complex: "text-complexity-complex",
43
43
  };
44
44
 
45
+ const ACTIVITY_MESSAGES = [
46
+ "Connecting to AI...",
47
+ "Analyzing task complexity...",
48
+ "Generating suggestions...",
49
+ "Finalizing...",
50
+ ];
51
+
52
+ function ProgressBar({ loading }: { loading: boolean }) {
53
+ const [messageIndex, setMessageIndex] = useState(0);
54
+
55
+ useEffect(() => {
56
+ if (!loading) {
57
+ setMessageIndex(0);
58
+ return;
59
+ }
60
+
61
+ const interval = setInterval(() => {
62
+ setMessageIndex((prev) =>
63
+ prev < ACTIVITY_MESSAGES.length - 1 ? prev + 1 : prev
64
+ );
65
+ }, 3000);
66
+
67
+ return () => clearInterval(interval);
68
+ }, [loading]);
69
+
70
+ if (!loading) return null;
71
+
72
+ return (
73
+ <div className="space-y-1.5">
74
+ <div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
75
+ <div className="h-full w-full rounded-full bg-primary animate-[progress-slide_1.5s_ease-in-out_infinite]" />
76
+ </div>
77
+ <p className="text-xs text-muted-foreground text-center">
78
+ {ACTIVITY_MESSAGES[messageIndex]}
79
+ </p>
80
+ </div>
81
+ );
82
+ }
83
+
45
84
  export function AIAssistPanel({
46
85
  title,
47
86
  description,
@@ -87,7 +126,7 @@ export function AIAssistPanel({
87
126
 
88
127
  if (!result) {
89
128
  return (
90
- <div className="pt-2">
129
+ <div className="pt-2 space-y-2">
91
130
  <Button
92
131
  type="button"
93
132
  variant="outline"
@@ -96,17 +135,16 @@ export function AIAssistPanel({
96
135
  disabled={loading || (!title.trim() && !description.trim())}
97
136
  className="w-full"
98
137
  >
99
- {loading ? (
100
- <Loader2 className="h-3 w-3 mr-1 animate-spin" />
101
- ) : (
102
- <Sparkles className="h-3 w-3 mr-1" />
103
- )}
104
- {loading ? "Analyzing..." : "AI Assist"}
138
+ <Sparkles className="h-3 w-3 mr-1" />
139
+ AI Assist
105
140
  </Button>
141
+ <ProgressBar loading={loading} />
106
142
  {error && <p className="text-xs text-destructive mt-1">{error}</p>}
107
- <p className="text-xs text-muted-foreground text-center mt-1.5">
108
- Suggests improved descriptions, sub-task breakdowns, and workflow patterns
109
- </p>
143
+ {!loading && (
144
+ <p className="text-xs text-muted-foreground text-center mt-1.5">
145
+ Suggests improved descriptions, sub-task breakdowns, and workflow patterns
146
+ </p>
147
+ )}
110
148
  </div>
111
149
  );
112
150
  }