@swarmclawai/swarmclaw 1.5.55 → 1.5.57
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 +19 -0
- package/package.json +1 -1
- package/src/app/api/chatrooms/[id]/route.ts +13 -3
- package/src/app/api/chatrooms/refusal-policy/route.ts +43 -0
- package/src/app/api/config-versions/restore/route.ts +26 -0
- package/src/app/api/config-versions/route.ts +21 -0
- package/src/app/api/documents/[id]/route.ts +20 -15
- package/src/app/api/external-agents/[id]/route.ts +15 -2
- package/src/app/api/goals/[id]/route.ts +12 -2
- package/src/app/api/providers/[id]/route.ts +11 -1
- package/src/app/api/secrets/[id]/route.ts +12 -6
- package/src/app/api/secrets/route.ts +10 -5
- package/src/app/api/task-workflow-states/route.ts +69 -0
- package/src/app/api/tts/route.test.ts +50 -0
- package/src/app/api/tts/route.ts +4 -1
- package/src/app/api/tts/stream/route.ts +5 -1
- package/src/app/api/usage/by-code/route.ts +32 -0
- package/src/app/api/workspaces/active/route.ts +19 -0
- package/src/app/api/workspaces/route.ts +46 -0
- package/src/cli/index.js +44 -0
- package/src/cli/spec.js +39 -0
- package/src/lib/server/agents/agent-budget-hook.ts +29 -0
- package/src/lib/server/agents/agent-service.ts +16 -0
- package/src/lib/server/agents/main-agent-loop.ts +8 -0
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +2 -0
- package/src/lib/server/chat-execution/post-stream-finalization.ts +2 -0
- package/src/lib/server/chatrooms/chatroom-refusal.ts +108 -0
- package/src/lib/server/config-versions/config-version-repository.ts +116 -0
- package/src/lib/server/portability/export.ts +201 -5
- package/src/lib/server/portability/import.ts +214 -23
- package/src/lib/server/runtime/session-run-manager/enqueue.ts +8 -2
- package/src/lib/server/tasks/workflow-state-repository.ts +98 -0
- package/src/lib/server/usage/cost-attribution.ts +85 -0
- package/src/lib/server/usage/resolve-billing-codes.ts +22 -0
- package/src/lib/server/workspaces/workspace-registry.ts +143 -0
- package/src/lib/validation/schemas.test.ts +16 -0
- package/src/lib/validation/schemas.ts +97 -0
- package/src/types/config-version.ts +20 -0
- package/src/types/misc.ts +12 -0
- package/src/types/mission.ts +4 -0
- package/src/types/session.ts +2 -0
- package/src/types/task.ts +4 -0
- package/src/types/workflow-state.ts +41 -0
- package/src/types/workspace.ts +27 -0
package/README.md
CHANGED
|
@@ -399,6 +399,25 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.5.57 Highlights
|
|
403
|
+
|
|
404
|
+
This release closes the org-orchestration feature gap with Paperclip while keeping SwarmClaw's autonomous-assistant focus. Most additions are additive; nothing existing has changed shape.
|
|
405
|
+
|
|
406
|
+
- **Workspace templates: full export/import bundle.** `src/lib/server/portability/{export,import}.ts` now round-trips agents, skills, schedules, **connectors** (with secret scrubbing), **chatrooms**, **MCP servers**, **projects**, **goals**, and an extension manifest reference. Manifest version bumped to `2`; v1 bundles still import. Connectors and MCP servers re-import with credentials stripped — the response payload now lists which records `needCredentials` so the UI can prompt. ID remapping handles cross-references (chatrooms → agents, schedules → agents, goals → projects/agents).
|
|
407
|
+
- **Per-agent budget enforcement at enqueue.** New `src/lib/server/agents/agent-budget-hook.ts` mirrors the existing mission budget hook. When an agent has `budgetAction: 'block'` and any window (`hourlyBudget`/`dailyBudget`/`monthlyBudget`) is exhausted, autonomous enqueues now fail fast in `session-run-manager` instead of getting blocked deeper in the chat-turn pipeline. User-initiated chats still flow through (so users can talk to an agent that's hit its cap). Default `'warn'` behavior is unchanged.
|
|
408
|
+
- **Goal hierarchy ancestry through Mission.** `Mission.goalId` is a new optional field. When a session has a `missionId` and the bound mission has a `goalId`, `main-agent-loop.ts` now walks `mission → goal → parentGoal → …` so the full Initiative/Project/Goal ancestry flows into the agent system prompt — previously only direct session-level goals were resolved.
|
|
409
|
+
- **Billing codes / cost attribution.** `Mission`, `BoardTask`, `Session`, and `UsageRecord` accept an optional `billingCodes: string[]`. `resolveBillingCodesForSession` combines session + mission codes when usage is appended, and the new `GET /api/usage/by-code?codes=foo,bar&range=7d` endpoint rolls up cost per code (and per agent within each code). Lets users running multiple parallel projects answer "what did Project X cost?" across agents, missions, and ad-hoc chats.
|
|
410
|
+
- **Customizable task workflow states.** New `WorkflowState` collection (`src/lib/server/tasks/workflow-state-repository.ts`) stores team-defined states like "Needs Review" or "Blocked on PM" that are orthogonal to `BoardTaskStatus` lifecycle. `BoardTask.workflowStateId` references one of seven defaults (Triage / Backlog / Todo / In Progress / Needs Review / Done / Cancelled) or any custom state. CRUD via `GET|POST|DELETE /api/task-workflow-states`. Atomic checkout via `task-checkout.ts` was already in place.
|
|
411
|
+
- **Cross-agent delegation refusal policy.** `Chatroom.onRefusal` (`'reroute' | 'escalate' | 'human'`) and `Chatroom.escalationTargetAgentId` formalize what happens when a delegated agent declines work. `chatroom-refusal.ts` reroutes to another room member, escalates to a configured target, or surfaces a `human_loop` approval. Policy management at `POST /api/chatrooms/refusal-policy`; simulation at `PUT` for tests.
|
|
412
|
+
- **Configuration version history.** Every `updateAgent` call now snapshots the prior agent state into `config-versions.json` (capped at 50 versions per entity). `GET /api/config-versions?entityKind=agent&entityId=...` lists history; `POST /api/config-versions/restore` rolls back. Foundation for extending to extensions, connectors, MCP servers, chatrooms, and projects.
|
|
413
|
+
- **Multi-workspace scaffolding.** New `Workspace` registry with `GET|POST|PATCH|DELETE /api/workspaces` and `GET|POST /api/workspaces/active`. The default workspace seeds itself on first read; switching the active workspace persists to `workspace-registry.json`. **Note:** this is metadata only in v1.5.57 — actual data-dir forking per workspace is intentionally deferred (low-risk shipping).
|
|
414
|
+
- **CLI manifest expanded.** New top-level groups: `workspaces`, `workflow-states`, `config-versions`, `cost-attribution`, `chatroom-policy`. Run `swarmclaw workspaces list`, `swarmclaw cost-attribution by-code --query codes=client-a,range=30d`, `swarmclaw config-versions list --query entityKind=agent,entityId=...`, etc. CLI route-coverage test passes.
|
|
415
|
+
|
|
416
|
+
### v1.5.56 Highlights
|
|
417
|
+
|
|
418
|
+
- **Fix: TTS error responses are now proper JSON instead of a raw Buffer blob.** `POST /api/tts` and `POST /api/tts/stream` previously returned `500` with the error message wrapped in a `new NextResponse(string, ...)` that the CLI JSON-decoded into `{"type":"Buffer","data":[78,111,...]}`. Both routes now return `NextResponse.json({error}, {status: 500})`. Regression test added.
|
|
419
|
+
- **Zod-validated PUT/PATCH endpoints — hardening sweep.** Extends the v1.5.55 work (agents, tasks, webhooks) to close the same silent-corruption bug class on the remaining vulnerable routes: `PUT /api/secrets/:id`, `POST /api/secrets`, `PATCH /api/goals/:id`, `PUT /api/providers/:id`, `PUT /api/documents/:id`, `PUT /api/external-agents/:id`, and `PUT /api/chatrooms/:id`. Each route validates against a dedicated schema (`SecretUpdateSchema`, `SecretCreateSchema`, `GoalUpdateSchema`, `ProviderUpdateSchema`, `DocumentUpdateSchema`, `ExternalAgentUpdateSchema`, `ChatroomUpdateSchema`) in `src/lib/validation/schemas.ts`, then filters parsed data to the keys actually present in the raw body so Zod defaults can't overwrite untouched stored fields. Endpoints already doing per-field `typeof` guards (knowledge, gateways, projects) were left as-is.
|
|
420
|
+
|
|
402
421
|
### v1.5.55 Highlights
|
|
403
422
|
|
|
404
423
|
- **Fix: mission budget updates with decimal values no longer silently fail with a 400.** The mission UI's `numOrNull` parsed user input with `Number.parseFloat`, but the API requires `int()` for `maxTokens`, `maxToolCalls`, `maxWallclockSec`, and `maxTurns`. Typing `1000.5` returned a cryptic Zod error to the toast and the update was lost. Added `intOrNull` (rounds) in `mission-edit-sheet.tsx`, `mission-template-install-dialog.tsx`, and `app/missions/page.tsx`. `maxUsd` still accepts decimals.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.57",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ensureChatroomRoutingGuidance,
|
|
10
10
|
synthesizeRoutingGuidanceFromRules,
|
|
11
11
|
} from '@/lib/server/chatrooms/chatroom-routing'
|
|
12
|
+
import { ChatroomUpdateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
12
13
|
|
|
13
14
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
14
15
|
const { id } = await params
|
|
@@ -25,14 +26,23 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|
|
25
26
|
|
|
26
27
|
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
27
28
|
const { id } = await params
|
|
28
|
-
const { data:
|
|
29
|
+
const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
|
|
29
30
|
if (error) return error
|
|
31
|
+
const parsed = ChatroomUpdateSchema.safeParse(raw)
|
|
32
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
33
|
+
|
|
34
|
+
const rawKeys = new Set(Object.keys(raw ?? {}))
|
|
35
|
+
const body: Record<string, unknown> = {}
|
|
36
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
37
|
+
if (rawKeys.has(key)) body[key] = value
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
const chatrooms = loadChatrooms()
|
|
31
41
|
const chatroom = chatrooms[id]
|
|
32
42
|
if (!chatroom) return notFound()
|
|
33
43
|
|
|
34
|
-
if (body.name !== undefined) chatroom.name = body.name
|
|
35
|
-
if (body.description !== undefined) chatroom.description = body.description
|
|
44
|
+
if (body.name !== undefined) chatroom.name = body.name as string
|
|
45
|
+
if (body.description !== undefined) chatroom.description = body.description as string
|
|
36
46
|
if (body.chatMode !== undefined) {
|
|
37
47
|
chatroom.chatMode = body.chatMode === 'parallel' ? 'parallel' : 'sequential'
|
|
38
48
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
3
|
+
import { setChatroomRefusalPolicy, handleAgentRefusal } from '@/lib/server/chatrooms/chatroom-refusal'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
const VALID_POLICIES = new Set(['reroute', 'escalate', 'human'])
|
|
8
|
+
|
|
9
|
+
export async function POST(req: Request) {
|
|
10
|
+
const { data: body, error } = await safeParseBody(req)
|
|
11
|
+
if (error) return error
|
|
12
|
+
const chatroomId = typeof body?.chatroomId === 'string' ? body.chatroomId : null
|
|
13
|
+
const policy = typeof body?.policy === 'string' ? body.policy : null
|
|
14
|
+
if (!chatroomId) return NextResponse.json({ error: 'chatroomId required' }, { status: 400 })
|
|
15
|
+
if (!policy || !VALID_POLICIES.has(policy)) {
|
|
16
|
+
return NextResponse.json({ error: 'policy must be reroute|escalate|human' }, { status: 400 })
|
|
17
|
+
}
|
|
18
|
+
const escalationTargetAgentId = typeof body?.escalationTargetAgentId === 'string'
|
|
19
|
+
? body.escalationTargetAgentId
|
|
20
|
+
: null
|
|
21
|
+
const room = setChatroomRefusalPolicy(
|
|
22
|
+
chatroomId,
|
|
23
|
+
policy as 'reroute' | 'escalate' | 'human',
|
|
24
|
+
escalationTargetAgentId,
|
|
25
|
+
)
|
|
26
|
+
if (!room) return NextResponse.json({ error: 'chatroom not found' }, { status: 404 })
|
|
27
|
+
return NextResponse.json({ chatroom: room })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function PUT(req: Request) {
|
|
31
|
+
// Trigger a refusal handling decision (used by agent runtime + tests).
|
|
32
|
+
const { data: body, error } = await safeParseBody(req)
|
|
33
|
+
if (error) return error
|
|
34
|
+
const chatroomId = typeof body?.chatroomId === 'string' ? body.chatroomId : null
|
|
35
|
+
const refusingAgentId = typeof body?.refusingAgentId === 'string' ? body.refusingAgentId : null
|
|
36
|
+
const taskOrTopic = typeof body?.taskOrTopic === 'string' ? body.taskOrTopic : ''
|
|
37
|
+
const reason = typeof body?.reason === 'string' ? body.reason : 'unspecified'
|
|
38
|
+
if (!chatroomId || !refusingAgentId) {
|
|
39
|
+
return NextResponse.json({ error: 'chatroomId and refusingAgentId required' }, { status: 400 })
|
|
40
|
+
}
|
|
41
|
+
const decision = handleAgentRefusal({ chatroomId, refusingAgentId, taskOrTopic, reason })
|
|
42
|
+
return NextResponse.json({ decision })
|
|
43
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
3
|
+
import { getVersion } from '@/lib/server/config-versions/config-version-repository'
|
|
4
|
+
import { updateAgent } from '@/lib/server/agents/agent-service'
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
export async function POST(req: Request) {
|
|
9
|
+
const { data: body, error } = await safeParseBody(req)
|
|
10
|
+
if (error) return error
|
|
11
|
+
const versionId = typeof body?.versionId === 'string' ? body.versionId : null
|
|
12
|
+
if (!versionId) return NextResponse.json({ error: 'versionId required' }, { status: 400 })
|
|
13
|
+
|
|
14
|
+
const version = getVersion(versionId)
|
|
15
|
+
if (!version) return NextResponse.json({ error: 'version not found' }, { status: 404 })
|
|
16
|
+
|
|
17
|
+
if (version.entityKind === 'agent') {
|
|
18
|
+
const restored = updateAgent(version.entityId, version.snapshot)
|
|
19
|
+
if (!restored) return NextResponse.json({ error: 'agent not found' }, { status: 404 })
|
|
20
|
+
return NextResponse.json({ ok: true, restored: { kind: 'agent', id: version.entityId } })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return NextResponse.json({
|
|
24
|
+
error: `Restore not yet implemented for kind=${version.entityKind}`,
|
|
25
|
+
}, { status: 501 })
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { listVersionsForEntity } from '@/lib/server/config-versions/config-version-repository'
|
|
3
|
+
import type { VersionedEntityKind } from '@/types/config-version'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
const VALID_KINDS = new Set<VersionedEntityKind>([
|
|
8
|
+
'agent', 'extension', 'connector', 'mcp_server', 'chatroom', 'project',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
export async function GET(req: Request) {
|
|
12
|
+
const { searchParams } = new URL(req.url)
|
|
13
|
+
const kind = searchParams.get('entityKind') as VersionedEntityKind | null
|
|
14
|
+
const id = searchParams.get('entityId')
|
|
15
|
+
if (!kind || !VALID_KINDS.has(kind)) {
|
|
16
|
+
return NextResponse.json({ error: 'entityKind required (agent|extension|connector|mcp_server|chatroom|project)' }, { status: 400 })
|
|
17
|
+
}
|
|
18
|
+
if (!id) return NextResponse.json({ error: 'entityId required' }, { status: 400 })
|
|
19
|
+
const versions = listVersionsForEntity(kind, id)
|
|
20
|
+
return NextResponse.json({ versions })
|
|
21
|
+
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import crypto from 'crypto'
|
|
3
3
|
import { loadDocuments, saveDocuments, upsertDocumentRevision } from '@/lib/server/storage'
|
|
4
|
-
|
|
5
|
-
function normalizeObject(value: unknown): Record<string, unknown> {
|
|
6
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
7
|
-
return value as Record<string, unknown>
|
|
8
|
-
}
|
|
4
|
+
import { DocumentUpdateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
9
5
|
|
|
10
6
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
11
7
|
const { id } = await params
|
|
@@ -17,13 +13,22 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|
|
17
13
|
|
|
18
14
|
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
19
15
|
const { id } = await params
|
|
20
|
-
const
|
|
16
|
+
const raw = await req.json().catch(() => null)
|
|
17
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
18
|
+
return NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 })
|
|
19
|
+
}
|
|
20
|
+
const parsed = DocumentUpdateSchema.safeParse(raw)
|
|
21
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
22
|
+
|
|
23
|
+
const rawKeys = new Set(Object.keys(raw))
|
|
24
|
+
const body = parsed.data
|
|
25
|
+
|
|
21
26
|
const docs = loadDocuments()
|
|
22
27
|
const doc = docs[id]
|
|
23
28
|
if (!doc) return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
|
24
29
|
|
|
25
30
|
// Snapshot previous content as a revision before updating
|
|
26
|
-
if (body.content !== undefined && body.content !== doc.content) {
|
|
31
|
+
if (rawKeys.has('content') && body.content !== undefined && body.content !== doc.content) {
|
|
27
32
|
const prevVersion = typeof doc.currentVersion === 'number' ? doc.currentVersion : 0
|
|
28
33
|
const revisionId = crypto.randomBytes(8).toString('hex')
|
|
29
34
|
upsertDocumentRevision(revisionId, {
|
|
@@ -32,18 +37,18 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
32
37
|
version: prevVersion,
|
|
33
38
|
content: doc.content,
|
|
34
39
|
createdAt: Date.now(),
|
|
35
|
-
createdBy:
|
|
40
|
+
createdBy: body.createdBy ?? null,
|
|
36
41
|
})
|
|
37
42
|
doc.currentVersion = prevVersion + 1
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
if (body.title !== undefined) doc.title = body.title
|
|
41
|
-
if (
|
|
42
|
-
if (
|
|
43
|
-
if (body.content !== undefined) doc.content = body.content
|
|
44
|
-
if (body.method !== undefined) doc.method = body.method
|
|
45
|
-
if (body.metadata !== undefined) doc.metadata =
|
|
46
|
-
doc.textLength =
|
|
45
|
+
if (rawKeys.has('title') && body.title !== undefined) doc.title = body.title
|
|
46
|
+
if (rawKeys.has('fileName')) doc.fileName = body.fileName ?? null
|
|
47
|
+
if (rawKeys.has('sourcePath')) doc.sourcePath = body.sourcePath ?? null
|
|
48
|
+
if (rawKeys.has('content') && body.content !== undefined) doc.content = body.content
|
|
49
|
+
if (rawKeys.has('method') && body.method !== undefined) doc.method = body.method
|
|
50
|
+
if (rawKeys.has('metadata') && body.metadata !== undefined) doc.metadata = body.metadata
|
|
51
|
+
doc.textLength = rawKeys.has('textLength') && body.textLength !== undefined
|
|
47
52
|
? body.textLength
|
|
48
53
|
: String(doc.content || '').length
|
|
49
54
|
doc.updatedAt = Date.now()
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { loadExternalAgents, saveExternalAgents } from '@/lib/server/storage'
|
|
3
3
|
import { mutateItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
|
|
4
4
|
import { notify } from '@/lib/server/ws-hub'
|
|
5
|
+
import { ExternalAgentUpdateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
5
6
|
export const dynamic = 'force-dynamic'
|
|
6
7
|
|
|
7
8
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -9,12 +10,24 @@ const ops: CollectionOps<any> = { load: loadExternalAgents, save: saveExternalAg
|
|
|
9
10
|
|
|
10
11
|
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
11
12
|
const { id } = await params
|
|
12
|
-
const
|
|
13
|
+
const raw = await req.json().catch(() => null)
|
|
14
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
15
|
+
return NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 })
|
|
16
|
+
}
|
|
17
|
+
const parsed = ExternalAgentUpdateSchema.safeParse(raw)
|
|
18
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
19
|
+
|
|
20
|
+
const rawKeys = new Set(Object.keys(raw))
|
|
21
|
+
const body: Record<string, unknown> = {}
|
|
22
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
23
|
+
if (rawKeys.has(key)) body[key] = value
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
const now = Date.now()
|
|
14
27
|
const result = mutateItem(ops, id, (runtime) => {
|
|
15
28
|
const action = typeof body.action === 'string' ? body.action : ''
|
|
16
29
|
const nextMetadata = body.metadata && typeof body.metadata === 'object'
|
|
17
|
-
? { ...(runtime.metadata || {}), ...body.metadata }
|
|
30
|
+
? { ...(runtime.metadata || {}), ...(body.metadata as Record<string, unknown>) }
|
|
18
31
|
: runtime.metadata
|
|
19
32
|
|
|
20
33
|
const next = {
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
3
3
|
import { getGoalById, updateGoal, deleteGoal, getGoalChain } from '@/lib/server/goals/goal-service'
|
|
4
4
|
import { notFound } from '@/lib/server/collection-helpers'
|
|
5
|
+
import { GoalUpdateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
5
6
|
export const dynamic = 'force-dynamic'
|
|
6
7
|
|
|
7
8
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
@@ -14,9 +15,18 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|
|
14
15
|
|
|
15
16
|
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
16
17
|
const { id } = await params
|
|
17
|
-
const { data:
|
|
18
|
+
const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
|
|
18
19
|
if (error) return error
|
|
19
|
-
const
|
|
20
|
+
const parsed = GoalUpdateSchema.safeParse(raw)
|
|
21
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
22
|
+
|
|
23
|
+
const rawKeys = new Set(Object.keys(raw ?? {}))
|
|
24
|
+
const patch: Record<string, unknown> = {}
|
|
25
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
26
|
+
if (rawKeys.has(key)) patch[key] = value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const updated = updateGoal(id, patch)
|
|
20
30
|
if (!updated) return notFound()
|
|
21
31
|
return NextResponse.json(updated)
|
|
22
32
|
}
|
|
@@ -4,6 +4,7 @@ import { loadProviderConfigs, saveProviderConfigs } from '@/lib/server/storage'
|
|
|
4
4
|
import { mutateItem, deleteItem, notFound, badRequest, type CollectionOps } from '@/lib/server/collection-helpers'
|
|
5
5
|
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
6
6
|
import { notify } from '@/lib/server/ws-hub'
|
|
7
|
+
import { ProviderUpdateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
7
8
|
|
|
8
9
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
10
|
const ops: CollectionOps<any> = { load: loadProviderConfigs, save: saveProviderConfigs, topic: 'providers' }
|
|
@@ -18,8 +19,17 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|
|
18
19
|
|
|
19
20
|
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
20
21
|
const { id } = await params
|
|
21
|
-
const { data:
|
|
22
|
+
const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
|
|
22
23
|
if (error) return error
|
|
24
|
+
const parsed = ProviderUpdateSchema.safeParse(raw)
|
|
25
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
26
|
+
|
|
27
|
+
const rawKeys = new Set(Object.keys(raw ?? {}))
|
|
28
|
+
const body: Record<string, unknown> = {}
|
|
29
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
30
|
+
if (rawKeys.has(key)) body[key] = value
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
if (!ops.load()[id]) {
|
|
24
34
|
const builtin = PROVIDERS[id]
|
|
25
35
|
if (!builtin) return notFound()
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { loadSecrets, saveSecrets } from '@/lib/server/storage'
|
|
3
3
|
import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
|
|
4
4
|
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
5
|
+
import { SecretUpdateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
5
6
|
|
|
6
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
8
|
const ops: CollectionOps<any> = { load: loadSecrets, save: saveSecrets }
|
|
@@ -25,14 +26,19 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|
|
25
26
|
|
|
26
27
|
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
27
28
|
const { id } = await params
|
|
28
|
-
const { data:
|
|
29
|
+
const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
|
|
29
30
|
if (error) return error
|
|
31
|
+
const parsed = SecretUpdateSchema.safeParse(raw)
|
|
32
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
33
|
+
const rawKeys = new Set(Object.keys(raw ?? {}))
|
|
34
|
+
const body = parsed.data
|
|
35
|
+
|
|
30
36
|
const result = mutateItem(ops, id, (secret) => {
|
|
31
|
-
if (body.name !== undefined) secret.name = body.name
|
|
32
|
-
if (body.service !== undefined) secret.service = body.service
|
|
33
|
-
if (body.scope !== undefined) secret.scope = body.scope
|
|
34
|
-
if (body.agentIds !== undefined) secret.agentIds = body.agentIds
|
|
35
|
-
if (
|
|
37
|
+
if (rawKeys.has('name') && body.name !== undefined) secret.name = body.name
|
|
38
|
+
if (rawKeys.has('service') && body.service !== undefined) secret.service = body.service
|
|
39
|
+
if (rawKeys.has('scope') && body.scope !== undefined) secret.scope = body.scope
|
|
40
|
+
if (rawKeys.has('agentIds') && body.agentIds !== undefined) secret.agentIds = body.agentIds
|
|
41
|
+
if (rawKeys.has('projectId')) secret.projectId = body.projectId || undefined
|
|
36
42
|
secret.updatedAt = Date.now()
|
|
37
43
|
return secret
|
|
38
44
|
})
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { genId } from '@/lib/id'
|
|
3
3
|
import { loadSecrets, saveSecrets, encryptKey } from '@/lib/server/storage'
|
|
4
4
|
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
5
|
+
import { SecretCreateSchema, formatZodError } from '@/lib/validation/schemas'
|
|
5
6
|
export const dynamic = 'force-dynamic'
|
|
6
7
|
|
|
7
8
|
|
|
@@ -18,16 +19,20 @@ export async function GET(_req: Request) {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export async function POST(req: Request) {
|
|
21
|
-
const { data:
|
|
22
|
+
const { data: raw, error } = await safeParseBody<Record<string, unknown>>(req)
|
|
22
23
|
if (error) return error
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
24
|
+
const parsed = SecretCreateSchema.safeParse(raw)
|
|
25
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
26
|
+
const body = parsed.data
|
|
26
27
|
|
|
27
|
-
if (!body.value
|
|
28
|
+
if (!body.value.trim()) {
|
|
28
29
|
return NextResponse.json({ error: 'value is required' }, { status: 400 })
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
const id = genId()
|
|
33
|
+
const now = Date.now()
|
|
34
|
+
const secrets = loadSecrets()
|
|
35
|
+
|
|
31
36
|
secrets[id] = {
|
|
32
37
|
id,
|
|
33
38
|
name: body.name || 'Unnamed Secret',
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { genId } from '@/lib/id'
|
|
3
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
4
|
+
import {
|
|
5
|
+
deleteWorkflowState,
|
|
6
|
+
listWorkflowStates,
|
|
7
|
+
resetWorkflowStatesToDefaults,
|
|
8
|
+
upsertWorkflowState,
|
|
9
|
+
} from '@/lib/server/tasks/workflow-state-repository'
|
|
10
|
+
import type { WorkflowState, WorkflowStateCategory } from '@/types/workflow-state'
|
|
11
|
+
|
|
12
|
+
export const dynamic = 'force-dynamic'
|
|
13
|
+
|
|
14
|
+
const VALID_CATEGORIES = new Set<WorkflowStateCategory>([
|
|
15
|
+
'triage', 'backlog', 'unstarted', 'started', 'completed', 'cancelled',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
function asString(value: unknown): string | null {
|
|
19
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function GET(req: Request) {
|
|
23
|
+
const { searchParams } = new URL(req.url)
|
|
24
|
+
const projectId = searchParams.get('projectId')
|
|
25
|
+
const states = listWorkflowStates(
|
|
26
|
+
projectId ? { projectId } : undefined,
|
|
27
|
+
)
|
|
28
|
+
return NextResponse.json({ states })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function POST(req: Request) {
|
|
32
|
+
const { data: body, error } = await safeParseBody(req)
|
|
33
|
+
if (error) return error
|
|
34
|
+
const label = asString(body?.label)
|
|
35
|
+
const categoryRaw = asString(body?.category) as WorkflowStateCategory | null
|
|
36
|
+
if (!label) return NextResponse.json({ error: 'label is required' }, { status: 400 })
|
|
37
|
+
if (!categoryRaw || !VALID_CATEGORIES.has(categoryRaw)) {
|
|
38
|
+
return NextResponse.json({ error: 'category must be one of triage|backlog|unstarted|started|completed|cancelled' }, { status: 400 })
|
|
39
|
+
}
|
|
40
|
+
const id = asString(body?.id) || genId()
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
const state: WorkflowState = {
|
|
43
|
+
id,
|
|
44
|
+
label,
|
|
45
|
+
category: categoryRaw,
|
|
46
|
+
projectId: asString(body?.projectId),
|
|
47
|
+
color: asString(body?.color),
|
|
48
|
+
position: typeof body?.position === 'number' ? body.position : undefined,
|
|
49
|
+
autoArchiveAfterDays: typeof body?.autoArchiveAfterDays === 'number' ? body.autoArchiveAfterDays : null,
|
|
50
|
+
createdAt: now,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
}
|
|
53
|
+
const saved = upsertWorkflowState(state)
|
|
54
|
+
return NextResponse.json({ state: saved })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function DELETE(req: Request) {
|
|
58
|
+
const { searchParams } = new URL(req.url)
|
|
59
|
+
const id = searchParams.get('id')
|
|
60
|
+
if (id) {
|
|
61
|
+
const removed = deleteWorkflowState(id)
|
|
62
|
+
return NextResponse.json({ ok: removed })
|
|
63
|
+
}
|
|
64
|
+
if (searchParams.get('reset') === 'true') {
|
|
65
|
+
resetWorkflowStatesToDefaults()
|
|
66
|
+
return NextResponse.json({ ok: true, reset: true })
|
|
67
|
+
}
|
|
68
|
+
return NextResponse.json({ error: 'id or reset=true required' }, { status: 400 })
|
|
69
|
+
}
|
|
@@ -80,3 +80,53 @@ test('tts routes reject empty text with a validation error', () => {
|
|
|
80
80
|
assert.equal(output.streamPayload.error, 'Validation failed')
|
|
81
81
|
assert.deepEqual(output.streamPayload.issues, [{ path: 'text', message: 'No text provided' }])
|
|
82
82
|
})
|
|
83
|
+
|
|
84
|
+
test('tts routes return a JSON error when no ElevenLabs API key is configured', () => {
|
|
85
|
+
// Regression: both routes previously returned the raw error string via
|
|
86
|
+
// `new NextResponse(message, { status: 500 })`, which serialized to a
|
|
87
|
+
// `{"type":"Buffer","data":[...]}` blob on the CLI side because the response
|
|
88
|
+
// had no content-type. They must now return a proper JSON error body.
|
|
89
|
+
const output = runWithTempDataDir<{
|
|
90
|
+
ttsStatus: number
|
|
91
|
+
ttsContentType: string | null
|
|
92
|
+
ttsPayload: { error?: string }
|
|
93
|
+
streamStatus: number
|
|
94
|
+
streamContentType: string | null
|
|
95
|
+
streamPayload: { error?: string }
|
|
96
|
+
}>(`
|
|
97
|
+
delete process.env.ELEVENLABS_API_KEY
|
|
98
|
+
const ttsRouteMod = await import('./src/app/api/tts/route')
|
|
99
|
+
const ttsStreamRouteMod = await import('./src/app/api/tts/stream/route')
|
|
100
|
+
const ttsRoute = ttsRouteMod.default || ttsRouteMod
|
|
101
|
+
const ttsStreamRoute = ttsStreamRouteMod.default || ttsStreamRouteMod
|
|
102
|
+
|
|
103
|
+
const ttsResponse = await ttsRoute.POST(new Request('http://local/api/tts', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ text: 'hello' }),
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
const ttsStreamResponse = await ttsStreamRoute.POST(new Request('http://local/api/tts/stream', {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({ text: 'hello' }),
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
console.log(JSON.stringify({
|
|
116
|
+
ttsStatus: ttsResponse.status,
|
|
117
|
+
ttsContentType: ttsResponse.headers.get('content-type'),
|
|
118
|
+
ttsPayload: await ttsResponse.json(),
|
|
119
|
+
streamStatus: ttsStreamResponse.status,
|
|
120
|
+
streamContentType: ttsStreamResponse.headers.get('content-type'),
|
|
121
|
+
streamPayload: await ttsStreamResponse.json(),
|
|
122
|
+
}))
|
|
123
|
+
`, { prefix: 'swarmclaw-tts-route-' })
|
|
124
|
+
|
|
125
|
+
assert.equal(output.ttsStatus, 500)
|
|
126
|
+
assert.ok(output.ttsContentType?.includes('application/json'), `expected JSON content-type, got ${output.ttsContentType}`)
|
|
127
|
+
assert.match(output.ttsPayload.error ?? '', /ElevenLabs API key/i)
|
|
128
|
+
|
|
129
|
+
assert.equal(output.streamStatus, 500)
|
|
130
|
+
assert.ok(output.streamContentType?.includes('application/json'), `expected JSON content-type, got ${output.streamContentType}`)
|
|
131
|
+
assert.match(output.streamPayload.error ?? '', /ElevenLabs API key/i)
|
|
132
|
+
})
|
package/src/app/api/tts/route.ts
CHANGED
|
@@ -23,6 +23,9 @@ export async function POST(req: Request) {
|
|
|
23
23
|
},
|
|
24
24
|
})
|
|
25
25
|
} catch (err: unknown) {
|
|
26
|
-
return
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ error: explainElevenLabsError(err) },
|
|
28
|
+
{ status: 500 },
|
|
29
|
+
)
|
|
27
30
|
}
|
|
28
31
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
1
2
|
import { z } from 'zod'
|
|
2
3
|
|
|
3
4
|
import { explainElevenLabsError, requestElevenLabsMp3Stream } from '@/lib/server/elevenlabs'
|
|
@@ -22,6 +23,9 @@ export async function POST(req: Request) {
|
|
|
22
23
|
},
|
|
23
24
|
})
|
|
24
25
|
} catch (err: unknown) {
|
|
25
|
-
return
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ error: explainElevenLabsError(err) },
|
|
28
|
+
{ status: 500 },
|
|
29
|
+
)
|
|
26
30
|
}
|
|
27
31
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { listObservedBillingCodes, rollupCostByBillingCode } from '@/lib/server/usage/cost-attribution'
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic'
|
|
5
|
+
|
|
6
|
+
const RANGE_MS: Record<string, number> = {
|
|
7
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
8
|
+
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
9
|
+
'30d': 30 * 24 * 60 * 60 * 1000,
|
|
10
|
+
'all': Number.POSITIVE_INFINITY,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function GET(req: Request) {
|
|
14
|
+
const { searchParams } = new URL(req.url)
|
|
15
|
+
const codesParam = searchParams.get('codes')
|
|
16
|
+
const codes = codesParam
|
|
17
|
+
? codesParam.split(',').map((s) => s.trim()).filter(Boolean)
|
|
18
|
+
: undefined
|
|
19
|
+
const rangeParam = searchParams.get('range') ?? 'all'
|
|
20
|
+
const rangeMs = RANGE_MS[rangeParam] ?? Number.POSITIVE_INFINITY
|
|
21
|
+
const sinceMs = Number.isFinite(rangeMs) ? Date.now() - rangeMs : 0
|
|
22
|
+
|
|
23
|
+
const rollups = rollupCostByBillingCode({ codes, sinceMs })
|
|
24
|
+
const observed = listObservedBillingCodes()
|
|
25
|
+
|
|
26
|
+
return NextResponse.json({
|
|
27
|
+
range: rangeParam,
|
|
28
|
+
codes: codes ?? null,
|
|
29
|
+
rollups,
|
|
30
|
+
observedCodes: observed,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
3
|
+
import { getActiveWorkspace, setActiveWorkspace } from '@/lib/server/workspaces/workspace-registry'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
export async function GET() {
|
|
8
|
+
return NextResponse.json({ workspace: getActiveWorkspace() })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function POST(req: Request) {
|
|
12
|
+
const { data: body, error } = await safeParseBody(req)
|
|
13
|
+
if (error) return error
|
|
14
|
+
const id = typeof body?.id === 'string' ? body.id : null
|
|
15
|
+
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
|
|
16
|
+
const workspace = setActiveWorkspace(id)
|
|
17
|
+
if (!workspace) return NextResponse.json({ error: 'workspace not found' }, { status: 404 })
|
|
18
|
+
return NextResponse.json({ workspace })
|
|
19
|
+
}
|