@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 +9 -0
- package/package.json +2 -2
- package/src/app/api/tasks/task-workspace-route.test.ts +112 -0
- package/src/components/tasks/task-card.tsx +49 -1
- package/src/components/tasks/task-sheet.tsx +173 -1
- package/src/components/ui/info-chip.tsx +3 -2
- package/src/features/tasks/queries.ts +2 -1
- package/src/lib/server/tasks/task-execution-workspace.test.ts +117 -0
- package/src/lib/server/tasks/task-execution-workspace.ts +321 -0
- package/src/lib/server/tasks/task-route-service.ts +69 -8
- package/src/lib/tasks.ts +13 -5
- package/src/lib/validation/schemas.ts +17 -0
- package/src/types/task.ts +62 -0
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.
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
114
|
+
tasks[id].updatedAt = now
|
|
87
115
|
} else {
|
|
88
116
|
applyTaskPatch({
|
|
89
117
|
task: tasks[id],
|
|
90
|
-
patch:
|
|
91
|
-
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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:
|
|
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
|