@swarmclawai/swarmclaw 1.8.13 → 1.9.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 CHANGED
@@ -399,6 +399,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
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
+
402
411
  ### v1.8.13 Highlights
403
412
 
404
413
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.8.13",
3
+ "version": "1.9.1",
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",
@@ -87,7 +87,7 @@
87
87
  "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",
88
88
  "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",
89
89
  "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",
91
91
  "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",
92
92
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
93
93
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -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
+ })
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useState, useCallback, useEffect } from 'react'
4
+ import { Activity, ExternalLink, FolderOpen } from 'lucide-react'
4
5
  import { useAppStore } from '@/stores/use-app-store'
5
6
  import { useNavigate } from '@/lib/app/navigation'
6
7
  import { useUpdateTaskMutation } from '@/features/tasks/queries'
@@ -8,7 +9,7 @@ import { ConfirmDialog } from '@/components/shared/confirm-dialog'
8
9
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
10
  import { timeAgo } from '@/lib/time-format'
10
11
  import { InfoChip } from '@/components/ui/info-chip'
11
- import type { Agent, BoardTask, Project } from '@/types'
12
+ import type { Agent, BoardTask, Project, TaskLivenessState } from '@/types'
12
13
 
13
14
  interface TaskCardProps {
14
15
  task: BoardTask
@@ -21,6 +22,22 @@ interface TaskCardProps {
21
22
  index?: number
22
23
  }
23
24
 
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
+
24
41
  export function TaskCard({
25
42
  task,
26
43
  agents,
@@ -71,6 +88,8 @@ export function TaskCard({
71
88
  const prio = task.priority && priorityConfig[task.priority]
72
89
 
73
90
  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)
74
93
  const isOverdue = task.dueAt
75
94
  && task.dueAt < now
76
95
  && task.status !== 'completed'
@@ -278,6 +297,35 @@ export function TaskCard({
278
297
  </div>
279
298
  )}
280
299
 
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
+
281
329
  <div className="flex items-center gap-2 flex-wrap">
282
330
  {agent && (
283
331
  <span className="px-2 py-1 rounded-[6px] bg-accent-soft text-accent-bright text-[11px] font-600">
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { useRouter } from 'next/navigation'
5
+ import { Activity, ExternalLink, FolderOpen, PlayCircle } from 'lucide-react'
5
6
  import ReactMarkdown from 'react-markdown'
6
7
  import remarkGfm from 'remark-gfm'
7
8
  import { useAppStore } from '@/stores/use-app-store'
@@ -21,10 +22,11 @@ import { DirBrowser } from '@/components/shared/dir-browser'
21
22
  import { SheetFooter } from '@/components/shared/sheet-footer'
22
23
  import { inputClass } from '@/components/shared/form-styles'
23
24
  import { StructuredSessionLauncher } from '@/components/protocols/structured-session-launcher'
24
- import type { BoardTask, TaskComment, TaskQualityGateConfig } from '@/types'
25
+ import type { BoardTask, TaskComment, TaskLivenessState, TaskQualityGateConfig } from '@/types'
25
26
  import { dedup, errorMessage } from '@/lib/shared-utils'
26
27
  import { SectionLabel } from '@/components/shared/section-label'
27
28
  import { AgentAvatar } from '@/components/agents/agent-avatar'
29
+ import { InfoChip } from '@/components/ui/info-chip'
28
30
 
29
31
  function fmtTime(ts: number) {
30
32
  const d = new Date(ts)
@@ -44,6 +46,22 @@ function normalizeGateNumber(value: unknown, fallback: number, min: number, max:
44
46
  return Math.max(min, Math.min(max, Math.trunc(parsed)))
45
47
  }
46
48
 
49
+ function livenessTone(state?: TaskLivenessState): 'neutral' | 'muted' | 'warning' | 'danger' | 'success' | 'info' | 'purple' | 'accent' {
50
+ if (state === 'stale' || state === 'retrying') return 'warning'
51
+ if (state === 'dead_lettered' || state === 'failed') return 'danger'
52
+ if (state === 'blocked') return 'purple'
53
+ if (state === 'completed') return 'success'
54
+ if (state === 'running' || state === 'queued') return 'info'
55
+ return 'muted'
56
+ }
57
+
58
+ function livenessLabel(task: BoardTask): string {
59
+ const state = task.liveness?.state
60
+ if (!state) return 'unknown'
61
+ if (state === 'dead_lettered') return 'dead letter'
62
+ return state.replace(/_/g, ' ')
63
+ }
64
+
47
65
  export function TaskSheet() {
48
66
  const router = useRouter()
49
67
  const open = useAppStore((s) => s.taskSheetOpen)
@@ -85,6 +103,8 @@ export function TaskSheet() {
85
103
  const [qualityGateRequireVerification, setQualityGateRequireVerification] = useState(false)
86
104
  const [qualityGateRequireArtifact, setQualityGateRequireArtifact] = useState(false)
87
105
  const [qualityGateRequireReport, setQualityGateRequireReport] = useState(false)
106
+ const [provisionWorkspace, setProvisionWorkspace] = useState(false)
107
+ const [workspacePreparing, setWorkspacePreparing] = useState(false)
88
108
  const [structuredSessionOpen, setStructuredSessionOpen] = useState(false)
89
109
  const formInitRef = useRef<string | null>(null)
90
110
 
@@ -139,6 +159,7 @@ export function TaskSheet() {
139
159
  setQualityGateRequireVerification(gate?.requireVerification ?? defaultGateRequireVerification)
140
160
  setQualityGateRequireArtifact(gate?.requireArtifact ?? defaultGateRequireArtifact)
141
161
  setQualityGateRequireReport(gate?.requireReport ?? defaultGateRequireReport)
162
+ setProvisionWorkspace(false)
142
163
  formInitRef.current = initKey
143
164
  return
144
165
  }
@@ -163,6 +184,7 @@ export function TaskSheet() {
163
184
  setQualityGateRequireVerification(defaultGateRequireVerification)
164
185
  setQualityGateRequireArtifact(defaultGateRequireArtifact)
165
186
  setQualityGateRequireReport(defaultGateRequireReport)
187
+ setProvisionWorkspace(false)
166
188
  formInitRef.current = initKey
167
189
  }, [
168
190
  activeProjectFilter,
@@ -212,6 +234,7 @@ export function TaskSheet() {
212
234
  customFields: Object.keys(customFields).length > 0 ? customFields : undefined,
213
235
  priority: priority || undefined,
214
236
  qualityGate,
237
+ provisionWorkspace: !editing && provisionWorkspace ? true : undefined,
215
238
  } as Partial<BoardTask> & { title: string; description: string; agentId: string }
216
239
  try {
217
240
  if (editing) {
@@ -263,6 +286,19 @@ export function TaskSheet() {
263
286
  }
264
287
  }
265
288
 
289
+ const handlePrepareWorkspace = async () => {
290
+ if (!editing) return
291
+ setWorkspacePreparing(true)
292
+ try {
293
+ await updateTaskMutation.mutateAsync({ id: editing.id, patch: { provisionWorkspace: true } })
294
+ setDepError(null)
295
+ } catch (err: unknown) {
296
+ setDepError(errorMessage(err))
297
+ } finally {
298
+ setWorkspacePreparing(false)
299
+ }
300
+ }
301
+
266
302
  const handleUnarchive = async () => {
267
303
  if (editing) {
268
304
  await updateTaskMutation.mutateAsync({ id: editing.id, patch: { status: 'backlog' } })
@@ -307,6 +343,16 @@ export function TaskSheet() {
307
343
 
308
344
  const taskAgent = editing ? agents[editing.agentId] : null
309
345
  const taskProject = editing?.projectId ? projects[editing.projectId] : null
346
+ const previewLinks = editing
347
+ ? (editing.previewLinks && editing.previewLinks.length > 0
348
+ ? editing.previewLinks
349
+ : editing.executionWorkspace?.previewLinks || [])
350
+ : []
351
+ const runtimeServices = editing
352
+ ? (editing.runtimeServices && editing.runtimeServices.length > 0
353
+ ? editing.runtimeServices
354
+ : editing.executionWorkspace?.runtimeServices || [])
355
+ : []
310
356
 
311
357
  /* ───── View-only mode ───── */
312
358
  if (viewOnly && editing) {
@@ -385,6 +431,67 @@ export function TaskSheet() {
385
431
  </div>
386
432
  )}
387
433
 
434
+ <div className="mb-8">
435
+ <SectionLabel>Execution</SectionLabel>
436
+ <div className="rounded-[14px] border border-white/[0.06] bg-surface p-4 space-y-3">
437
+ <div className="flex flex-wrap items-center gap-2">
438
+ {editing.liveness && (
439
+ <InfoChip tone={livenessTone(editing.liveness.state)} title={editing.liveness.reason}>
440
+ <Activity size={12} />
441
+ {livenessLabel(editing)}
442
+ </InfoChip>
443
+ )}
444
+ {editing.executionWorkspace ? (
445
+ <InfoChip tone="accent" title={editing.executionWorkspace.path}>
446
+ <FolderOpen size={12} />
447
+ Workspace ready
448
+ </InfoChip>
449
+ ) : (
450
+ <InfoChip tone="muted">
451
+ <FolderOpen size={12} />
452
+ No workspace
453
+ </InfoChip>
454
+ )}
455
+ {runtimeServices.map((service) => (
456
+ <InfoChip key={service.id} tone={service.status === 'running' ? 'success' : service.status === 'failed' ? 'danger' : 'neutral'}>
457
+ <PlayCircle size={12} />
458
+ {service.name}: {service.status}
459
+ </InfoChip>
460
+ ))}
461
+ </div>
462
+ {editing.executionWorkspace?.path && (
463
+ <code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
464
+ )}
465
+ {previewLinks.length > 0 && (
466
+ <div className="flex flex-wrap gap-2">
467
+ {previewLinks.map((link) => (
468
+ <a
469
+ key={link.id}
470
+ href={link.url}
471
+ target="_blank"
472
+ rel="noreferrer"
473
+ className="inline-flex items-center gap-1.5 rounded-[8px] bg-emerald-500/10 px-2.5 py-1.5 text-[12px] font-600 text-emerald-300 hover:bg-emerald-500/15"
474
+ >
475
+ <ExternalLink size={12} />
476
+ {link.label || 'Preview'}
477
+ </a>
478
+ ))}
479
+ </div>
480
+ )}
481
+ {!editing.executionWorkspace && (
482
+ <button
483
+ onClick={handlePrepareWorkspace}
484
+ disabled={workspacePreparing}
485
+ className="inline-flex items-center gap-2 rounded-[10px] border border-accent-bright/20 bg-accent-bright/10 px-3 py-2 text-[12px] font-600 text-accent-bright hover:bg-accent-bright/14 disabled:opacity-50"
486
+ style={{ fontFamily: 'inherit' }}
487
+ >
488
+ <FolderOpen size={13} />
489
+ {workspacePreparing ? 'Preparing...' : 'Prepare Workspace'}
490
+ </button>
491
+ )}
492
+ </div>
493
+ </div>
494
+
388
495
  {/* Tags */}
389
496
  {editing.tags && editing.tags.length > 0 && (
390
497
  <div className="mb-8">
@@ -809,6 +916,71 @@ export function TaskSheet() {
809
916
  />
810
917
  </div>
811
918
 
919
+ <div className="mb-8">
920
+ <SectionLabel>Execution Workspace</SectionLabel>
921
+ {editing ? (
922
+ <div className="rounded-[14px] border border-white/[0.06] bg-surface p-4 space-y-3">
923
+ <div className="flex flex-wrap items-center gap-2">
924
+ {editing.liveness && (
925
+ <InfoChip tone={livenessTone(editing.liveness.state)} title={editing.liveness.reason}>
926
+ <Activity size={12} />
927
+ {livenessLabel(editing)}
928
+ </InfoChip>
929
+ )}
930
+ {editing.executionWorkspace ? (
931
+ <InfoChip tone="accent" title={editing.executionWorkspace.path}>
932
+ <FolderOpen size={12} />
933
+ Workspace ready
934
+ </InfoChip>
935
+ ) : (
936
+ <InfoChip tone="muted">
937
+ <FolderOpen size={12} />
938
+ No workspace
939
+ </InfoChip>
940
+ )}
941
+ </div>
942
+ {editing.executionWorkspace?.path && (
943
+ <code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
944
+ )}
945
+ {previewLinks.length > 0 && (
946
+ <div className="flex flex-wrap gap-2">
947
+ {previewLinks.map((link) => (
948
+ <a
949
+ key={link.id}
950
+ href={link.url}
951
+ target="_blank"
952
+ rel="noreferrer"
953
+ className="inline-flex items-center gap-1.5 rounded-[8px] bg-emerald-500/10 px-2.5 py-1.5 text-[12px] font-600 text-emerald-300 hover:bg-emerald-500/15"
954
+ >
955
+ <ExternalLink size={12} />
956
+ {link.label || 'Preview'}
957
+ </a>
958
+ ))}
959
+ </div>
960
+ )}
961
+ <button
962
+ onClick={handlePrepareWorkspace}
963
+ disabled={workspacePreparing}
964
+ className="inline-flex items-center gap-2 rounded-[10px] border border-accent-bright/20 bg-accent-bright/10 px-3 py-2 text-[12px] font-600 text-accent-bright hover:bg-accent-bright/14 disabled:opacity-50"
965
+ style={{ fontFamily: 'inherit' }}
966
+ >
967
+ <FolderOpen size={13} />
968
+ {workspacePreparing ? 'Preparing...' : editing.executionWorkspace ? 'Refresh Workspace' : 'Prepare Workspace'}
969
+ </button>
970
+ </div>
971
+ ) : (
972
+ <label className="flex items-center gap-2 rounded-[14px] border border-white/[0.06] bg-surface px-4 py-3 text-[13px] text-text-2">
973
+ <input
974
+ type="checkbox"
975
+ checked={provisionWorkspace}
976
+ onChange={(e) => setProvisionWorkspace(e.target.checked)}
977
+ className="h-4 w-4 rounded border-white/20 accent-accent"
978
+ />
979
+ Prepare a task-scoped workspace when this task is created
980
+ </label>
981
+ )}
982
+ </div>
983
+
812
984
  {/* Tags */}
813
985
  <div className="mb-8">
814
986
  <SectionLabel>Tags <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel>
@@ -30,11 +30,12 @@ const chipVariants = cva(
30
30
  interface InfoChipProps extends VariantProps<typeof chipVariants> {
31
31
  children: React.ReactNode
32
32
  className?: string
33
+ title?: string
33
34
  }
34
35
 
35
- export function InfoChip({ size, tone, children, className }: InfoChipProps) {
36
+ export function InfoChip({ size, tone, children, className, title }: InfoChipProps) {
36
37
  return (
37
- <span className={cn(chipVariants({ size, tone }), className)}>
38
+ <span title={title} className={cn(chipVariants({ size, tone }), className)}>
38
39
  {children}
39
40
  </span>
40
41
  )
@@ -8,6 +8,7 @@ import {
8
8
  updateTask,
9
9
  type GitHubIssueImportRequest,
10
10
  type GitHubIssueImportResult,
11
+ type TaskWriteInput,
11
12
  } from '@/lib/tasks'
12
13
  import type { BoardTask, BoardTaskStatus, TaskComment } from '@/types'
13
14
 
@@ -98,7 +99,7 @@ export function useCreateTaskMutation() {
98
99
  export function useUpdateTaskMutation() {
99
100
  const queryClient = useQueryClient()
100
101
  return useMutation({
101
- mutationFn: ({ id, patch }: { id: string; patch: Partial<BoardTask> }) => updateTask(id, patch),
102
+ mutationFn: ({ id, patch }: { id: string; patch: TaskWriteInput }) => updateTask(id, patch),
102
103
  onMutate: async ({ id, patch }) => {
103
104
  await queryClient.cancelQueries({ queryKey: taskQueryKeys.lists() })
104
105
  const snapshots = patchTaskCaches(queryClient, (current, includeArchived) => {
@@ -0,0 +1,117 @@
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, describe, it } 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
+ }
14
+
15
+ let tempDir = ''
16
+ let workspace: typeof import('@/lib/server/tasks/task-execution-workspace')
17
+
18
+ function makeTask(overrides: Partial<BoardTask> = {}): BoardTask {
19
+ return {
20
+ id: 'task-1',
21
+ title: 'Ship preview URLs',
22
+ description: 'Prepare an isolated task workspace.',
23
+ status: 'backlog',
24
+ agentId: 'agent-1',
25
+ createdAt: 1,
26
+ updatedAt: 1,
27
+ ...overrides,
28
+ } as BoardTask
29
+ }
30
+
31
+ before(async () => {
32
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-task-workspace-'))
33
+ process.env.DATA_DIR = path.join(tempDir, 'data')
34
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
35
+ process.env.SWARMCLAW_BUILD_MODE = '1'
36
+ workspace = await import('@/lib/server/tasks/task-execution-workspace')
37
+ })
38
+
39
+ after(() => {
40
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
41
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
42
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
43
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
44
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
45
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
46
+ fs.rmSync(tempDir, { recursive: true, force: true })
47
+ })
48
+
49
+ describe('task execution workspaces', () => {
50
+ it('provisions a deterministic task workspace with preview metadata', () => {
51
+ const task = makeTask({
52
+ id: 'task-alpha',
53
+ title: 'Launch QA / preview',
54
+ cwd: '/repo/source',
55
+ projectId: 'project-1',
56
+ })
57
+
58
+ const patch = workspace.prepareTaskExecutionWorkspace(task, {
59
+ now: 100,
60
+ actor: 'test',
61
+ previewLinks: [{ label: 'Local preview', url: 'http://127.0.0.1:3456', port: 3456 }],
62
+ runtimeServices: [{ name: 'Next dev', status: 'planned', command: 'npm run dev', port: 3456 }],
63
+ })
64
+
65
+ assert.match(patch.executionWorkspace.path, /project-1/)
66
+ assert.match(patch.executionWorkspace.path, /task-alpha-launch-qa-preview/)
67
+ assert.equal(fs.existsSync(patch.executionWorkspace.path), true)
68
+ assert.equal(fs.existsSync(patch.executionWorkspace.readmePath || ''), true)
69
+ assert.equal(patch.executionWorkspace.sourceCwd, '/repo/source')
70
+ assert.equal(patch.executionWorkspace.previewLinks[0]?.label, 'Local preview')
71
+ assert.equal(patch.previewLinks[0]?.url, 'http://127.0.0.1:3456')
72
+ assert.equal(patch.runtimeServices[0]?.status, 'planned')
73
+ })
74
+
75
+ it('deduplicates preview URLs and computes blocked, stale, and retrying liveness', () => {
76
+ const task = makeTask({
77
+ id: 'task-beta',
78
+ status: 'running',
79
+ startedAt: 10,
80
+ updatedAt: 10,
81
+ lastActivityAt: 10,
82
+ previewLinks: [{ id: 'old', label: 'Existing', url: 'http://localhost:3000', kind: 'web', addedAt: 5 }],
83
+ })
84
+
85
+ const patch = workspace.prepareTaskExecutionWorkspace(task, {
86
+ now: 100,
87
+ previewLinks: [
88
+ { label: 'Duplicate', url: 'http://localhost:3000' },
89
+ { label: 'Docs', url: 'http://localhost:3000/docs', kind: 'docs' },
90
+ ],
91
+ })
92
+
93
+ assert.equal(patch.previewLinks.length, 2)
94
+ assert.equal(patch.previewLinks[0]?.label, 'Existing')
95
+ assert.equal(patch.previewLinks[1]?.kind, 'docs')
96
+
97
+ const stale = workspace.computeTaskLiveness(task, {}, { now: 100, staleAfterMs: 50 })
98
+ assert.equal(stale.state, 'stale')
99
+ assert.match(stale.reason, /No activity/)
100
+
101
+ const blocked = workspace.computeTaskLiveness(makeTask({
102
+ status: 'queued',
103
+ blockedBy: ['dep-1'],
104
+ }), {
105
+ 'dep-1': makeTask({ id: 'dep-1', status: 'running' }),
106
+ }, { now: 100 })
107
+ assert.equal(blocked.state, 'blocked')
108
+ assert.deepEqual(blocked.blockerTaskIds, ['dep-1'])
109
+
110
+ const retrying = workspace.computeTaskLiveness(makeTask({
111
+ status: 'queued',
112
+ retryScheduledAt: 150,
113
+ }), {}, { now: 100 })
114
+ assert.equal(retrying.state, 'retrying')
115
+ assert.equal(retrying.nextWakeAt, 150)
116
+ })
117
+ })
@@ -0,0 +1,321 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+
4
+ import { WORKSPACE_DIR } from '@/lib/server/data-dir'
5
+ import type {
6
+ BoardTask,
7
+ TaskExecutionWorkspace,
8
+ TaskLivenessSnapshot,
9
+ TaskPreviewLink,
10
+ TaskRuntimeService,
11
+ } from '@/types'
12
+
13
+ const DEFAULT_STALE_RUNNING_MS = 30 * 60 * 1000
14
+ const MAX_PREVIEW_LINKS = 12
15
+ const MAX_RUNTIME_SERVICES = 12
16
+
17
+ type PreviewInput = Partial<Omit<TaskPreviewLink, 'id' | 'addedAt'>> & {
18
+ id?: unknown
19
+ label?: unknown
20
+ url?: unknown
21
+ kind?: unknown
22
+ port?: unknown
23
+ }
24
+
25
+ type RuntimeServiceInput = Partial<Omit<TaskRuntimeService, 'id' | 'updatedAt'>> & {
26
+ id?: unknown
27
+ name?: unknown
28
+ status?: unknown
29
+ command?: unknown
30
+ url?: unknown
31
+ port?: unknown
32
+ startedAt?: unknown
33
+ }
34
+
35
+ export interface PrepareTaskExecutionWorkspaceOptions {
36
+ now?: number
37
+ actor?: string | null
38
+ workspaceRoot?: string
39
+ previewLinks?: PreviewInput[]
40
+ runtimeServices?: RuntimeServiceInput[]
41
+ tasks?: Record<string, BoardTask>
42
+ }
43
+
44
+ export interface TaskExecutionWorkspacePatch {
45
+ executionWorkspace: TaskExecutionWorkspace
46
+ previewLinks: TaskPreviewLink[]
47
+ runtimeServices: TaskRuntimeService[]
48
+ liveness: TaskLivenessSnapshot
49
+ }
50
+
51
+ function compactText(value: unknown, maxLen: number): string {
52
+ if (typeof value !== 'string') return ''
53
+ const compact = value.replace(/\s+/g, ' ').trim()
54
+ return compact.slice(0, maxLen)
55
+ }
56
+
57
+ function stableIdFrom(value: string): string {
58
+ let hash = 5381
59
+ for (let i = 0; i < value.length; i += 1) {
60
+ hash = ((hash << 5) + hash) ^ value.charCodeAt(i)
61
+ }
62
+ return Math.abs(hash >>> 0).toString(36)
63
+ }
64
+
65
+ export function taskWorkspaceSlug(task: Pick<BoardTask, 'id' | 'title'>): string {
66
+ const raw = `${task.id} ${task.title || 'task'}`
67
+ let out = ''
68
+ let lastWasDash = false
69
+ for (const char of raw.toLowerCase()) {
70
+ const isAlpha = char >= 'a' && char <= 'z'
71
+ const isDigit = char >= '0' && char <= '9'
72
+ if (isAlpha || isDigit) {
73
+ out += char
74
+ lastWasDash = false
75
+ continue
76
+ }
77
+ if (!lastWasDash && out) {
78
+ out += '-'
79
+ lastWasDash = true
80
+ }
81
+ }
82
+ const trimmed = out.replace(/-+$/g, '')
83
+ return (trimmed || `task-${task.id}`).slice(0, 96)
84
+ }
85
+
86
+ function normalizePort(value: unknown): number | null {
87
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseInt(value, 10) : Number.NaN
88
+ if (!Number.isFinite(parsed)) return null
89
+ const port = Math.trunc(parsed)
90
+ return port > 0 && port < 65536 ? port : null
91
+ }
92
+
93
+ function normalizePreviewKind(value: unknown): TaskPreviewLink['kind'] {
94
+ return value === 'api' || value === 'docs' || value === 'custom' ? value : 'web'
95
+ }
96
+
97
+ export function normalizeTaskPreviewLinks(
98
+ existing: TaskPreviewLink[] | undefined,
99
+ incoming: PreviewInput[] | undefined,
100
+ now = Date.now(),
101
+ ): TaskPreviewLink[] {
102
+ const out: TaskPreviewLink[] = []
103
+ const seenUrls = new Set<string>()
104
+
105
+ const append = (link: PreviewInput | TaskPreviewLink) => {
106
+ const url = compactText(link.url, 2048)
107
+ if (!url || seenUrls.has(url)) return
108
+ seenUrls.add(url)
109
+ const label = compactText(link.label, 80) || 'Preview'
110
+ const port = normalizePort(link.port)
111
+ out.push({
112
+ id: compactText(link.id, 80) || `preview-${stableIdFrom(url)}`,
113
+ label,
114
+ url,
115
+ kind: normalizePreviewKind(link.kind),
116
+ port,
117
+ addedAt: typeof (link as TaskPreviewLink).addedAt === 'number' && Number.isFinite((link as TaskPreviewLink).addedAt)
118
+ ? (link as TaskPreviewLink).addedAt
119
+ : now,
120
+ })
121
+ }
122
+
123
+ for (const link of existing || []) append(link)
124
+ for (const link of incoming || []) append(link)
125
+ return out.slice(0, MAX_PREVIEW_LINKS)
126
+ }
127
+
128
+ function normalizeRuntimeStatus(value: unknown): TaskRuntimeService['status'] {
129
+ return value === 'running' || value === 'stopped' || value === 'failed' || value === 'unknown'
130
+ ? value
131
+ : 'planned'
132
+ }
133
+
134
+ export function normalizeTaskRuntimeServices(
135
+ existing: TaskRuntimeService[] | undefined,
136
+ incoming: RuntimeServiceInput[] | undefined,
137
+ now = Date.now(),
138
+ ): TaskRuntimeService[] {
139
+ const out: TaskRuntimeService[] = []
140
+ const seenKeys = new Set<string>()
141
+
142
+ const append = (service: RuntimeServiceInput | TaskRuntimeService) => {
143
+ const name = compactText(service.name, 100)
144
+ const url = compactText(service.url, 2048) || null
145
+ const port = normalizePort(service.port)
146
+ const command = compactText(service.command, 500) || null
147
+ const key = `${name || 'service'}:${url || ''}:${port || ''}`
148
+ if (seenKeys.has(key)) return
149
+ seenKeys.add(key)
150
+ out.push({
151
+ id: compactText(service.id, 80) || `service-${stableIdFrom(key)}`,
152
+ name: name || 'Runtime service',
153
+ status: normalizeRuntimeStatus(service.status),
154
+ command,
155
+ url,
156
+ port,
157
+ startedAt: typeof service.startedAt === 'number' && Number.isFinite(service.startedAt) ? service.startedAt : null,
158
+ updatedAt: typeof (service as TaskRuntimeService).updatedAt === 'number' && Number.isFinite((service as TaskRuntimeService).updatedAt)
159
+ ? (service as TaskRuntimeService).updatedAt
160
+ : now,
161
+ })
162
+ }
163
+
164
+ for (const service of existing || []) append(service)
165
+ for (const service of incoming || []) append(service)
166
+ return out.slice(0, MAX_RUNTIME_SERVICES)
167
+ }
168
+
169
+ function taskWorkspaceRoot(task: BoardTask, workspaceRoot: string): string {
170
+ if (task.projectId) return path.join(workspaceRoot, 'projects', task.projectId, 'task-workspaces')
171
+ return path.join(workspaceRoot, 'task-workspaces')
172
+ }
173
+
174
+ function writeWorkspaceReadme(task: BoardTask, workspacePath: string, now: number): string {
175
+ const readmePath = path.join(workspacePath, 'README.md')
176
+ const lines = [
177
+ `# ${task.title || 'Task Workspace'}`,
178
+ '',
179
+ `Task ID: ${task.id}`,
180
+ `Status: ${task.status}`,
181
+ `Prepared: ${new Date(now).toISOString()}`,
182
+ ]
183
+ if (task.projectId) lines.push(`Project ID: ${task.projectId}`)
184
+ if (task.cwd) lines.push(`Source cwd: ${task.cwd}`)
185
+ lines.push('', 'Use this directory for task-local notes, generated artifacts, and preview handoff files.')
186
+ fs.writeFileSync(readmePath, `${lines.join('\n')}\n`, 'utf8')
187
+ return readmePath
188
+ }
189
+
190
+ export function computeTaskLiveness(
191
+ task: BoardTask,
192
+ tasks: Record<string, BoardTask> = {},
193
+ options: { now?: number; staleAfterMs?: number } = {},
194
+ ): TaskLivenessSnapshot {
195
+ const now = options.now ?? Date.now()
196
+ const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_RUNNING_MS
197
+ const lastActivityAt = task.lastActivityAt ?? task.updatedAt ?? task.startedAt ?? task.createdAt ?? null
198
+ const blockerTaskIds = (task.blockedBy || [])
199
+ .filter((id) => {
200
+ const blocker = tasks[id]
201
+ return !blocker || blocker.status !== 'completed'
202
+ })
203
+
204
+ if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled' || task.status === 'archived') {
205
+ return {
206
+ state: task.status,
207
+ reason: `Task is ${task.status}.`,
208
+ checkedAt: now,
209
+ lastActivityAt,
210
+ }
211
+ }
212
+
213
+ if (task.deadLetteredAt) {
214
+ return {
215
+ state: 'dead_lettered',
216
+ reason: 'Retry budget was exhausted.',
217
+ checkedAt: now,
218
+ lastActivityAt,
219
+ }
220
+ }
221
+
222
+ if (blockerTaskIds.length > 0) {
223
+ return {
224
+ state: 'blocked',
225
+ reason: `Waiting on ${blockerTaskIds.length} blocker${blockerTaskIds.length === 1 ? '' : 's'}.`,
226
+ checkedAt: now,
227
+ lastActivityAt,
228
+ blockerTaskIds,
229
+ }
230
+ }
231
+
232
+ if (task.retryScheduledAt && task.retryScheduledAt > now) {
233
+ return {
234
+ state: 'retrying',
235
+ reason: 'Retry is scheduled.',
236
+ checkedAt: now,
237
+ lastActivityAt,
238
+ nextWakeAt: task.retryScheduledAt,
239
+ }
240
+ }
241
+
242
+ if (task.status === 'running') {
243
+ const staleMs = lastActivityAt ? now - lastActivityAt : null
244
+ if (staleMs !== null && staleMs > staleAfterMs) {
245
+ return {
246
+ state: 'stale',
247
+ reason: `No activity for ${Math.round(staleMs / 60000)} minute${Math.round(staleMs / 60000) === 1 ? '' : 's'}.`,
248
+ checkedAt: now,
249
+ lastActivityAt,
250
+ staleMs,
251
+ }
252
+ }
253
+ return {
254
+ state: 'running',
255
+ reason: 'Task is checked out and running.',
256
+ checkedAt: now,
257
+ lastActivityAt,
258
+ }
259
+ }
260
+
261
+ if (task.status === 'queued') {
262
+ return {
263
+ state: 'queued',
264
+ reason: 'Ready in the execution queue.',
265
+ checkedAt: now,
266
+ lastActivityAt,
267
+ }
268
+ }
269
+
270
+ return {
271
+ state: task.executionWorkspace ? 'ready' : 'not_started',
272
+ reason: task.executionWorkspace ? 'Workspace is prepared.' : 'No execution workspace has been prepared yet.',
273
+ checkedAt: now,
274
+ lastActivityAt,
275
+ }
276
+ }
277
+
278
+ export function prepareTaskExecutionWorkspace(
279
+ task: BoardTask,
280
+ options: PrepareTaskExecutionWorkspaceOptions = {},
281
+ ): TaskExecutionWorkspacePatch {
282
+ const now = options.now ?? Date.now()
283
+ const workspaceRoot = options.workspaceRoot || WORKSPACE_DIR
284
+ const existing = task.executionWorkspace || null
285
+ const workspacePath = existing?.path || path.join(taskWorkspaceRoot(task, workspaceRoot), taskWorkspaceSlug(task))
286
+ fs.mkdirSync(workspacePath, { recursive: true })
287
+ const readmePath = writeWorkspaceReadme(task, workspacePath, now)
288
+ const previewLinks = normalizeTaskPreviewLinks(
289
+ task.previewLinks || existing?.previewLinks,
290
+ options.previewLinks,
291
+ now,
292
+ )
293
+ const runtimeServices = normalizeTaskRuntimeServices(
294
+ task.runtimeServices || existing?.runtimeServices,
295
+ options.runtimeServices,
296
+ now,
297
+ )
298
+ const executionWorkspace: TaskExecutionWorkspace = {
299
+ path: workspacePath,
300
+ mode: task.projectId ? 'project' : 'task',
301
+ sourceCwd: task.cwd || existing?.sourceCwd || null,
302
+ projectId: task.projectId || existing?.projectId || null,
303
+ preparedAt: existing?.preparedAt || now,
304
+ preparedBy: options.actor || existing?.preparedBy || null,
305
+ readmePath,
306
+ previewLinks,
307
+ runtimeServices,
308
+ }
309
+ const taskForLiveness = {
310
+ ...task,
311
+ executionWorkspace,
312
+ previewLinks,
313
+ runtimeServices,
314
+ }
315
+ return {
316
+ executionWorkspace,
317
+ previewLinks,
318
+ runtimeServices,
319
+ liveness: computeTaskLiveness(taskForLiveness, options.tasks || {}, { now }),
320
+ }
321
+ }
@@ -22,6 +22,11 @@ import {
22
22
  saveTask,
23
23
  saveTaskMany,
24
24
  } from '@/lib/server/tasks/task-repository'
25
+ import {
26
+ computeTaskLiveness,
27
+ prepareTaskExecutionWorkspace,
28
+ type PrepareTaskExecutionWorkspaceOptions,
29
+ } from '@/lib/server/tasks/task-execution-workspace'
25
30
  import { resolveTaskAgentFromDescription } from '@/lib/server/tasks/task-mention'
26
31
  import { applyTaskPatch, prepareTaskCreation } from '@/lib/server/tasks/task-service'
27
32
  import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
@@ -60,7 +65,15 @@ export function prepareTasksForListing() {
60
65
  validateCompletedTasksQueue()
61
66
  recoverStalledRunningTasks()
62
67
  const allTasks = loadTasks()
63
- return allTasks
68
+ const listed: Record<string, BoardTask> = {}
69
+ const now = Date.now()
70
+ for (const [id, task] of Object.entries(allTasks)) {
71
+ listed[id] = {
72
+ ...task,
73
+ liveness: computeTaskLiveness(task, allTasks, { now }),
74
+ }
75
+ }
76
+ return listed
64
77
  }
65
78
 
66
79
  export function updateTaskFromRoute(id: string, body: Record<string, unknown>): ServiceResult<BoardTask> {
@@ -69,6 +82,21 @@ export function updateTaskFromRoute(id: string, body: Record<string, unknown>):
69
82
  if (!tasks[id]) return serviceFail(404, 'Task not found')
70
83
 
71
84
  const prevStatus = tasks[id].status
85
+ const now = Date.now()
86
+ const shouldProvisionWorkspace = body.provisionWorkspace === true
87
+ const workspaceOptions: Pick<PrepareTaskExecutionWorkspaceOptions, 'previewLinks' | 'runtimeServices'> = {
88
+ previewLinks: Array.isArray(body.previewLinks)
89
+ ? body.previewLinks as PrepareTaskExecutionWorkspaceOptions['previewLinks']
90
+ : undefined,
91
+ runtimeServices: Array.isArray(body.runtimeServices)
92
+ ? body.runtimeServices as PrepareTaskExecutionWorkspaceOptions['runtimeServices']
93
+ : undefined,
94
+ }
95
+ const patchBody = { ...body }
96
+ delete patchBody.provisionWorkspace
97
+ delete patchBody.previewLinks
98
+ delete patchBody.runtimeServices
99
+
72
100
  if (Array.isArray(body.blockedBy)) {
73
101
  const dagResult = validateDag(tasks, id, body.blockedBy)
74
102
  if (!dagResult.valid) {
@@ -83,12 +111,12 @@ export function updateTaskFromRoute(id: string, body: Record<string, unknown>):
83
111
  }
84
112
  if (!tasks[id].comments) tasks[id].comments = []
85
113
  tasks[id].comments.push(appendedComment)
86
- tasks[id].updatedAt = Date.now()
114
+ tasks[id].updatedAt = now
87
115
  } else {
88
116
  applyTaskPatch({
89
117
  task: tasks[id],
90
- patch: body,
91
- now: Date.now(),
118
+ patch: patchBody,
119
+ now,
92
120
  settings,
93
121
  preserveCompletedAt: true,
94
122
  clearProjectIdWhenNull: true,
@@ -103,22 +131,34 @@ export function updateTaskFromRoute(id: string, body: Record<string, unknown>):
103
131
  if (oldParentId && oldParentId !== newParentId && tasks[oldParentId]) {
104
132
  const oldSubs = Array.isArray(tasks[oldParentId].subtaskIds) ? tasks[oldParentId].subtaskIds : []
105
133
  tasks[oldParentId].subtaskIds = oldSubs.filter((s: string) => s !== id)
106
- tasks[oldParentId].updatedAt = Date.now()
134
+ tasks[oldParentId].updatedAt = now
107
135
  saveTask(oldParentId, tasks[oldParentId])
108
136
  }
109
137
  if (newParentId && tasks[newParentId]) {
110
138
  const newSubs = Array.isArray(tasks[newParentId].subtaskIds) ? tasks[newParentId].subtaskIds : []
111
139
  if (!newSubs.includes(id)) {
112
140
  tasks[newParentId].subtaskIds = [...newSubs, id]
113
- tasks[newParentId].updatedAt = Date.now()
141
+ tasks[newParentId].updatedAt = now
114
142
  saveTask(newParentId, tasks[newParentId])
115
143
  }
116
144
  }
117
145
  tasks[id].parentTaskId = newParentId
118
146
  }
119
147
 
148
+ if (shouldProvisionWorkspace || workspaceOptions.previewLinks || workspaceOptions.runtimeServices) {
149
+ Object.assign(tasks[id], prepareTaskExecutionWorkspace(tasks[id], {
150
+ now,
151
+ actor: 'user',
152
+ tasks,
153
+ ...workspaceOptions,
154
+ }))
155
+ tasks[id].updatedAt = now
156
+ } else {
157
+ tasks[id].liveness = computeTaskLiveness(tasks[id], tasks, { now })
158
+ }
159
+
120
160
  if (prevStatus !== 'archived' && tasks[id].status === 'archived') {
121
- tasks[id].archivedAt = Date.now()
161
+ tasks[id].archivedAt = now
122
162
  }
123
163
 
124
164
  saveTask(id, tasks[id])
@@ -180,7 +220,8 @@ export function updateTaskFromRoute(id: string, body: Record<string, unknown>):
180
220
  const incompleteBlocker = blockers.find((bid: string) => tasks[bid] && tasks[bid].status !== 'completed')
181
221
  if (incompleteBlocker) {
182
222
  tasks[id].status = prevStatus
183
- tasks[id].updatedAt = Date.now()
223
+ tasks[id].updatedAt = now
224
+ tasks[id].liveness = computeTaskLiveness(tasks[id], tasks, { now })
184
225
  saveTask(id, tasks[id])
185
226
  return serviceFail(409, 'Cannot queue: blocked by incomplete tasks')
186
227
  }
@@ -330,6 +371,26 @@ export function createTaskFromRoute(body: Record<string, unknown>): ServiceResul
330
371
  }
331
372
  }
332
373
 
374
+ if (
375
+ body.provisionWorkspace === true
376
+ || Array.isArray(body.previewLinks)
377
+ || Array.isArray(body.runtimeServices)
378
+ ) {
379
+ Object.assign(task, prepareTaskExecutionWorkspace(task, {
380
+ now,
381
+ actor: 'user',
382
+ tasks,
383
+ previewLinks: Array.isArray(body.previewLinks)
384
+ ? body.previewLinks as PrepareTaskExecutionWorkspaceOptions['previewLinks']
385
+ : undefined,
386
+ runtimeServices: Array.isArray(body.runtimeServices)
387
+ ? body.runtimeServices as PrepareTaskExecutionWorkspaceOptions['runtimeServices']
388
+ : undefined,
389
+ }))
390
+ } else {
391
+ task.liveness = computeTaskLiveness(task, tasks, { now })
392
+ }
393
+
333
394
  saveTask(id, task)
334
395
  logActivity({ entityType: 'task', entityId: id, action: 'created', actor: 'user', summary: `Task created: "${task.title}"` })
335
396
  pushMainLoopEventToMainSessions({
package/src/lib/tasks.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { api } from './app/api-client'
2
- import type { BoardTask } from '../types'
2
+ import type { BoardTask, TaskComment, TaskPreviewLink, TaskRuntimeService } from '../types'
3
3
 
4
4
  export const fetchTasks = (includeArchived = false) =>
5
5
  api<Record<string, BoardTask>>('GET', `/tasks${includeArchived ? '?includeArchived=true' : ''}`)
@@ -29,16 +29,24 @@ export interface GitHubIssueImportResult {
29
29
  skipped: GitHubIssueImportItem[]
30
30
  }
31
31
 
32
- export const createTask = (data: {
32
+ export type TaskWriteInput = Partial<BoardTask> & {
33
+ title?: string
34
+ description?: string
35
+ agentId?: string
36
+ provisionWorkspace?: boolean
37
+ previewLinks?: Array<Partial<TaskPreviewLink> & { url: string }>
38
+ runtimeServices?: Array<Partial<TaskRuntimeService>>
39
+ appendComment?: TaskComment
40
+ }
41
+
42
+ export const createTask = (data: TaskWriteInput & {
33
43
  title: string
34
44
  description: string
35
45
  agentId: string
36
- status?: string
37
- qualityGate?: BoardTask['qualityGate']
38
46
  }) =>
39
47
  api<BoardTask>('POST', '/tasks', data)
40
48
 
41
- export const updateTask = (id: string, data: Partial<BoardTask>) =>
49
+ export const updateTask = (id: string, data: TaskWriteInput) =>
42
50
  api<BoardTask>('PUT', `/tasks/${id}`, data)
43
51
 
44
52
  export const deleteTask = (id: string) =>
@@ -200,6 +200,23 @@ export const TaskCreateSchema = z.object({
200
200
  retryBackoffSec: z.number().optional(),
201
201
  priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
202
202
  dueAt: z.number().nullable().optional(),
203
+ provisionWorkspace: z.boolean().optional(),
204
+ previewLinks: z.array(z.object({
205
+ id: z.string().optional(),
206
+ label: z.string().optional(),
207
+ url: z.string().min(1),
208
+ kind: z.enum(['web', 'api', 'docs', 'custom']).optional(),
209
+ port: z.number().nullable().optional(),
210
+ })).optional(),
211
+ runtimeServices: z.array(z.object({
212
+ id: z.string().optional(),
213
+ name: z.string().optional(),
214
+ status: z.enum(['planned', 'running', 'stopped', 'failed', 'unknown']).optional(),
215
+ command: z.string().nullable().optional(),
216
+ url: z.string().nullable().optional(),
217
+ port: z.number().nullable().optional(),
218
+ startedAt: z.number().nullable().optional(),
219
+ })).optional(),
203
220
  qualityGate: z.object({
204
221
  enabled: z.boolean().optional(),
205
222
  minResultChars: z.number().optional(),
package/src/types/task.ts CHANGED
@@ -21,6 +21,64 @@ export interface TaskQualityGateConfig {
21
21
  requireReport?: boolean
22
22
  }
23
23
 
24
+ export type TaskExecutionWorkspaceMode = 'task' | 'project' | 'custom'
25
+
26
+ export interface TaskPreviewLink {
27
+ id: string
28
+ label: string
29
+ url: string
30
+ kind: 'web' | 'api' | 'docs' | 'custom'
31
+ port?: number | null
32
+ addedAt: number
33
+ }
34
+
35
+ export interface TaskRuntimeService {
36
+ id: string
37
+ name: string
38
+ status: 'planned' | 'running' | 'stopped' | 'failed' | 'unknown'
39
+ command?: string | null
40
+ url?: string | null
41
+ port?: number | null
42
+ startedAt?: number | null
43
+ updatedAt: number
44
+ }
45
+
46
+ export interface TaskExecutionWorkspace {
47
+ path: string
48
+ mode: TaskExecutionWorkspaceMode
49
+ sourceCwd?: string | null
50
+ projectId?: string | null
51
+ preparedAt: number
52
+ preparedBy?: string | null
53
+ readmePath?: string | null
54
+ previewLinks: TaskPreviewLink[]
55
+ runtimeServices: TaskRuntimeService[]
56
+ }
57
+
58
+ export type TaskLivenessState =
59
+ | 'not_started'
60
+ | 'ready'
61
+ | 'queued'
62
+ | 'blocked'
63
+ | 'running'
64
+ | 'stale'
65
+ | 'retrying'
66
+ | 'dead_lettered'
67
+ | 'completed'
68
+ | 'failed'
69
+ | 'cancelled'
70
+ | 'archived'
71
+
72
+ export interface TaskLivenessSnapshot {
73
+ state: TaskLivenessState
74
+ reason: string
75
+ checkedAt: number
76
+ lastActivityAt?: number | null
77
+ nextWakeAt?: number | null
78
+ blockerTaskIds?: string[]
79
+ staleMs?: number | null
80
+ }
81
+
24
82
  export interface BoardTask {
25
83
  id: string
26
84
  title: string
@@ -49,6 +107,10 @@ export interface BoardTask {
49
107
  type: 'image' | 'video' | 'pdf' | 'file'
50
108
  filename: string
51
109
  }>
110
+ executionWorkspace?: TaskExecutionWorkspace | null
111
+ previewLinks?: TaskPreviewLink[]
112
+ runtimeServices?: TaskRuntimeService[]
113
+ liveness?: TaskLivenessSnapshot | null
52
114
  comments?: TaskComment[]
53
115
  images?: string[]
54
116
  createdByAgentId?: string | null