Skip to content

Commit 2ac772f

Browse files
committed
feat: add task execution workspaces
1 parent 0414f0e commit 2ac772f

15 files changed

Lines changed: 978 additions & 22 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
399399

400400
## Releases
401401

402+
### v1.9.1 Highlights
403+
404+
Task execution workspace release: the first Paperclip-style work-control slice for task-scoped workspaces, preview handoffs, and liveness evidence.
405+
406+
- **Task-scoped execution workspaces.** Tasks can now provision a deterministic workspace under the SwarmClaw workspace root, preserving source cwd and project context while creating a task-local README for artifacts and handoffs.
407+
- **Preview and runtime metadata.** Tasks can carry preview links and runtime services, and the task board surfaces those links directly on task cards and sheets.
408+
- **Liveness snapshots.** Task list/read responses now compute blocked, queued, stale, retrying, ready, and terminal liveness states so operators can see why work is stopped or ready to run.
409+
- **Browser smoke coverage.** The browser smoke now creates a workspace-backed task and verifies the task board renders the workspace and liveness chips.
410+
402411
### v1.8.13 Highlights
403412

404413
Task retry and host execute hotfix for issues [#68](https://github.com/swarmclawai/swarmclaw/issues/68) and [#69](https://github.com/swarmclawai/swarmclaw/issues/69).

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@swarmclawai/swarmclaw",
3-
"version": "1.8.13",
3+
"version": "1.9.1",
44
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
55
"main": "electron-dist/main.js",
66
"license": "MIT",
@@ -87,7 +87,7 @@
8787
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
8888
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
8989
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
90-
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/session-tools/execute.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
90+
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/session-tools/execute.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
9191
"test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
9292
"test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
9393
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",

scripts/browser-e2e-smoke.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,33 @@ async function runBrowserSmoke(baseUrl: string): Promise<void> {
316316
anyText: ['Quality', 'Operator Quality Center', 'Eval Lab', 'Approval Desk'],
317317
})
318318

319+
const taskRes = await fetchWithTimeout(new URL('/api/tasks', baseUrl).toString(), {
320+
method: 'POST',
321+
headers: {
322+
'Content-Type': 'application/json',
323+
'X-Access-Key': auth.accessKey,
324+
...(auth.cookieHeader ? { Cookie: auth.cookieHeader } : {}),
325+
},
326+
body: JSON.stringify({
327+
title: 'E2E task workspace',
328+
description: 'Verify task-scoped workspace and preview metadata render in the task board.',
329+
provisionWorkspace: true,
330+
previewLinks: [{ label: 'Local preview', url: `${baseUrl}/tasks`, kind: 'web' }],
331+
runtimeServices: [{ name: 'Task board', status: 'running', url: `${baseUrl}/tasks` }],
332+
}),
333+
}, 10_000)
334+
if (!taskRes.ok) {
335+
throw new Error(`Could not create task workspace smoke record: ${taskRes.status} ${await taskRes.text().catch(() => '')}`)
336+
}
337+
338+
await waitForPageText(page, '/tasks?taskView=all', {
339+
anyText: ['E2E task workspace'],
340+
})
341+
await page.waitForFunction(() => {
342+
const text = document.body?.innerText || ''
343+
return text.includes('E2E task workspace') && text.includes('workspace') && text.includes('ready')
344+
}, { timeout: PAGE_TIMEOUT_MS })
345+
319346
if (pageErrors.length > 0) {
320347
throw new Error(`Browser page errors:\n${pageErrors.join('\n')}`)
321348
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import assert from 'node:assert/strict'
2+
import fs from 'node:fs'
3+
import os from 'node:os'
4+
import path from 'node:path'
5+
import { after, before, test } from 'node:test'
6+
7+
import type { BoardTask } from '@/types'
8+
9+
const originalEnv = {
10+
DATA_DIR: process.env.DATA_DIR,
11+
WORKSPACE_DIR: process.env.WORKSPACE_DIR,
12+
SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
13+
SWARMCLAW_DAEMON_AUTOSTART: process.env.SWARMCLAW_DAEMON_AUTOSTART,
14+
}
15+
16+
let tempDir = ''
17+
let putTask: typeof import('./[id]/route')['PUT']
18+
let getTasks: typeof import('./route')['GET']
19+
let storage: typeof import('@/lib/server/storage')
20+
21+
function routeParams(id: string) {
22+
return { params: Promise.resolve({ id }) }
23+
}
24+
25+
function seedTask(id: string, overrides: Partial<BoardTask> = {}) {
26+
const now = Date.now()
27+
storage.saveTasks({
28+
[id]: {
29+
id,
30+
title: 'Workspace Task',
31+
description: '',
32+
status: 'backlog',
33+
agentId: 'agent-1',
34+
createdAt: now,
35+
updatedAt: now,
36+
...overrides,
37+
} as BoardTask,
38+
})
39+
}
40+
41+
before(async () => {
42+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-task-route-'))
43+
process.env.DATA_DIR = path.join(tempDir, 'data')
44+
process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
45+
process.env.SWARMCLAW_BUILD_MODE = '1'
46+
process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
47+
storage = await import('@/lib/server/storage')
48+
putTask = (await import('./[id]/route')).PUT
49+
getTasks = (await import('./route')).GET
50+
})
51+
52+
after(() => {
53+
if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
54+
else process.env.DATA_DIR = originalEnv.DATA_DIR
55+
if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
56+
else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
57+
if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
58+
else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
59+
if (originalEnv.SWARMCLAW_DAEMON_AUTOSTART === undefined) delete process.env.SWARMCLAW_DAEMON_AUTOSTART
60+
else process.env.SWARMCLAW_DAEMON_AUTOSTART = originalEnv.SWARMCLAW_DAEMON_AUTOSTART
61+
fs.rmSync(tempDir, { recursive: true, force: true })
62+
})
63+
64+
test('PUT /api/tasks/:id provisions an execution workspace and preview links', async () => {
65+
seedTask('task-route-workspace', {
66+
title: 'Route Workspace',
67+
projectId: 'project-route',
68+
cwd: '/source/repo',
69+
})
70+
71+
const response = await putTask(new Request('http://local/api/tasks/task-route-workspace', {
72+
method: 'PUT',
73+
headers: { 'content-type': 'application/json' },
74+
body: JSON.stringify({
75+
provisionWorkspace: true,
76+
previewLinks: [{ label: 'Preview', url: 'http://127.0.0.1:3456', port: 3456 }],
77+
runtimeServices: [{ name: 'Next dev', status: 'planned', command: 'npm run dev', port: 3456 }],
78+
}),
79+
}), routeParams('task-route-workspace'))
80+
81+
assert.equal(response.status, 200)
82+
const body = await response.json() as BoardTask
83+
assert.equal(body.executionWorkspace?.sourceCwd, '/source/repo')
84+
assert.equal(body.previewLinks?.[0]?.url, 'http://127.0.0.1:3456')
85+
assert.equal(body.runtimeServices?.[0]?.name, 'Next dev')
86+
assert.equal(fs.existsSync(body.executionWorkspace?.path || ''), true)
87+
})
88+
89+
test('GET /api/tasks returns computed blocked liveness without persisting a task patch', async () => {
90+
seedTask('task-blocked', {
91+
title: 'Blocked Route Task',
92+
status: 'backlog',
93+
blockedBy: ['dep-route'],
94+
})
95+
const tasks = storage.loadTasks()
96+
tasks['dep-route'] = {
97+
id: 'dep-route',
98+
title: 'Dependency',
99+
description: '',
100+
status: 'running',
101+
agentId: 'agent-1',
102+
createdAt: Date.now(),
103+
updatedAt: Date.now(),
104+
} as BoardTask
105+
storage.saveTasks(tasks)
106+
107+
const response = await getTasks(new Request('http://local/api/tasks'))
108+
assert.equal(response.status, 200)
109+
const body = await response.json() as Record<string, BoardTask>
110+
assert.equal(body['task-blocked']?.liveness?.state, 'blocked')
111+
assert.deepEqual(body['task-blocked']?.liveness?.blockerTaskIds, ['dep-route'])
112+
})

src/components/tasks/task-card.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
'use client'
22

33
import { useState, useCallback, useEffect } from 'react'
4+
import { Activity, ExternalLink, FolderOpen } from 'lucide-react'
45
import { useAppStore } from '@/stores/use-app-store'
56
import { useNavigate } from '@/lib/app/navigation'
67
import { useUpdateTaskMutation } from '@/features/tasks/queries'
78
import { ConfirmDialog } from '@/components/shared/confirm-dialog'
89
import { AgentAvatar } from '@/components/agents/agent-avatar'
910
import { timeAgo } from '@/lib/time-format'
1011
import { InfoChip } from '@/components/ui/info-chip'
11-
import type { Agent, BoardTask, Project } from '@/types'
12+
import type { Agent, BoardTask, Project, TaskLivenessState } from '@/types'
1213

1314
interface TaskCardProps {
1415
task: BoardTask
@@ -21,6 +22,22 @@ interface TaskCardProps {
2122
index?: number
2223
}
2324

25+
function livenessTone(state: TaskLivenessState | undefined): 'neutral' | 'muted' | 'warning' | 'danger' | 'success' | 'info' | 'purple' | 'accent' {
26+
if (state === 'stale' || state === 'retrying') return 'warning'
27+
if (state === 'dead_lettered' || state === 'failed') return 'danger'
28+
if (state === 'blocked') return 'purple'
29+
if (state === 'completed') return 'success'
30+
if (state === 'running' || state === 'queued') return 'info'
31+
return 'muted'
32+
}
33+
34+
function livenessLabel(task: BoardTask): string | null {
35+
const state = task.liveness?.state
36+
if (!state) return null
37+
if (state === 'dead_lettered') return 'dead letter'
38+
return state.replace(/_/g, ' ')
39+
}
40+
2441
export function TaskCard({
2542
task,
2643
agents,
@@ -71,6 +88,8 @@ export function TaskCard({
7188
const prio = task.priority && priorityConfig[task.priority]
7289

7390
const isBlocked = Array.isArray(task.blockedBy) && task.blockedBy.length > 0
91+
const previewLink = task.previewLinks?.[0] || task.executionWorkspace?.previewLinks?.[0] || null
92+
const liveness = livenessLabel(task)
7493
const isOverdue = task.dueAt
7594
&& task.dueAt < now
7695
&& task.status !== 'completed'
@@ -278,6 +297,35 @@ export function TaskCard({
278297
</div>
279298
)}
280299

300+
{(liveness || task.executionWorkspace || previewLink) && (
301+
<div className="flex flex-wrap gap-1.5 mb-3">
302+
{liveness && (
303+
<InfoChip tone={livenessTone(task.liveness?.state)} title={task.liveness?.reason}>
304+
<Activity size={10} />
305+
{liveness}
306+
</InfoChip>
307+
)}
308+
{task.executionWorkspace && (
309+
<InfoChip tone="accent" title={task.executionWorkspace.path}>
310+
<FolderOpen size={10} />
311+
workspace
312+
</InfoChip>
313+
)}
314+
{previewLink && (
315+
<a
316+
href={previewLink.url}
317+
target="_blank"
318+
rel="noreferrer"
319+
onClick={(e) => e.stopPropagation()}
320+
className="inline-flex items-center gap-1.5 rounded-[7px] bg-emerald-500/10 px-2 py-1 text-[10px] font-600 text-emerald-300 hover:bg-emerald-500/15"
321+
>
322+
<ExternalLink size={10} />
323+
{previewLink.label || 'Preview'}
324+
</a>
325+
)}
326+
</div>
327+
)}
328+
281329
<div className="flex items-center gap-2 flex-wrap">
282330
{agent && (
283331
<span className="px-2 py-1 rounded-[6px] bg-accent-soft text-accent-bright text-[11px] font-600">

0 commit comments

Comments
 (0)