stagent 0.10.0 → 0.11.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.
- package/README.md +44 -31
- package/dist/cli.js +24 -0
- package/docs/.coverage-gaps.json +154 -24
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +12 -2
- package/docs/features/chat.md +40 -5
- package/docs/features/cost-usage.md +1 -1
- package/docs/features/documents.md +5 -2
- package/docs/features/inbox-notifications.md +10 -2
- package/docs/features/keyboard-navigation.md +12 -3
- package/docs/features/provider-runtimes.md +16 -2
- package/docs/features/settings.md +2 -2
- package/docs/features/shared-components.md +7 -3
- package/docs/features/tables.md +3 -1
- package/docs/features/tool-permissions.md +6 -2
- package/docs/features/workflows.md +6 -2
- package/docs/getting-started.md +1 -1
- package/docs/index.md +1 -1
- package/docs/journeys/developer.md +25 -2
- package/docs/journeys/personal-use.md +12 -5
- package/docs/journeys/power-user.md +45 -14
- package/docs/journeys/work-use.md +17 -8
- package/docs/manifest.json +15 -15
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
- package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
- package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
- package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
- package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
- package/next.config.mjs +1 -0
- package/package.json +3 -3
- package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
- package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
- package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
- package/src/app/api/chat/export/route.ts +52 -0
- package/src/app/api/chat/files/search/route.ts +50 -0
- package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
- package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
- package/src/app/api/environment/skills/route.ts +13 -0
- package/src/app/api/schedules/[id]/execute/route.ts +2 -2
- package/src/app/api/settings/chat/pins/route.ts +94 -0
- package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
- package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
- package/src/app/api/settings/environment/route.ts +26 -0
- package/src/app/api/tasks/[id]/execute/route.ts +52 -12
- package/src/app/api/tasks/[id]/respond/route.ts +31 -15
- package/src/app/api/tasks/[id]/resume/route.ts +24 -3
- package/src/app/documents/page.tsx +4 -1
- package/src/app/settings/page.tsx +2 -0
- package/src/components/book/content-blocks.tsx +1 -1
- package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
- package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
- package/src/components/chat/capability-banner.tsx +68 -0
- package/src/components/chat/chat-command-popover.tsx +668 -47
- package/src/components/chat/chat-input.tsx +103 -8
- package/src/components/chat/chat-message.tsx +12 -3
- package/src/components/chat/chat-session-provider.tsx +73 -3
- package/src/components/chat/chat-shell.tsx +62 -3
- package/src/components/chat/command-tab-bar.tsx +68 -0
- package/src/components/chat/conversation-template-picker.tsx +421 -0
- package/src/components/chat/help-dialog.tsx +39 -0
- package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
- package/src/components/chat/skill-row.tsx +147 -0
- package/src/components/documents/document-browser.tsx +37 -19
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
- package/src/components/notifications/permission-response-actions.tsx +155 -1
- package/src/components/playbook/playbook-detail-view.tsx +1 -1
- package/src/components/settings/environment-section.tsx +102 -0
- package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
- package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
- package/src/components/shared/command-palette.tsx +262 -2
- package/src/components/shared/filter-hint.tsx +70 -0
- package/src/components/shared/filter-input.tsx +59 -0
- package/src/components/shared/saved-searches-manager.tsx +199 -0
- package/src/components/tasks/task-bento-grid.tsx +12 -2
- package/src/components/tasks/task-card.tsx +3 -0
- package/src/components/tasks/task-chip-bar.tsx +30 -1
- package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
- package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
- package/src/hooks/use-active-skills.ts +110 -0
- package/src/hooks/use-chat-autocomplete.ts +120 -7
- package/src/hooks/use-enriched-skills.ts +19 -0
- package/src/hooks/use-pinned-entries.ts +104 -0
- package/src/hooks/use-recent-user-messages.ts +19 -0
- package/src/hooks/use-saved-searches.ts +142 -0
- package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
- package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
- package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
- package/src/lib/agents/claude-agent.ts +105 -46
- package/src/lib/agents/handoff/bus.ts +2 -2
- package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
- package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
- package/src/lib/agents/profiles/registry.ts +97 -22
- package/src/lib/agents/profiles/types.ts +7 -1
- package/src/lib/agents/router.ts +3 -6
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
- package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
- package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
- package/src/lib/agents/runtime/catalog.ts +121 -0
- package/src/lib/agents/runtime/claude-sdk.ts +32 -0
- package/src/lib/agents/runtime/execution-target.ts +456 -0
- package/src/lib/agents/runtime/index.ts +4 -0
- package/src/lib/agents/runtime/launch-failure.ts +101 -0
- package/src/lib/agents/runtime/openai-codex.ts +35 -0
- package/src/lib/agents/runtime/openai-direct.ts +8 -0
- package/src/lib/agents/task-dispatch.ts +220 -0
- package/src/lib/agents/tool-permissions.ts +16 -1
- package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
- package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
- package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
- package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
- package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
- package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
- package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
- package/src/lib/chat/__tests__/types.test.ts +28 -0
- package/src/lib/chat/active-skills.ts +31 -0
- package/src/lib/chat/clean-filter-input.ts +30 -0
- package/src/lib/chat/codex-engine.ts +30 -7
- package/src/lib/chat/command-tabs.ts +61 -0
- package/src/lib/chat/context-builder.ts +141 -1
- package/src/lib/chat/dismissals.ts +73 -0
- package/src/lib/chat/engine.ts +109 -15
- package/src/lib/chat/files/__tests__/search.test.ts +135 -0
- package/src/lib/chat/files/expand-mention.ts +76 -0
- package/src/lib/chat/files/search.ts +99 -0
- package/src/lib/chat/skill-composition.ts +210 -0
- package/src/lib/chat/skill-conflict.ts +105 -0
- package/src/lib/chat/stagent-tools.ts +6 -19
- package/src/lib/chat/stream-telemetry.ts +9 -4
- package/src/lib/chat/system-prompt.ts +22 -0
- package/src/lib/chat/tool-catalog.ts +33 -3
- package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
- package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
- package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
- package/src/lib/chat/tools/blueprint-tools.ts +190 -0
- package/src/lib/chat/tools/helpers.ts +2 -0
- package/src/lib/chat/tools/profile-tools.ts +120 -23
- package/src/lib/chat/tools/skill-tools.ts +183 -0
- package/src/lib/chat/tools/task-tools.ts +6 -2
- package/src/lib/chat/tools/workflow-tools.ts +61 -20
- package/src/lib/chat/types.ts +15 -0
- package/src/lib/constants/settings.ts +2 -0
- package/src/lib/data/clear.ts +2 -6
- package/src/lib/db/bootstrap.ts +17 -0
- package/src/lib/db/schema.ts +26 -0
- package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
- package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
- package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
- package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
- package/src/lib/environment/data.ts +9 -0
- package/src/lib/environment/list-skills.ts +176 -0
- package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
- package/src/lib/environment/parsers/skill.ts +26 -5
- package/src/lib/environment/profile-generator.ts +56 -2
- package/src/lib/environment/skill-enrichment.ts +106 -0
- package/src/lib/environment/skill-recommendations.ts +66 -0
- package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
- package/src/lib/filters/__tests__/parse.test.ts +135 -0
- package/src/lib/filters/parse.ts +86 -0
- package/src/lib/instance/__tests__/detect.test.ts +1 -1
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +8 -10
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- package/src/lib/utils/stagent-paths.ts +4 -0
- package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
- package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
- package/src/lib/workflows/blueprints/types.ts +6 -0
- package/src/lib/workflows/engine.ts +5 -3
- package/src/test/setup.ts +10 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { db } from "@/lib/db";
|
|
8
|
+
import { documents } from "@/lib/db/schema";
|
|
9
|
+
|
|
10
|
+
const bodySchema = z.object({
|
|
11
|
+
title: z.string().min(1).max(200),
|
|
12
|
+
markdown: z.string().min(1),
|
|
13
|
+
conversationId: z.string().nullable().optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export async function POST(req: NextRequest) {
|
|
17
|
+
const raw = await req.json().catch(() => null);
|
|
18
|
+
const parsed = bodySchema.safeParse(raw);
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
{ error: "Invalid body", details: parsed.error.issues },
|
|
22
|
+
{ status: 400 }
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { title, markdown, conversationId } = parsed.data;
|
|
27
|
+
const id = randomUUID();
|
|
28
|
+
const safeName = title.replace(/[^a-z0-9-_\. ]/gi, "_").slice(0, 80);
|
|
29
|
+
const filename = `${Date.now()}-${safeName}.md`;
|
|
30
|
+
const dir = path.join(homedir(), ".stagent", "uploads", "chat-exports");
|
|
31
|
+
await mkdir(dir, { recursive: true });
|
|
32
|
+
const storagePath = path.join(dir, filename);
|
|
33
|
+
await writeFile(storagePath, markdown, "utf8");
|
|
34
|
+
|
|
35
|
+
const now = new Date();
|
|
36
|
+
await db.insert(documents).values({
|
|
37
|
+
id,
|
|
38
|
+
filename,
|
|
39
|
+
originalName: `${safeName}.md`,
|
|
40
|
+
mimeType: "text/markdown",
|
|
41
|
+
size: Buffer.byteLength(markdown, "utf8"),
|
|
42
|
+
storagePath,
|
|
43
|
+
direction: "output",
|
|
44
|
+
status: "ready",
|
|
45
|
+
source: "chat-export",
|
|
46
|
+
conversationId: conversationId ?? null,
|
|
47
|
+
createdAt: now,
|
|
48
|
+
updatedAt: now,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return NextResponse.json({ id, filename }, { status: 201 });
|
|
52
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { db } from "@/lib/db";
|
|
4
|
+
import { projects } from "@/lib/db/schema";
|
|
5
|
+
import { getLaunchCwd } from "@/lib/environment/workspace-context";
|
|
6
|
+
import { searchFiles } from "@/lib/chat/files/search";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/chat/files/search?q=&projectId=&limit=20
|
|
10
|
+
*
|
|
11
|
+
* Returns files under the active project's workingDirectory (if a valid
|
|
12
|
+
* projectId is supplied), else under the stagent launch cwd. The client
|
|
13
|
+
* never supplies cwd directly — that would let a hostile prompt or XSS
|
|
14
|
+
* reach arbitrary paths on disk.
|
|
15
|
+
*
|
|
16
|
+
* Results respect `.gitignore` via `git ls-files --exclude-standard`.
|
|
17
|
+
*/
|
|
18
|
+
export async function GET(req: NextRequest) {
|
|
19
|
+
const { searchParams } = new URL(req.url);
|
|
20
|
+
const q = searchParams.get("q") ?? "";
|
|
21
|
+
|
|
22
|
+
const limitRaw = parseInt(searchParams.get("limit") ?? "20", 10);
|
|
23
|
+
const limit = Number.isFinite(limitRaw)
|
|
24
|
+
? Math.max(1, Math.min(50, limitRaw))
|
|
25
|
+
: 20;
|
|
26
|
+
|
|
27
|
+
const projectId = searchParams.get("projectId");
|
|
28
|
+
|
|
29
|
+
let cwd = getLaunchCwd();
|
|
30
|
+
if (projectId) {
|
|
31
|
+
const project = await db
|
|
32
|
+
.select({ workingDirectory: projects.workingDirectory })
|
|
33
|
+
.from(projects)
|
|
34
|
+
.where(eq(projects.id, projectId))
|
|
35
|
+
.get();
|
|
36
|
+
if (project?.workingDirectory) {
|
|
37
|
+
cwd = project.workingDirectory;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const results = searchFiles(cwd, q, limit);
|
|
43
|
+
return NextResponse.json({ results });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return NextResponse.json(
|
|
46
|
+
{ error: e instanceof Error ? e.message : "file search failed" },
|
|
47
|
+
{ status: 500 }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@/lib/environment/auto-scan", () => ({
|
|
4
|
+
shouldRescan: vi.fn(),
|
|
5
|
+
ensureFreshScan: vi.fn(() => ({ scannedAt: new Date() })),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("@/lib/environment/workspace-context", () => ({
|
|
9
|
+
getLaunchCwd: () => "/tmp/project",
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { POST } from "../route";
|
|
13
|
+
import * as autoScan from "@/lib/environment/auto-scan";
|
|
14
|
+
|
|
15
|
+
describe("POST /api/environment/rescan-if-stale", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns scanned:true when shouldRescan=true", async () => {
|
|
21
|
+
(autoScan.shouldRescan as unknown as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
|
22
|
+
const res = await POST();
|
|
23
|
+
expect(res.status).toBe(200);
|
|
24
|
+
const json = await res.json();
|
|
25
|
+
expect(json.scanned).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns scanned:false when not stale", async () => {
|
|
29
|
+
(autoScan.shouldRescan as unknown as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
30
|
+
const res = await POST();
|
|
31
|
+
const json = await res.json();
|
|
32
|
+
expect(json.scanned).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns scanned:false and logs when ensureFreshScan throws", async () => {
|
|
36
|
+
(autoScan.shouldRescan as unknown as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
|
37
|
+
(autoScan.ensureFreshScan as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
38
|
+
throw new Error("fs error");
|
|
39
|
+
});
|
|
40
|
+
const res = await POST();
|
|
41
|
+
const json = await res.json();
|
|
42
|
+
// ensureFreshScan itself swallows errors — but if it re-threw we must not 500
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { shouldRescan, ensureFreshScan } from "@/lib/environment/auto-scan";
|
|
3
|
+
import { getLaunchCwd } from "@/lib/environment/workspace-context";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fire-and-forget rescan endpoint for chat session activation.
|
|
7
|
+
* - If last scan is fresh (<5min), returns `{ scanned: false }` without I/O.
|
|
8
|
+
* - Otherwise, runs a scan via `ensureFreshScan` (which catches errors and
|
|
9
|
+
* returns null on failure), returns `{ scanned: true }`.
|
|
10
|
+
* - Never 500s; the chat UI must not be blocked by env issues.
|
|
11
|
+
*/
|
|
12
|
+
export async function POST() {
|
|
13
|
+
if (!shouldRescan()) {
|
|
14
|
+
return NextResponse.json({ scanned: false });
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
ensureFreshScan(getLaunchCwd());
|
|
18
|
+
} catch (err) {
|
|
19
|
+
// ensureFreshScan itself catches internally; this is belt + suspenders.
|
|
20
|
+
console.warn("[rescan-if-stale] unexpected:", err);
|
|
21
|
+
}
|
|
22
|
+
return NextResponse.json({ scanned: true });
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { listSkillsEnriched } from "@/lib/environment/list-skills";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
try {
|
|
6
|
+
return NextResponse.json(listSkillsEnriched());
|
|
7
|
+
} catch (err) {
|
|
8
|
+
return NextResponse.json(
|
|
9
|
+
{ error: err instanceof Error ? err.message : "scan failed" },
|
|
10
|
+
{ status: 500 }
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -2,13 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { db } from "@/lib/db";
|
|
3
3
|
import { schedules, tasks, usageLedger } from "@/lib/db/schema";
|
|
4
4
|
import { eq } from "drizzle-orm";
|
|
5
|
-
import { executeTaskWithRuntime } from "@/lib/agents/runtime";
|
|
6
5
|
import { claimSlot, countRunningScheduledSlots } from "@/lib/schedules/slot-claim";
|
|
7
6
|
import {
|
|
8
7
|
getScheduleMaxConcurrent,
|
|
9
8
|
getScheduleMaxRunDurationSec,
|
|
10
9
|
} from "@/lib/schedules/config";
|
|
11
10
|
import { randomUUID } from "crypto";
|
|
11
|
+
import { startTaskExecution } from "@/lib/agents/task-dispatch";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Manually fire a schedule. Honors the global concurrency cap by default.
|
|
@@ -103,7 +103,7 @@ export async function POST(
|
|
|
103
103
|
|
|
104
104
|
// Fire-and-forget: the route returns immediately with taskId; execution runs
|
|
105
105
|
// in the background. Errors are logged but do not affect the 200 response.
|
|
106
|
-
|
|
106
|
+
startTaskExecution(taskId).catch((err) => {
|
|
107
107
|
console.error(`[api/schedules/execute] task ${taskId} failed:`, err);
|
|
108
108
|
});
|
|
109
109
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getSetting, setSetting } from "@/lib/settings/helpers";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET/PUT `/api/settings/chat/pins` — per-user pinned entities for chat
|
|
7
|
+
* mention popover. Storage is a single JSON blob under the
|
|
8
|
+
* `chat.pinnedEntries` key in the `settings` key-value table.
|
|
9
|
+
*
|
|
10
|
+
* Design notes:
|
|
11
|
+
* - No server-side validation that `entityId` exists in its table.
|
|
12
|
+
* Pins are weakly referenced; popover filter just won't match if the
|
|
13
|
+
* entity has been deleted. Cheaper than maintaining referential integrity
|
|
14
|
+
* via cascading deletes, and stale pins can be removed by the user on next
|
|
15
|
+
* un-pin. Trade-off accepted.
|
|
16
|
+
* - PUT replaces the entire list (client is source of truth). Read-modify-
|
|
17
|
+
* write happens on the client to avoid concurrent-mutation issues, which
|
|
18
|
+
* for single-user local usage is a non-issue.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const SETTINGS_KEY = "chat.pinnedEntries";
|
|
22
|
+
|
|
23
|
+
// Entity types recognized by entities/search — mirrored here to constrain
|
|
24
|
+
// what can be pinned. Add new types as they become available.
|
|
25
|
+
const ENTITY_TYPES = [
|
|
26
|
+
"task",
|
|
27
|
+
"project",
|
|
28
|
+
"workflow",
|
|
29
|
+
"document",
|
|
30
|
+
"schedule",
|
|
31
|
+
"table",
|
|
32
|
+
"profile",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
// We denormalize `label`, `description`, and `status` into the pin record
|
|
36
|
+
// so the Pinned group renders standalone, independent of whether the item
|
|
37
|
+
// appears in the current `entities/search` response window (top-20 per type).
|
|
38
|
+
// Trade-off: labels may go stale if the underlying entity is renamed.
|
|
39
|
+
// Acceptable for a UX affordance — selecting the pin still uses the canonical
|
|
40
|
+
// id, and the user can un-pin/re-pin to refresh. Mitigation is lazy refresh
|
|
41
|
+
// on next popover open (future enhancement).
|
|
42
|
+
const PinnedEntrySchema = z.object({
|
|
43
|
+
id: z.string().min(1),
|
|
44
|
+
type: z.enum(ENTITY_TYPES),
|
|
45
|
+
label: z.string().min(1),
|
|
46
|
+
description: z.string().optional(),
|
|
47
|
+
status: z.string().optional(),
|
|
48
|
+
pinnedAt: z.string(), // ISO 8601
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const PinsPayloadSchema = z.object({
|
|
52
|
+
pins: z.array(PinnedEntrySchema),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export type PinnedEntry = z.infer<typeof PinnedEntrySchema>;
|
|
56
|
+
|
|
57
|
+
export async function GET() {
|
|
58
|
+
const raw = await getSetting(SETTINGS_KEY);
|
|
59
|
+
if (!raw) return NextResponse.json({ pins: [] });
|
|
60
|
+
try {
|
|
61
|
+
const parsed = PinsPayloadSchema.parse(JSON.parse(raw));
|
|
62
|
+
return NextResponse.json(parsed);
|
|
63
|
+
} catch {
|
|
64
|
+
// Malformed stored value (manual edit, version mismatch) — recover by
|
|
65
|
+
// returning an empty list rather than erroring. The user can re-pin.
|
|
66
|
+
return NextResponse.json({ pins: [] });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function PUT(req: NextRequest) {
|
|
71
|
+
let body: unknown;
|
|
72
|
+
try {
|
|
73
|
+
body = await req.json();
|
|
74
|
+
} catch {
|
|
75
|
+
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result = PinsPayloadSchema.safeParse(body);
|
|
79
|
+
if (!result.success) {
|
|
80
|
+
return NextResponse.json(
|
|
81
|
+
{ error: "invalid pins payload", issues: result.error.issues },
|
|
82
|
+
{ status: 400 }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// De-dup by id — client may send the same pin twice on rapid clicks.
|
|
87
|
+
// Last write wins for pinnedAt.
|
|
88
|
+
const byId = new Map<string, PinnedEntry>();
|
|
89
|
+
for (const pin of result.data.pins) byId.set(pin.id, pin);
|
|
90
|
+
const deduped = Array.from(byId.values());
|
|
91
|
+
|
|
92
|
+
await setSetting(SETTINGS_KEY, JSON.stringify({ pins: deduped }));
|
|
93
|
+
return NextResponse.json({ pins: deduped });
|
|
94
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@/lib/settings/helpers", () => {
|
|
4
|
+
const store = new Map<string, string>();
|
|
5
|
+
return {
|
|
6
|
+
getSetting: vi.fn(async (k: string) => store.get(k) ?? null),
|
|
7
|
+
setSetting: vi.fn(async (k: string, v: string) => {
|
|
8
|
+
store.set(k, v);
|
|
9
|
+
}),
|
|
10
|
+
__store: store,
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
import * as helpers from "@/lib/settings/helpers";
|
|
15
|
+
import { GET, PUT } from "../route";
|
|
16
|
+
import { NextRequest } from "next/server";
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Reset the in-memory store between tests
|
|
20
|
+
(helpers as unknown as { __store: Map<string, string> }).__store.clear();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("saved-searches route", () => {
|
|
24
|
+
it("GET with no stored value returns empty list", async () => {
|
|
25
|
+
const res = await GET();
|
|
26
|
+
const body = await res.json();
|
|
27
|
+
expect(body).toEqual({ searches: [] });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("PUT then GET round-trips a valid payload", async () => {
|
|
31
|
+
const payload = {
|
|
32
|
+
searches: [
|
|
33
|
+
{
|
|
34
|
+
id: "a",
|
|
35
|
+
surface: "task" as const,
|
|
36
|
+
label: "Blocked",
|
|
37
|
+
filterInput: "#status:blocked",
|
|
38
|
+
createdAt: "2026-04-14T00:00:00Z",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
const putRes = await PUT(
|
|
43
|
+
new NextRequest("http://x/api/settings/chat/saved-searches", {
|
|
44
|
+
method: "PUT",
|
|
45
|
+
body: JSON.stringify(payload),
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
expect(putRes.status).toBe(200);
|
|
49
|
+
|
|
50
|
+
const getBody = await (await GET()).json();
|
|
51
|
+
expect(getBody.searches).toHaveLength(1);
|
|
52
|
+
expect(getBody.searches[0].label).toBe("Blocked");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("PUT dedupes by id — last write wins", async () => {
|
|
56
|
+
const req = new NextRequest("http://x", {
|
|
57
|
+
method: "PUT",
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
searches: [
|
|
60
|
+
{
|
|
61
|
+
id: "a",
|
|
62
|
+
surface: "task",
|
|
63
|
+
label: "First",
|
|
64
|
+
filterInput: "#a:1",
|
|
65
|
+
createdAt: "2026-04-14T00:00:00Z",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "a",
|
|
69
|
+
surface: "task",
|
|
70
|
+
label: "Second (dup)",
|
|
71
|
+
filterInput: "#a:1",
|
|
72
|
+
createdAt: "2026-04-14T00:01:00Z",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
const res = await PUT(req);
|
|
78
|
+
const body = await res.json();
|
|
79
|
+
expect(body.searches).toHaveLength(1);
|
|
80
|
+
expect(body.searches[0].label).toBe("Second (dup)");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("PUT rejects invalid surface with 400", async () => {
|
|
84
|
+
const req = new NextRequest("http://x", {
|
|
85
|
+
method: "PUT",
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
searches: [
|
|
88
|
+
{
|
|
89
|
+
id: "a",
|
|
90
|
+
surface: "bogus",
|
|
91
|
+
label: "x",
|
|
92
|
+
filterInput: "",
|
|
93
|
+
createdAt: "z",
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
const res = await PUT(req);
|
|
99
|
+
expect(res.status).toBe(400);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("PUT rejects malformed JSON with 400", async () => {
|
|
103
|
+
const req = new NextRequest("http://x", {
|
|
104
|
+
method: "PUT",
|
|
105
|
+
body: "not json",
|
|
106
|
+
});
|
|
107
|
+
const res = await PUT(req);
|
|
108
|
+
expect(res.status).toBe(400);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("GET recovers from malformed stored value", async () => {
|
|
112
|
+
(helpers as unknown as { __store: Map<string, string> }).__store.set(
|
|
113
|
+
"chat.savedSearches",
|
|
114
|
+
"not-json-at-all"
|
|
115
|
+
);
|
|
116
|
+
const body = await (await GET()).json();
|
|
117
|
+
expect(body).toEqual({ searches: [] });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getSetting, setSetting } from "@/lib/settings/helpers";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET/PUT `/api/settings/chat/saved-searches` — per-user saved filter
|
|
7
|
+
* combinations for chat popovers and the ⌘K palette. Storage mirrors the
|
|
8
|
+
* pins route: a single JSON blob under `chat.savedSearches` in the
|
|
9
|
+
* settings key-value table.
|
|
10
|
+
*
|
|
11
|
+
* Design notes:
|
|
12
|
+
* - Full-list replacement on PUT. Client is source of truth — avoids
|
|
13
|
+
* concurrent-mutation bookkeeping for a single-user local product.
|
|
14
|
+
* - PUT dedupes by `id` with last-write-wins on rapid double-saves.
|
|
15
|
+
* - Malformed stored value (manual edit, schema drift) degrades to `[]`
|
|
16
|
+
* rather than erroring — the user can re-save.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SETTINGS_KEY = "chat.savedSearches";
|
|
20
|
+
|
|
21
|
+
// Surfaces map to popover tabs + list routes. Extend as new popover
|
|
22
|
+
// surfaces are added (table, schedule, etc.).
|
|
23
|
+
const SURFACES = [
|
|
24
|
+
"task",
|
|
25
|
+
"project",
|
|
26
|
+
"workflow",
|
|
27
|
+
"document",
|
|
28
|
+
"skill",
|
|
29
|
+
"profile",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
const SavedSearchSchema = z.object({
|
|
33
|
+
id: z.string().min(1),
|
|
34
|
+
surface: z.enum(SURFACES),
|
|
35
|
+
label: z.string().min(1).max(120),
|
|
36
|
+
filterInput: z.string().max(500),
|
|
37
|
+
createdAt: z.string(), // ISO 8601
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const PayloadSchema = z.object({
|
|
41
|
+
searches: z.array(SavedSearchSchema),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type SavedSearch = z.infer<typeof SavedSearchSchema>;
|
|
45
|
+
|
|
46
|
+
export async function GET() {
|
|
47
|
+
const raw = await getSetting(SETTINGS_KEY);
|
|
48
|
+
if (!raw) return NextResponse.json({ searches: [] });
|
|
49
|
+
try {
|
|
50
|
+
const parsed = PayloadSchema.parse(JSON.parse(raw));
|
|
51
|
+
return NextResponse.json(parsed);
|
|
52
|
+
} catch {
|
|
53
|
+
return NextResponse.json({ searches: [] });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function PUT(req: NextRequest) {
|
|
58
|
+
let body: unknown;
|
|
59
|
+
try {
|
|
60
|
+
body = await req.json();
|
|
61
|
+
} catch {
|
|
62
|
+
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = PayloadSchema.safeParse(body);
|
|
66
|
+
if (!result.success) {
|
|
67
|
+
return NextResponse.json(
|
|
68
|
+
{ error: "invalid searches payload", issues: result.error.issues },
|
|
69
|
+
{ status: 400 }
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const byId = new Map<string, SavedSearch>();
|
|
74
|
+
for (const s of result.data.searches) byId.set(s.id, s);
|
|
75
|
+
const deduped = Array.from(byId.values());
|
|
76
|
+
|
|
77
|
+
await setSetting(SETTINGS_KEY, JSON.stringify({ searches: deduped }));
|
|
78
|
+
return NextResponse.json({ searches: deduped });
|
|
79
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getSetting, setSetting } from "@/lib/settings/helpers";
|
|
3
|
+
import { SETTINGS_KEYS } from "@/lib/constants/settings";
|
|
4
|
+
|
|
5
|
+
export async function GET() {
|
|
6
|
+
const autoPromote = await getSetting(SETTINGS_KEYS.AUTO_PROMOTE_SKILLS);
|
|
7
|
+
return NextResponse.json({
|
|
8
|
+
autoPromoteSkills: autoPromote === "true",
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function POST(req: NextRequest) {
|
|
13
|
+
const body = await req.json();
|
|
14
|
+
|
|
15
|
+
if (body.autoPromoteSkills !== undefined) {
|
|
16
|
+
await setSetting(
|
|
17
|
+
SETTINGS_KEYS.AUTO_PROMOTE_SKILLS,
|
|
18
|
+
body.autoPromoteSkills ? "true" : "false"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const autoPromote = await getSetting(SETTINGS_KEYS.AUTO_PROMOTE_SKILLS);
|
|
23
|
+
return NextResponse.json({
|
|
24
|
+
autoPromoteSkills: autoPromote === "true",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -2,14 +2,15 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { db } from "@/lib/db";
|
|
3
3
|
import { tasks, projects } from "@/lib/db/schema";
|
|
4
4
|
import { eq, and } from "drizzle-orm";
|
|
5
|
-
import {
|
|
6
|
-
import { DEFAULT_AGENT_RUNTIME } from "@/lib/agents/runtime/catalog";
|
|
5
|
+
import { classifyTaskProfile } from "@/lib/agents/router";
|
|
7
6
|
import { validateRuntimeProfileAssignment } from "@/lib/agents/profiles/assignment-validation";
|
|
8
7
|
import {
|
|
9
8
|
BudgetLimitExceededError,
|
|
10
9
|
enforceTaskBudgetGuardrails,
|
|
11
10
|
} from "@/lib/settings/budget-guardrails";
|
|
12
11
|
import { ensureFreshScan } from "@/lib/environment/auto-scan";
|
|
12
|
+
import { resolveTaskExecutionTarget } from "@/lib/agents/runtime/execution-target";
|
|
13
|
+
import { startTaskExecution } from "@/lib/agents/task-dispatch";
|
|
13
14
|
|
|
14
15
|
export async function POST(
|
|
15
16
|
_req: NextRequest,
|
|
@@ -47,6 +48,7 @@ export async function POST(
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
const task = claimed[0];
|
|
51
|
+
let taskProfile = task.agentProfile;
|
|
50
52
|
|
|
51
53
|
// Auto-scan environment if the task's project has a workingDirectory
|
|
52
54
|
if (task.projectId) {
|
|
@@ -59,26 +61,64 @@ export async function POST(
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
let executionTarget;
|
|
65
|
+
try {
|
|
66
|
+
executionTarget = await resolveTaskExecutionTarget({
|
|
67
|
+
title: task.title,
|
|
68
|
+
description: task.description,
|
|
69
|
+
requestedRuntimeId: task.assignedAgent,
|
|
70
|
+
profileId: taskProfile,
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
db.update(tasks)
|
|
75
|
+
.set({
|
|
76
|
+
status: "failed",
|
|
77
|
+
result: message,
|
|
78
|
+
updatedAt: new Date(),
|
|
79
|
+
})
|
|
80
|
+
.where(eq(tasks.id, id))
|
|
81
|
+
.run();
|
|
82
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Auto-classify profile if none was set. Use the resolved runtime so the
|
|
86
|
+
// chosen profile is compatible with the runtime we will actually launch.
|
|
87
|
+
if (!taskProfile) {
|
|
64
88
|
const autoProfile = classifyTaskProfile(
|
|
65
89
|
task.title,
|
|
66
90
|
task.description,
|
|
67
|
-
|
|
91
|
+
executionTarget.effectiveRuntimeId
|
|
68
92
|
);
|
|
69
93
|
db.update(tasks)
|
|
70
94
|
.set({ agentProfile: autoProfile, updatedAt: new Date() })
|
|
71
95
|
.where(eq(tasks.id, id))
|
|
72
96
|
.run();
|
|
97
|
+
taskProfile = autoProfile;
|
|
98
|
+
try {
|
|
99
|
+
executionTarget = await resolveTaskExecutionTarget({
|
|
100
|
+
title: task.title,
|
|
101
|
+
description: task.description,
|
|
102
|
+
requestedRuntimeId: task.assignedAgent,
|
|
103
|
+
profileId: taskProfile,
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
107
|
+
db.update(tasks)
|
|
108
|
+
.set({
|
|
109
|
+
status: "failed",
|
|
110
|
+
result: message,
|
|
111
|
+
updatedAt: new Date(),
|
|
112
|
+
})
|
|
113
|
+
.where(eq(tasks.id, id))
|
|
114
|
+
.run();
|
|
115
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
116
|
+
}
|
|
73
117
|
}
|
|
74
118
|
|
|
75
119
|
const compatibilityError = validateRuntimeProfileAssignment({
|
|
76
|
-
profileId:
|
|
77
|
-
|
|
78
|
-
task.description,
|
|
79
|
-
task.assignedAgent ?? DEFAULT_AGENT_RUNTIME
|
|
80
|
-
),
|
|
81
|
-
runtimeId: task.assignedAgent,
|
|
120
|
+
profileId: taskProfile,
|
|
121
|
+
runtimeId: executionTarget.effectiveRuntimeId,
|
|
82
122
|
context: "Task profile",
|
|
83
123
|
});
|
|
84
124
|
if (compatibilityError) {
|
|
@@ -94,7 +134,7 @@ export async function POST(
|
|
|
94
134
|
}
|
|
95
135
|
|
|
96
136
|
// Fire-and-forget — task already marked as running
|
|
97
|
-
|
|
137
|
+
startTaskExecution(id, { requestedRuntimeId: task.assignedAgent }).catch(
|
|
98
138
|
(err) => console.error(`Task ${id} execution error:`, err)
|
|
99
139
|
);
|
|
100
140
|
|