@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.
Files changed (30) hide show
  1. package/README.md +19 -0
  2. package/package.json +3 -3
  3. package/scripts/ensure-sandbox-browser-image.mjs +12 -2
  4. package/src/app/api/knowledge/hygiene/route.ts +19 -1
  5. package/src/app/api/portability/export/route.test.ts +17 -0
  6. package/src/app/api/portability/export/route.ts +11 -2
  7. package/src/app/api/tasks/task-workspace-route.test.ts +112 -0
  8. package/src/components/tasks/task-card.tsx +49 -1
  9. package/src/components/tasks/task-sheet.tsx +173 -1
  10. package/src/components/ui/info-chip.tsx +3 -2
  11. package/src/features/tasks/queries.ts +2 -1
  12. package/src/lib/server/agents/delegation-advisory.test.ts +1 -0
  13. package/src/lib/server/agents/delegation-advisory.ts +10 -0
  14. package/src/lib/server/chat-execution/iteration-event-handler.ts +24 -8
  15. package/src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts +117 -0
  16. package/src/lib/server/chat-execution/reasoning-tag-scrubber.ts +219 -0
  17. package/src/lib/server/knowledge-sources.test.ts +45 -0
  18. package/src/lib/server/knowledge-sources.ts +33 -0
  19. package/src/lib/server/portability/export.ts +10 -0
  20. package/src/lib/server/session-tools/crud.ts +25 -2
  21. package/src/lib/server/session-tools/manage-tasks.test.ts +7 -2
  22. package/src/lib/server/tasks/task-execution-workspace.test.ts +117 -0
  23. package/src/lib/server/tasks/task-execution-workspace.ts +321 -0
  24. package/src/lib/server/tasks/task-route-service.ts +87 -9
  25. package/src/lib/server/tasks/task-service.test.ts +60 -2
  26. package/src/lib/server/tasks/task-service.ts +35 -0
  27. package/src/lib/tasks.ts +13 -5
  28. package/src/lib/validation/schemas.ts +19 -0
  29. package/src/types/misc.ts +1 -1
  30. 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 { applyTaskPatch, prepareTaskCreation } from '@/lib/server/tasks/task-service'
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
- return allTasks
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 = Date.now()
118
+ tasks[id].updatedAt = now
87
119
  } else {
88
120
  applyTaskPatch({
89
121
  task: tasks[id],
90
- patch: body,
91
- now: Date.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 = Date.now()
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 = Date.now()
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 = Date.now()
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 = Date.now()
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
- import { applyTaskPatch, prepareTaskCreation } from '@/lib/server/tasks/task-service'
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
+ })