@swarmclawai/swarmclaw 1.8.13 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +3 -3
- package/scripts/ensure-sandbox-browser-image.mjs +12 -2
- package/src/app/api/knowledge/hygiene/route.ts +19 -1
- package/src/app/api/portability/export/route.test.ts +17 -0
- package/src/app/api/portability/export/route.ts +11 -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/agents/delegation-advisory.test.ts +1 -0
- package/src/lib/server/agents/delegation-advisory.ts +10 -0
- package/src/lib/server/chat-execution/iteration-event-handler.ts +24 -8
- package/src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts +117 -0
- package/src/lib/server/chat-execution/reasoning-tag-scrubber.ts +219 -0
- package/src/lib/server/knowledge-sources.test.ts +45 -0
- package/src/lib/server/knowledge-sources.ts +33 -0
- package/src/lib/server/portability/export.ts +10 -0
- package/src/lib/server/session-tools/crud.ts +25 -2
- package/src/lib/server/session-tools/manage-tasks.test.ts +7 -2
- 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 +87 -9
- package/src/lib/server/tasks/task-service.test.ts +60 -2
- package/src/lib/server/tasks/task-service.ts +35 -0
- package/src/lib/tasks.ts +13 -5
- package/src/lib/validation/schemas.ts +19 -0
- package/src/types/misc.ts +1 -1
- package/src/types/task.ts +62 -0
|
@@ -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,8 +22,17 @@ 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
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
applyTaskPatch,
|
|
33
|
+
prepareTaskCreation,
|
|
34
|
+
resolveAssignmentWorkflowStateTransition,
|
|
35
|
+
} from '@/lib/server/tasks/task-service'
|
|
27
36
|
import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
|
|
28
37
|
import { queueSwarmFeedTaskCompletionWake } from '@/lib/server/swarmfeed-runtime'
|
|
29
38
|
import { notify } from '@/lib/server/ws-hub'
|
|
@@ -60,7 +69,15 @@ export function prepareTasksForListing() {
|
|
|
60
69
|
validateCompletedTasksQueue()
|
|
61
70
|
recoverStalledRunningTasks()
|
|
62
71
|
const allTasks = loadTasks()
|
|
63
|
-
|
|
72
|
+
const listed: Record<string, BoardTask> = {}
|
|
73
|
+
const now = Date.now()
|
|
74
|
+
for (const [id, task] of Object.entries(allTasks)) {
|
|
75
|
+
listed[id] = {
|
|
76
|
+
...task,
|
|
77
|
+
liveness: computeTaskLiveness(task, allTasks, { now }),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return listed
|
|
64
81
|
}
|
|
65
82
|
|
|
66
83
|
export function updateTaskFromRoute(id: string, body: Record<string, unknown>): ServiceResult<BoardTask> {
|
|
@@ -69,6 +86,21 @@ export function updateTaskFromRoute(id: string, body: Record<string, unknown>):
|
|
|
69
86
|
if (!tasks[id]) return serviceFail(404, 'Task not found')
|
|
70
87
|
|
|
71
88
|
const prevStatus = tasks[id].status
|
|
89
|
+
const now = Date.now()
|
|
90
|
+
const shouldProvisionWorkspace = body.provisionWorkspace === true
|
|
91
|
+
const workspaceOptions: Pick<PrepareTaskExecutionWorkspaceOptions, 'previewLinks' | 'runtimeServices'> = {
|
|
92
|
+
previewLinks: Array.isArray(body.previewLinks)
|
|
93
|
+
? body.previewLinks as PrepareTaskExecutionWorkspaceOptions['previewLinks']
|
|
94
|
+
: undefined,
|
|
95
|
+
runtimeServices: Array.isArray(body.runtimeServices)
|
|
96
|
+
? body.runtimeServices as PrepareTaskExecutionWorkspaceOptions['runtimeServices']
|
|
97
|
+
: undefined,
|
|
98
|
+
}
|
|
99
|
+
const patchBody = { ...body }
|
|
100
|
+
delete patchBody.provisionWorkspace
|
|
101
|
+
delete patchBody.previewLinks
|
|
102
|
+
delete patchBody.runtimeServices
|
|
103
|
+
|
|
72
104
|
if (Array.isArray(body.blockedBy)) {
|
|
73
105
|
const dagResult = validateDag(tasks, id, body.blockedBy)
|
|
74
106
|
if (!dagResult.valid) {
|
|
@@ -83,12 +115,12 @@ export function updateTaskFromRoute(id: string, body: Record<string, unknown>):
|
|
|
83
115
|
}
|
|
84
116
|
if (!tasks[id].comments) tasks[id].comments = []
|
|
85
117
|
tasks[id].comments.push(appendedComment)
|
|
86
|
-
tasks[id].updatedAt =
|
|
118
|
+
tasks[id].updatedAt = now
|
|
87
119
|
} else {
|
|
88
120
|
applyTaskPatch({
|
|
89
121
|
task: tasks[id],
|
|
90
|
-
patch:
|
|
91
|
-
now
|
|
122
|
+
patch: patchBody,
|
|
123
|
+
now,
|
|
92
124
|
settings,
|
|
93
125
|
preserveCompletedAt: true,
|
|
94
126
|
clearProjectIdWhenNull: true,
|
|
@@ -103,22 +135,34 @@ export function updateTaskFromRoute(id: string, body: Record<string, unknown>):
|
|
|
103
135
|
if (oldParentId && oldParentId !== newParentId && tasks[oldParentId]) {
|
|
104
136
|
const oldSubs = Array.isArray(tasks[oldParentId].subtaskIds) ? tasks[oldParentId].subtaskIds : []
|
|
105
137
|
tasks[oldParentId].subtaskIds = oldSubs.filter((s: string) => s !== id)
|
|
106
|
-
tasks[oldParentId].updatedAt =
|
|
138
|
+
tasks[oldParentId].updatedAt = now
|
|
107
139
|
saveTask(oldParentId, tasks[oldParentId])
|
|
108
140
|
}
|
|
109
141
|
if (newParentId && tasks[newParentId]) {
|
|
110
142
|
const newSubs = Array.isArray(tasks[newParentId].subtaskIds) ? tasks[newParentId].subtaskIds : []
|
|
111
143
|
if (!newSubs.includes(id)) {
|
|
112
144
|
tasks[newParentId].subtaskIds = [...newSubs, id]
|
|
113
|
-
tasks[newParentId].updatedAt =
|
|
145
|
+
tasks[newParentId].updatedAt = now
|
|
114
146
|
saveTask(newParentId, tasks[newParentId])
|
|
115
147
|
}
|
|
116
148
|
}
|
|
117
149
|
tasks[id].parentTaskId = newParentId
|
|
118
150
|
}
|
|
119
151
|
|
|
152
|
+
if (shouldProvisionWorkspace || workspaceOptions.previewLinks || workspaceOptions.runtimeServices) {
|
|
153
|
+
Object.assign(tasks[id], prepareTaskExecutionWorkspace(tasks[id], {
|
|
154
|
+
now,
|
|
155
|
+
actor: 'user',
|
|
156
|
+
tasks,
|
|
157
|
+
...workspaceOptions,
|
|
158
|
+
}))
|
|
159
|
+
tasks[id].updatedAt = now
|
|
160
|
+
} else {
|
|
161
|
+
tasks[id].liveness = computeTaskLiveness(tasks[id], tasks, { now })
|
|
162
|
+
}
|
|
163
|
+
|
|
120
164
|
if (prevStatus !== 'archived' && tasks[id].status === 'archived') {
|
|
121
|
-
tasks[id].archivedAt =
|
|
165
|
+
tasks[id].archivedAt = now
|
|
122
166
|
}
|
|
123
167
|
|
|
124
168
|
saveTask(id, tasks[id])
|
|
@@ -180,7 +224,8 @@ export function updateTaskFromRoute(id: string, body: Record<string, unknown>):
|
|
|
180
224
|
const incompleteBlocker = blockers.find((bid: string) => tasks[bid] && tasks[bid].status !== 'completed')
|
|
181
225
|
if (incompleteBlocker) {
|
|
182
226
|
tasks[id].status = prevStatus
|
|
183
|
-
tasks[id].updatedAt =
|
|
227
|
+
tasks[id].updatedAt = now
|
|
228
|
+
tasks[id].liveness = computeTaskLiveness(tasks[id], tasks, { now })
|
|
184
229
|
saveTask(id, tasks[id])
|
|
185
230
|
return serviceFail(409, 'Cannot queue: blocked by incomplete tasks')
|
|
186
231
|
}
|
|
@@ -330,6 +375,26 @@ export function createTaskFromRoute(body: Record<string, unknown>): ServiceResul
|
|
|
330
375
|
}
|
|
331
376
|
}
|
|
332
377
|
|
|
378
|
+
if (
|
|
379
|
+
body.provisionWorkspace === true
|
|
380
|
+
|| Array.isArray(body.previewLinks)
|
|
381
|
+
|| Array.isArray(body.runtimeServices)
|
|
382
|
+
) {
|
|
383
|
+
Object.assign(task, prepareTaskExecutionWorkspace(task, {
|
|
384
|
+
now,
|
|
385
|
+
actor: 'user',
|
|
386
|
+
tasks,
|
|
387
|
+
previewLinks: Array.isArray(body.previewLinks)
|
|
388
|
+
? body.previewLinks as PrepareTaskExecutionWorkspaceOptions['previewLinks']
|
|
389
|
+
: undefined,
|
|
390
|
+
runtimeServices: Array.isArray(body.runtimeServices)
|
|
391
|
+
? body.runtimeServices as PrepareTaskExecutionWorkspaceOptions['runtimeServices']
|
|
392
|
+
: undefined,
|
|
393
|
+
}))
|
|
394
|
+
} else {
|
|
395
|
+
task.liveness = computeTaskLiveness(task, tasks, { now })
|
|
396
|
+
}
|
|
397
|
+
|
|
333
398
|
saveTask(id, task)
|
|
334
399
|
logActivity({ entityType: 'task', entityId: id, action: 'created', actor: 'user', summary: `Task created: "${task.title}"` })
|
|
335
400
|
pushMainLoopEventToMainSessions({
|
|
@@ -366,12 +431,25 @@ export function bulkUpdateTasksFromRoute(body: Record<string, unknown>): Service
|
|
|
366
431
|
}
|
|
367
432
|
}
|
|
368
433
|
if ('agentId' in body) {
|
|
434
|
+
const previousAgentId = tasks[id].agentId
|
|
435
|
+
const previousWorkflowStateId = tasks[id].workflowStateId || null
|
|
369
436
|
tasks[id].agentId = body.agentId === null ? '' : String(body.agentId)
|
|
437
|
+
const workflowTransition = resolveAssignmentWorkflowStateTransition({
|
|
438
|
+
previousAgentId,
|
|
439
|
+
nextAgentId: tasks[id].agentId,
|
|
440
|
+
previousWorkflowStateId,
|
|
441
|
+
explicitWorkflowState: Object.prototype.hasOwnProperty.call(body, 'workflowStateId'),
|
|
442
|
+
})
|
|
443
|
+
if (workflowTransition) tasks[id].workflowStateId = workflowTransition
|
|
370
444
|
}
|
|
371
445
|
if ('projectId' in body) {
|
|
372
446
|
if (body.projectId === null) delete tasks[id].projectId
|
|
373
447
|
else tasks[id].projectId = String(body.projectId)
|
|
374
448
|
}
|
|
449
|
+
if ('workflowStateId' in body) {
|
|
450
|
+
if (body.workflowStateId === null) delete tasks[id].workflowStateId
|
|
451
|
+
else tasks[id].workflowStateId = String(body.workflowStateId)
|
|
452
|
+
}
|
|
375
453
|
tasks[id].updatedAt = Date.now()
|
|
376
454
|
updated += 1
|
|
377
455
|
results.push(id)
|
|
@@ -3,8 +3,11 @@ import { describe, it } from 'node:test'
|
|
|
3
3
|
|
|
4
4
|
import { computeTaskFingerprint } from '@/lib/task-dedupe'
|
|
5
5
|
import type { BoardTask } from '@/types'
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
applyTaskPatch,
|
|
8
|
+
prepareTaskCreation,
|
|
9
|
+
resolveAssignmentWorkflowStateTransition,
|
|
10
|
+
} from '@/lib/server/tasks/task-service'
|
|
8
11
|
|
|
9
12
|
function makeTask(overrides: Partial<BoardTask> = {}): BoardTask {
|
|
10
13
|
return {
|
|
@@ -106,3 +109,58 @@ describe('task service helpers', () => {
|
|
|
106
109
|
assert.equal(task.status, 'queued')
|
|
107
110
|
})
|
|
108
111
|
})
|
|
112
|
+
|
|
113
|
+
describe('task-service assignment workflow transitions', () => {
|
|
114
|
+
it('moves newly assigned backlog workflow tasks to in_progress without queueing runtime work', () => {
|
|
115
|
+
const task = makeTask({ agentId: '', workflowStateId: 'backlog' })
|
|
116
|
+
applyTaskPatch({
|
|
117
|
+
task,
|
|
118
|
+
patch: { agentId: 'agent-builder' },
|
|
119
|
+
now: 100,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
assert.equal(task.agentId, 'agent-builder')
|
|
123
|
+
assert.equal(task.status, 'backlog')
|
|
124
|
+
assert.equal(task.workflowStateId, 'in_progress')
|
|
125
|
+
assert.equal(task.updatedAt, 100)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('preserves explicit workflow state patches', () => {
|
|
129
|
+
const task = makeTask({ workflowStateId: 'todo' })
|
|
130
|
+
applyTaskPatch({
|
|
131
|
+
task,
|
|
132
|
+
patch: { agentId: 'agent-builder', workflowStateId: 'needs_review' },
|
|
133
|
+
now: 100,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
assert.equal(task.workflowStateId, 'needs_review')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('seeds assigned task creation into the in_progress workflow lane', () => {
|
|
140
|
+
const prepared = prepareTaskCreation({
|
|
141
|
+
input: {
|
|
142
|
+
title: 'Build the client',
|
|
143
|
+
description: '',
|
|
144
|
+
agentId: 'builder',
|
|
145
|
+
},
|
|
146
|
+
tasks: {},
|
|
147
|
+
now: 200,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
assert.equal(prepared.ok, true)
|
|
151
|
+
if (prepared.ok) {
|
|
152
|
+
assert.equal(prepared.task.status, 'backlog')
|
|
153
|
+
assert.equal(prepared.task.workflowStateId, 'in_progress')
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('leaves already-started workflow states alone', () => {
|
|
158
|
+
const next = resolveAssignmentWorkflowStateTransition({
|
|
159
|
+
previousAgentId: '',
|
|
160
|
+
nextAgentId: 'agent-builder',
|
|
161
|
+
previousWorkflowStateId: 'needs_review',
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
assert.equal(next, null)
|
|
165
|
+
})
|
|
166
|
+
})
|