@swarmclawai/swarmclaw 1.9.9 → 1.9.10
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/[id]/handoff/route.ts +73 -0
- package/src/app/api/tasks/handoffs/route.ts +50 -0
- package/src/app/api/tasks/task-workspace-route.test.ts +98 -0
- package/src/cli/index.js +3 -0
- package/src/cli/index.test.js +32 -0
- package/src/cli/spec.js +3 -0
- package/src/components/tasks/task-sheet.tsx +93 -1
- package/src/lib/server/tasks/task-handoff.test.ts +86 -0
- package/src/lib/server/tasks/task-handoff.ts +365 -0
- package/src/lib/tasks.ts +20 -1
- package/src/types/task.ts +91 -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.10 Highlights
|
|
403
|
+
|
|
404
|
+
Task handoff release: operators can package task state, readiness, workspace context, dependencies, outputs, and resume handles into a shareable packet before continuing work.
|
|
405
|
+
|
|
406
|
+
- **Task handoff packets.** `GET /api/tasks/:id/handoff` returns a structured packet with owner, liveness, workspace, runtime links, dependencies, quality checks, outputs, run summary, and recommended actions.
|
|
407
|
+
- **Workspace snapshots.** `POST /api/tasks/:id/handoff` prepares a workspace when needed and writes `handoff.md` plus `handoff.json` beside the task context files.
|
|
408
|
+
- **Board-level triage.** `GET /api/tasks/handoffs` lists readiness packets with ready, needs-attention, and blocked counts so operators can scan handoff risk across the board.
|
|
409
|
+
- **CLI and UI access.** `swarmclaw tasks handoff`, `swarmclaw tasks handoff-save`, and `swarmclaw tasks handoffs` expose the workflow for scripts, while the task sheet can copy, open, or save packets.
|
|
410
|
+
|
|
402
411
|
### v1.9.9 Highlights
|
|
403
412
|
|
|
404
413
|
Schedule revision timeline release: schedule edits, lifecycle changes, and run evidence now stay inspectable from UI, API, and CLI surfaces.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.10",
|
|
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/ensure-sandbox-browser-image.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/agents/delegation-advisory.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/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.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/chat-execution/reasoning-tag-scrubber.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/schedules/schedule-history.test.ts src/lib/quality/release-readiness.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/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.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/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/schedule-history-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/agents/delegation-advisory.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/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.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/chat-execution/reasoning-tag-scrubber.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/schedules/schedule-history.test.ts src/lib/quality/release-readiness.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/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.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/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/schedule-history-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,73 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { buildTaskHandoffPacket, formatTaskHandoffMarkdown } from '@/lib/server/tasks/task-handoff'
|
|
5
|
+
import { prepareTaskExecutionWorkspace } from '@/lib/server/tasks/task-execution-workspace'
|
|
6
|
+
import { loadTasks, saveTask } from '@/lib/server/tasks/task-repository'
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic'
|
|
9
|
+
|
|
10
|
+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
11
|
+
const { id } = await params
|
|
12
|
+
const tasks = loadTasks()
|
|
13
|
+
const task = tasks[id]
|
|
14
|
+
if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 })
|
|
15
|
+
|
|
16
|
+
const packet = buildTaskHandoffPacket(task, tasks)
|
|
17
|
+
const { searchParams } = new URL(req.url)
|
|
18
|
+
if (searchParams.get('format') === 'markdown') {
|
|
19
|
+
return new Response(formatTaskHandoffMarkdown(packet), {
|
|
20
|
+
headers: {
|
|
21
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return NextResponse.json(packet)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
30
|
+
const { id } = await params
|
|
31
|
+
const tasks = loadTasks()
|
|
32
|
+
const task = tasks[id]
|
|
33
|
+
if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 })
|
|
34
|
+
|
|
35
|
+
let body: Record<string, unknown> = {}
|
|
36
|
+
try {
|
|
37
|
+
body = await req.json() as Record<string, unknown>
|
|
38
|
+
} catch {
|
|
39
|
+
body = {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!task.executionWorkspace || body.prepareWorkspace !== false) {
|
|
43
|
+
Object.assign(task, prepareTaskExecutionWorkspace(task, {
|
|
44
|
+
now: Date.now(),
|
|
45
|
+
actor: 'user',
|
|
46
|
+
tasks,
|
|
47
|
+
}))
|
|
48
|
+
task.updatedAt = Date.now()
|
|
49
|
+
tasks[id] = task
|
|
50
|
+
saveTask(id, task)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const workspacePath = task.executionWorkspace?.path
|
|
54
|
+
if (!workspacePath) {
|
|
55
|
+
return NextResponse.json({ error: 'Task workspace is not available' }, { status: 409 })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fs.mkdirSync(workspacePath, { recursive: true })
|
|
59
|
+
const packet = buildTaskHandoffPacket(task, tasks)
|
|
60
|
+
const markdown = formatTaskHandoffMarkdown(packet)
|
|
61
|
+
const markdownPath = path.join(workspacePath, 'handoff.md')
|
|
62
|
+
const jsonPath = path.join(workspacePath, 'handoff.json')
|
|
63
|
+
fs.writeFileSync(markdownPath, markdown, 'utf8')
|
|
64
|
+
fs.writeFileSync(jsonPath, `${JSON.stringify(packet, null, 2)}\n`, 'utf8')
|
|
65
|
+
|
|
66
|
+
return NextResponse.json({
|
|
67
|
+
packet,
|
|
68
|
+
files: {
|
|
69
|
+
markdownPath,
|
|
70
|
+
jsonPath,
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { buildTaskHandoffPacket } from '@/lib/server/tasks/task-handoff'
|
|
3
|
+
import { loadTasks } from '@/lib/server/tasks/task-repository'
|
|
4
|
+
import type { TaskHandoffReadinessStatus } from '@/types'
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
const READINESS_STATUSES: TaskHandoffReadinessStatus[] = ['ready', 'needs_attention', 'blocked']
|
|
9
|
+
|
|
10
|
+
function normalizeLimit(value: string | null): number {
|
|
11
|
+
const parsed = value ? Number.parseInt(value, 10) : 50
|
|
12
|
+
if (!Number.isFinite(parsed)) return 50
|
|
13
|
+
return Math.max(1, Math.min(200, Math.trunc(parsed)))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function GET(req: Request) {
|
|
17
|
+
const { searchParams } = new URL(req.url)
|
|
18
|
+
const status = searchParams.get('status') as TaskHandoffReadinessStatus | null
|
|
19
|
+
const includeArchived = searchParams.get('includeArchived') === 'true'
|
|
20
|
+
const limit = normalizeLimit(searchParams.get('limit'))
|
|
21
|
+
const now = Date.now()
|
|
22
|
+
const tasks = loadTasks()
|
|
23
|
+
const packets = Object.values(tasks)
|
|
24
|
+
.filter((task) => includeArchived || task.status !== 'archived')
|
|
25
|
+
.map((task) => buildTaskHandoffPacket(task, tasks, { now, runBrief: null }))
|
|
26
|
+
.sort((left, right) => {
|
|
27
|
+
const statusRank: Record<TaskHandoffReadinessStatus, number> = {
|
|
28
|
+
blocked: 0,
|
|
29
|
+
needs_attention: 1,
|
|
30
|
+
ready: 2,
|
|
31
|
+
}
|
|
32
|
+
return statusRank[left.readiness.status] - statusRank[right.readiness.status] || right.updatedAt - left.updatedAt
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const filtered = status && READINESS_STATUSES.includes(status)
|
|
36
|
+
? packets.filter((packet) => packet.readiness.status === status)
|
|
37
|
+
: packets
|
|
38
|
+
const counts: Record<TaskHandoffReadinessStatus, number> = {
|
|
39
|
+
ready: 0,
|
|
40
|
+
needs_attention: 0,
|
|
41
|
+
blocked: 0,
|
|
42
|
+
}
|
|
43
|
+
for (const packet of packets) counts[packet.readiness.status] += 1
|
|
44
|
+
|
|
45
|
+
return NextResponse.json({
|
|
46
|
+
generatedAt: now,
|
|
47
|
+
counts,
|
|
48
|
+
items: filtered.slice(0, limit),
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -15,6 +15,9 @@ const originalEnv = {
|
|
|
15
15
|
|
|
16
16
|
let tempDir = ''
|
|
17
17
|
let putTask: typeof import('./[id]/route')['PUT']
|
|
18
|
+
let getTaskHandoff: typeof import('./[id]/handoff/route')['GET']
|
|
19
|
+
let postTaskHandoff: typeof import('./[id]/handoff/route')['POST']
|
|
20
|
+
let getTaskHandoffs: typeof import('./handoffs/route')['GET']
|
|
18
21
|
let getTasks: typeof import('./route')['GET']
|
|
19
22
|
let storage: typeof import('@/lib/server/storage')
|
|
20
23
|
|
|
@@ -46,6 +49,10 @@ before(async () => {
|
|
|
46
49
|
process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
|
|
47
50
|
storage = await import('@/lib/server/storage')
|
|
48
51
|
putTask = (await import('./[id]/route')).PUT
|
|
52
|
+
const handoffRoute = await import('./[id]/handoff/route')
|
|
53
|
+
getTaskHandoff = handoffRoute.GET
|
|
54
|
+
postTaskHandoff = handoffRoute.POST
|
|
55
|
+
getTaskHandoffs = (await import('./handoffs/route')).GET
|
|
49
56
|
getTasks = (await import('./route')).GET
|
|
50
57
|
})
|
|
51
58
|
|
|
@@ -114,3 +121,94 @@ test('GET /api/tasks returns computed blocked liveness without persisting a task
|
|
|
114
121
|
assert.equal(body['task-blocked']?.liveness?.state, 'blocked')
|
|
115
122
|
assert.deepEqual(body['task-blocked']?.liveness?.blockerTaskIds, ['dep-route'])
|
|
116
123
|
})
|
|
124
|
+
|
|
125
|
+
test('GET /api/tasks/:id/handoff returns readiness and markdown packets', async () => {
|
|
126
|
+
seedTask('task-handoff', {
|
|
127
|
+
title: 'Handoff Route Task',
|
|
128
|
+
description: 'Prepare a packet.',
|
|
129
|
+
blockedBy: ['dep-handoff'],
|
|
130
|
+
qualityGate: {
|
|
131
|
+
enabled: true,
|
|
132
|
+
minResultChars: 50,
|
|
133
|
+
minEvidenceItems: 1,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
const tasks = storage.loadTasks()
|
|
137
|
+
tasks['dep-handoff'] = {
|
|
138
|
+
id: 'dep-handoff',
|
|
139
|
+
title: 'Dependency',
|
|
140
|
+
description: '',
|
|
141
|
+
status: 'running',
|
|
142
|
+
agentId: 'agent-1',
|
|
143
|
+
createdAt: Date.now(),
|
|
144
|
+
updatedAt: Date.now(),
|
|
145
|
+
} as BoardTask
|
|
146
|
+
storage.saveTasks(tasks)
|
|
147
|
+
|
|
148
|
+
const jsonResponse = await getTaskHandoff(
|
|
149
|
+
new Request('http://local/api/tasks/task-handoff/handoff'),
|
|
150
|
+
routeParams('task-handoff'),
|
|
151
|
+
)
|
|
152
|
+
assert.equal(jsonResponse.status, 200)
|
|
153
|
+
const packet = await jsonResponse.json()
|
|
154
|
+
assert.equal(packet.taskId, 'task-handoff')
|
|
155
|
+
assert.equal(packet.readiness.status, 'blocked')
|
|
156
|
+
assert.equal(packet.dependencies.blockedBy[0]?.id, 'dep-handoff')
|
|
157
|
+
|
|
158
|
+
const markdownResponse = await getTaskHandoff(
|
|
159
|
+
new Request('http://local/api/tasks/task-handoff/handoff?format=markdown'),
|
|
160
|
+
routeParams('task-handoff'),
|
|
161
|
+
)
|
|
162
|
+
assert.equal(markdownResponse.status, 200)
|
|
163
|
+
assert.match(markdownResponse.headers.get('content-type') || '', /text\/markdown/)
|
|
164
|
+
const markdown = await markdownResponse.text()
|
|
165
|
+
assert.match(markdown, /# Task Handoff: Handoff Route Task/)
|
|
166
|
+
assert.match(markdown, /Readiness: blocked/)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('POST /api/tasks/:id/handoff saves markdown and JSON snapshots into the workspace', async () => {
|
|
170
|
+
seedTask('task-handoff-save', {
|
|
171
|
+
title: 'Saved Handoff Task',
|
|
172
|
+
cwd: '/source/repo',
|
|
173
|
+
result: 'Ready for the next operator.',
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const response = await postTaskHandoff(
|
|
177
|
+
new Request('http://local/api/tasks/task-handoff-save/handoff', {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'content-type': 'application/json' },
|
|
180
|
+
body: JSON.stringify({ prepareWorkspace: true }),
|
|
181
|
+
}),
|
|
182
|
+
routeParams('task-handoff-save'),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert.equal(response.status, 200)
|
|
186
|
+
const body = await response.json()
|
|
187
|
+
assert.equal(body.packet.taskId, 'task-handoff-save')
|
|
188
|
+
assert.equal(fs.existsSync(body.files.markdownPath), true)
|
|
189
|
+
assert.equal(fs.existsSync(body.files.jsonPath), true)
|
|
190
|
+
assert.match(fs.readFileSync(body.files.markdownPath, 'utf8'), /# Task Handoff: Saved Handoff Task/)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('GET /api/tasks/handoffs lists board-level readiness packets with counts', async () => {
|
|
194
|
+
seedTask('task-ready', {
|
|
195
|
+
title: 'Ready Task',
|
|
196
|
+
executionWorkspace: {
|
|
197
|
+
path: '/tmp/ready',
|
|
198
|
+
mode: 'task',
|
|
199
|
+
preparedAt: Date.now(),
|
|
200
|
+
previewLinks: [],
|
|
201
|
+
runtimeServices: [],
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
seedTask('task-needs-attention', {
|
|
205
|
+
title: 'Needs Workspace',
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const response = await getTaskHandoffs(new Request('http://local/api/tasks/handoffs?status=needs_attention&limit=10'))
|
|
209
|
+
assert.equal(response.status, 200)
|
|
210
|
+
const body = await response.json()
|
|
211
|
+
assert.equal(body.counts.ready >= 1, true)
|
|
212
|
+
assert.equal(body.counts.needs_attention >= 1, true)
|
|
213
|
+
assert.equal(body.items.every((packet: { readiness: { status: string } }) => packet.readiness.status === 'needs_attention'), true)
|
|
214
|
+
})
|
package/src/cli/index.js
CHANGED
|
@@ -735,6 +735,9 @@ const COMMAND_GROUPS = [
|
|
|
735
735
|
commands: [
|
|
736
736
|
cmd('list', 'GET', '/tasks', 'List tasks'),
|
|
737
737
|
cmd('get', 'GET', '/tasks/:id', 'Get task'),
|
|
738
|
+
cmd('handoff', 'GET', '/tasks/:id/handoff', 'Get task handoff packet'),
|
|
739
|
+
cmd('handoff-save', 'POST', '/tasks/:id/handoff', 'Save task handoff packet into the task workspace', { expectsJsonBody: true }),
|
|
740
|
+
cmd('handoffs', 'GET', '/tasks/handoffs', 'List task handoff readiness packets'),
|
|
738
741
|
cmd('create', 'POST', '/tasks', 'Create task', { expectsJsonBody: true }),
|
|
739
742
|
cmd('bulk', 'POST', '/tasks/bulk', 'Bulk update tasks (status/agent/project)', { expectsJsonBody: true }),
|
|
740
743
|
cmd('update', 'PUT', '/tasks/:id', 'Update task', { expectsJsonBody: true }),
|
package/src/cli/index.test.js
CHANGED
|
@@ -163,6 +163,38 @@ test('runCli sends authenticated request and emits compact JSON when --json is s
|
|
|
163
163
|
assert.equal(stderr.toString(), '')
|
|
164
164
|
})
|
|
165
165
|
|
|
166
|
+
test('tasks handoff command can request markdown packets', async () => {
|
|
167
|
+
const stdout = makeWritable()
|
|
168
|
+
const stderr = makeWritable()
|
|
169
|
+
const calls = []
|
|
170
|
+
|
|
171
|
+
const fetchImpl = async (url, init) => {
|
|
172
|
+
calls.push({ url: String(url), init })
|
|
173
|
+
return new Response('# Task Handoff\n', {
|
|
174
|
+
status: 200,
|
|
175
|
+
headers: { 'content-type': 'text/markdown; charset=utf-8' },
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const exitCode = await runCli(
|
|
180
|
+
['tasks', 'handoff', 'task-1', '--query', 'format=markdown'],
|
|
181
|
+
{
|
|
182
|
+
fetchImpl,
|
|
183
|
+
stdout,
|
|
184
|
+
stderr,
|
|
185
|
+
env: {},
|
|
186
|
+
cwd: process.cwd(),
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
assert.equal(exitCode, 0)
|
|
191
|
+
assert.equal(calls.length, 1)
|
|
192
|
+
assert.match(calls[0].url, /\/api\/tasks\/task-1\/handoff\?format=markdown$/)
|
|
193
|
+
assert.equal(calls[0].init.method, 'GET')
|
|
194
|
+
assert.equal(stdout.toString(), '# Task Handoff\n')
|
|
195
|
+
assert.equal(stderr.toString(), '')
|
|
196
|
+
})
|
|
197
|
+
|
|
166
198
|
test('openclaw deploy bundle command merges action with provided JSON body', async () => {
|
|
167
199
|
const stdout = makeWritable()
|
|
168
200
|
const stderr = makeWritable()
|
package/src/cli/spec.js
CHANGED
|
@@ -529,6 +529,9 @@ const COMMAND_GROUPS = {
|
|
|
529
529
|
commands: {
|
|
530
530
|
list: { description: 'List tasks', method: 'GET', path: '/tasks' },
|
|
531
531
|
get: { description: 'Get task by id', method: 'GET', path: '/tasks/:id', params: ['id'] },
|
|
532
|
+
handoff: { description: 'Get task handoff packet', method: 'GET', path: '/tasks/:id/handoff', params: ['id'] },
|
|
533
|
+
'handoff-save': { description: 'Save task handoff packet into the task workspace', method: 'POST', path: '/tasks/:id/handoff', params: ['id'] },
|
|
534
|
+
handoffs: { description: 'List task handoff readiness packets', method: 'GET', path: '/tasks/handoffs' },
|
|
532
535
|
create: { description: 'Create task', method: 'POST', path: '/tasks' },
|
|
533
536
|
bulk: { description: 'Bulk update tasks (status/agent/project)', method: 'POST', path: '/tasks/bulk' },
|
|
534
537
|
update: { description: 'Update task', method: 'PUT', path: '/tasks/:id', params: ['id'] },
|
|
@@ -2,7 +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
|
+
import { Activity, ClipboardCopy, ExternalLink, FileText, FolderOpen, PlayCircle, Save } from 'lucide-react'
|
|
6
6
|
import ReactMarkdown from 'react-markdown'
|
|
7
7
|
import remarkGfm from 'remark-gfm'
|
|
8
8
|
import { useAppStore } from '@/stores/use-app-store'
|
|
@@ -27,6 +27,7 @@ import { dedup, errorMessage } from '@/lib/shared-utils'
|
|
|
27
27
|
import { SectionLabel } from '@/components/shared/section-label'
|
|
28
28
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
29
29
|
import { InfoChip } from '@/components/ui/info-chip'
|
|
30
|
+
import { fetchTaskHandoffMarkdown, saveTaskHandoffSnapshot } from '@/lib/tasks'
|
|
30
31
|
|
|
31
32
|
function fmtTime(ts: number) {
|
|
32
33
|
const d = new Date(ts)
|
|
@@ -105,6 +106,11 @@ export function TaskSheet() {
|
|
|
105
106
|
const [qualityGateRequireReport, setQualityGateRequireReport] = useState(false)
|
|
106
107
|
const [provisionWorkspace, setProvisionWorkspace] = useState(false)
|
|
107
108
|
const [workspacePreparing, setWorkspacePreparing] = useState(false)
|
|
109
|
+
const [handoffCopying, setHandoffCopying] = useState(false)
|
|
110
|
+
const [handoffSaving, setHandoffSaving] = useState(false)
|
|
111
|
+
const [handoffCopied, setHandoffCopied] = useState(false)
|
|
112
|
+
const [handoffError, setHandoffError] = useState<string | null>(null)
|
|
113
|
+
const [handoffSavedPath, setHandoffSavedPath] = useState<string | null>(null)
|
|
108
114
|
const [structuredSessionOpen, setStructuredSessionOpen] = useState(false)
|
|
109
115
|
const formInitRef = useRef<string | null>(null)
|
|
110
116
|
|
|
@@ -160,6 +166,9 @@ export function TaskSheet() {
|
|
|
160
166
|
setQualityGateRequireArtifact(gate?.requireArtifact ?? defaultGateRequireArtifact)
|
|
161
167
|
setQualityGateRequireReport(gate?.requireReport ?? defaultGateRequireReport)
|
|
162
168
|
setProvisionWorkspace(false)
|
|
169
|
+
setHandoffCopied(false)
|
|
170
|
+
setHandoffError(null)
|
|
171
|
+
setHandoffSavedPath(null)
|
|
163
172
|
formInitRef.current = initKey
|
|
164
173
|
return
|
|
165
174
|
}
|
|
@@ -185,6 +194,9 @@ export function TaskSheet() {
|
|
|
185
194
|
setQualityGateRequireArtifact(defaultGateRequireArtifact)
|
|
186
195
|
setQualityGateRequireReport(defaultGateRequireReport)
|
|
187
196
|
setProvisionWorkspace(false)
|
|
197
|
+
setHandoffCopied(false)
|
|
198
|
+
setHandoffError(null)
|
|
199
|
+
setHandoffSavedPath(null)
|
|
188
200
|
formInitRef.current = initKey
|
|
189
201
|
}, [
|
|
190
202
|
activeProjectFilter,
|
|
@@ -209,6 +221,9 @@ export function TaskSheet() {
|
|
|
209
221
|
const onClose = () => {
|
|
210
222
|
formInitRef.current = null
|
|
211
223
|
setDepError(null)
|
|
224
|
+
setHandoffCopied(false)
|
|
225
|
+
setHandoffError(null)
|
|
226
|
+
setHandoffSavedPath(null)
|
|
212
227
|
setOpen(false)
|
|
213
228
|
setEditingId(null)
|
|
214
229
|
}
|
|
@@ -299,6 +314,39 @@ export function TaskSheet() {
|
|
|
299
314
|
}
|
|
300
315
|
}
|
|
301
316
|
|
|
317
|
+
const handleCopyHandoff = async () => {
|
|
318
|
+
if (!editing) return
|
|
319
|
+
setHandoffCopying(true)
|
|
320
|
+
try {
|
|
321
|
+
const markdown = await fetchTaskHandoffMarkdown(editing.id)
|
|
322
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
|
323
|
+
throw new Error('Clipboard is unavailable in this browser')
|
|
324
|
+
}
|
|
325
|
+
await navigator.clipboard.writeText(markdown)
|
|
326
|
+
setHandoffCopied(true)
|
|
327
|
+
setHandoffError(null)
|
|
328
|
+
globalThis.setTimeout(() => setHandoffCopied(false), 1800)
|
|
329
|
+
} catch (err: unknown) {
|
|
330
|
+
setHandoffError(errorMessage(err))
|
|
331
|
+
} finally {
|
|
332
|
+
setHandoffCopying(false)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const handleSaveHandoff = async () => {
|
|
337
|
+
if (!editing) return
|
|
338
|
+
setHandoffSaving(true)
|
|
339
|
+
try {
|
|
340
|
+
const result = await saveTaskHandoffSnapshot(editing.id, { prepareWorkspace: true })
|
|
341
|
+
setHandoffSavedPath(result.files.markdownPath)
|
|
342
|
+
setHandoffError(null)
|
|
343
|
+
} catch (err: unknown) {
|
|
344
|
+
setHandoffError(errorMessage(err))
|
|
345
|
+
} finally {
|
|
346
|
+
setHandoffSaving(false)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
302
350
|
const handleUnarchive = async () => {
|
|
303
351
|
if (editing) {
|
|
304
352
|
await updateTaskMutation.mutateAsync({ id: editing.id, patch: { status: 'backlog' } })
|
|
@@ -353,6 +401,48 @@ export function TaskSheet() {
|
|
|
353
401
|
? editing.runtimeServices
|
|
354
402
|
: editing.executionWorkspace?.runtimeServices || [])
|
|
355
403
|
: []
|
|
404
|
+
const handoffUrl = editing
|
|
405
|
+
? `/api/tasks/${encodeURIComponent(editing.id)}/handoff?format=markdown`
|
|
406
|
+
: ''
|
|
407
|
+
const handoffControls = editing ? (
|
|
408
|
+
<div className="space-y-2">
|
|
409
|
+
<div className="flex flex-wrap gap-2">
|
|
410
|
+
<button
|
|
411
|
+
onClick={handleCopyHandoff}
|
|
412
|
+
disabled={handoffCopying}
|
|
413
|
+
className="inline-flex items-center gap-2 rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-[12px] font-600 text-text-2 hover:bg-white/[0.08] disabled:opacity-50"
|
|
414
|
+
style={{ fontFamily: 'inherit' }}
|
|
415
|
+
>
|
|
416
|
+
<ClipboardCopy size={13} />
|
|
417
|
+
{handoffCopied ? 'Copied' : handoffCopying ? 'Copying...' : 'Copy Handoff'}
|
|
418
|
+
</button>
|
|
419
|
+
<a
|
|
420
|
+
href={handoffUrl}
|
|
421
|
+
target="_blank"
|
|
422
|
+
rel="noreferrer"
|
|
423
|
+
className="inline-flex items-center gap-2 rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-[12px] font-600 text-text-2 hover:bg-white/[0.08]"
|
|
424
|
+
>
|
|
425
|
+
<FileText size={13} />
|
|
426
|
+
Open Packet
|
|
427
|
+
</a>
|
|
428
|
+
<button
|
|
429
|
+
onClick={handleSaveHandoff}
|
|
430
|
+
disabled={handoffSaving}
|
|
431
|
+
className="inline-flex items-center gap-2 rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-[12px] font-600 text-text-2 hover:bg-white/[0.08] disabled:opacity-50"
|
|
432
|
+
style={{ fontFamily: 'inherit' }}
|
|
433
|
+
>
|
|
434
|
+
<Save size={13} />
|
|
435
|
+
{handoffSaving ? 'Saving...' : 'Save Packet'}
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
{handoffSavedPath && (
|
|
439
|
+
<code className="block text-[11px] text-text-3 font-mono break-all">{handoffSavedPath}</code>
|
|
440
|
+
)}
|
|
441
|
+
{handoffError && (
|
|
442
|
+
<p className="text-[12px] font-600 text-red-400">{handoffError}</p>
|
|
443
|
+
)}
|
|
444
|
+
</div>
|
|
445
|
+
) : null
|
|
356
446
|
|
|
357
447
|
/* ───── View-only mode ───── */
|
|
358
448
|
if (viewOnly && editing) {
|
|
@@ -503,6 +593,7 @@ export function TaskSheet() {
|
|
|
503
593
|
))}
|
|
504
594
|
</div>
|
|
505
595
|
)}
|
|
596
|
+
{handoffControls}
|
|
506
597
|
{!editing.executionWorkspace && (
|
|
507
598
|
<button
|
|
508
599
|
onClick={handlePrepareWorkspace}
|
|
@@ -1008,6 +1099,7 @@ export function TaskSheet() {
|
|
|
1008
1099
|
))}
|
|
1009
1100
|
</div>
|
|
1010
1101
|
)}
|
|
1102
|
+
{handoffControls}
|
|
1011
1103
|
<button
|
|
1012
1104
|
onClick={handlePrepareWorkspace}
|
|
1013
1105
|
disabled={workspacePreparing}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { buildTaskHandoffPacket, formatTaskHandoffMarkdown } from '@/lib/server/tasks/task-handoff'
|
|
5
|
+
import type { BoardTask } from '@/types'
|
|
6
|
+
|
|
7
|
+
function task(overrides: Partial<BoardTask> = {}): BoardTask {
|
|
8
|
+
return {
|
|
9
|
+
id: 'task-main',
|
|
10
|
+
title: 'Prepare release handoff',
|
|
11
|
+
description: 'Summarize the release state for the next operator.',
|
|
12
|
+
status: 'backlog',
|
|
13
|
+
agentId: 'agent-1',
|
|
14
|
+
createdAt: 100,
|
|
15
|
+
updatedAt: 200,
|
|
16
|
+
...overrides,
|
|
17
|
+
} as BoardTask
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('task handoff packets', () => {
|
|
21
|
+
it('marks unresolved blockers as blocked and recommends a concrete action', () => {
|
|
22
|
+
const tasks: Record<string, BoardTask> = {
|
|
23
|
+
'task-main': task({
|
|
24
|
+
blockedBy: ['dep-1'],
|
|
25
|
+
executionWorkspace: {
|
|
26
|
+
path: '/tmp/workspace',
|
|
27
|
+
mode: 'task',
|
|
28
|
+
preparedAt: 150,
|
|
29
|
+
previewLinks: [],
|
|
30
|
+
runtimeServices: [],
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
'dep-1': task({
|
|
34
|
+
id: 'dep-1',
|
|
35
|
+
title: 'Finish prerequisite',
|
|
36
|
+
status: 'running',
|
|
37
|
+
agentId: 'agent-2',
|
|
38
|
+
}),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const packet = buildTaskHandoffPacket(tasks['task-main'], tasks, { now: 300, runBrief: null })
|
|
42
|
+
|
|
43
|
+
assert.equal(packet.readiness.status, 'blocked')
|
|
44
|
+
assert.deepEqual(packet.dependencies.blockedBy.map((ref) => ref.id), ['dep-1'])
|
|
45
|
+
assert.equal(packet.readiness.checks.find((item) => item.id === 'dependencies')?.status, 'blocked')
|
|
46
|
+
assert.ok(packet.readiness.recommendedActions.some((action) => action.includes('unresolved blockers')))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('summarizes workspace, evidence, and quality gate state in markdown', () => {
|
|
50
|
+
const mainTask = task({
|
|
51
|
+
status: 'completed',
|
|
52
|
+
result: 'Release completed with tests and browser smoke.',
|
|
53
|
+
completionReportPath: '/tmp/workspace/report.md',
|
|
54
|
+
outputFiles: ['/tmp/workspace/report.md'],
|
|
55
|
+
artifacts: [{ filename: 'smoke.txt', type: 'file', url: '/uploads/smoke.txt' }],
|
|
56
|
+
verificationSummary: 'npm run test passed',
|
|
57
|
+
qualityGate: {
|
|
58
|
+
enabled: true,
|
|
59
|
+
minResultChars: 10,
|
|
60
|
+
minEvidenceItems: 2,
|
|
61
|
+
requireVerification: true,
|
|
62
|
+
requireArtifact: true,
|
|
63
|
+
requireReport: true,
|
|
64
|
+
},
|
|
65
|
+
executionWorkspace: {
|
|
66
|
+
path: '/tmp/workspace',
|
|
67
|
+
mode: 'task',
|
|
68
|
+
contextPath: '/tmp/workspace/context.json',
|
|
69
|
+
envPath: '/tmp/workspace/.env.swarmclaw',
|
|
70
|
+
preparedAt: 150,
|
|
71
|
+
previewLinks: [{ id: 'preview', label: 'Preview', kind: 'web', url: 'http://127.0.0.1:3456', addedAt: 160 }],
|
|
72
|
+
runtimeServices: [{ id: 'dev', name: 'Dev server', status: 'running', updatedAt: 160 }],
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const packet = buildTaskHandoffPacket(mainTask, { 'task-main': mainTask }, { now: 300, runBrief: null })
|
|
77
|
+
const markdown = formatTaskHandoffMarkdown(packet)
|
|
78
|
+
|
|
79
|
+
assert.equal(packet.readiness.status, 'ready')
|
|
80
|
+
assert.match(markdown, /# Task Handoff: Prepare release handoff/)
|
|
81
|
+
assert.match(markdown, /Readiness: ready/)
|
|
82
|
+
assert.match(markdown, /Workspace: \/tmp\/workspace/)
|
|
83
|
+
assert.match(markdown, /Preview: Preview http:\/\/127\.0\.0\.1:3456/)
|
|
84
|
+
assert.match(markdown, /Task report: \/tmp\/workspace\/report\.md/)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { buildRunBrief } from '@/lib/server/runs/run-brief'
|
|
2
|
+
import { getUnifiedRunById, listUnifiedRunEvents, listUnifiedRuns } from '@/lib/server/runs/unified-run-queries'
|
|
3
|
+
import { computeTaskLiveness } from '@/lib/server/tasks/task-execution-workspace'
|
|
4
|
+
import type {
|
|
5
|
+
BoardTask,
|
|
6
|
+
RunBrief,
|
|
7
|
+
TaskHandoffCheck,
|
|
8
|
+
TaskHandoffPacket,
|
|
9
|
+
TaskHandoffReadinessStatus,
|
|
10
|
+
TaskHandoffTaskRef,
|
|
11
|
+
} from '@/types'
|
|
12
|
+
|
|
13
|
+
const MAX_MARKDOWN_TEXT = 900
|
|
14
|
+
|
|
15
|
+
function compactText(value: unknown, maxChars = MAX_MARKDOWN_TEXT): string | null {
|
|
16
|
+
if (typeof value !== 'string') return null
|
|
17
|
+
const text = value.split(/\s+/).filter(Boolean).join(' ').trim()
|
|
18
|
+
if (!text) return null
|
|
19
|
+
return text.length > maxChars ? `${text.slice(0, maxChars - 3)}...` : text
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toIso(value: number | null | undefined): string {
|
|
23
|
+
return value && Number.isFinite(value) ? new Date(value).toISOString() : 'n/a'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function taskRef(id: string, tasks: Record<string, BoardTask>, now: number): TaskHandoffTaskRef {
|
|
27
|
+
const task = tasks[id]
|
|
28
|
+
if (!task) {
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
title: id,
|
|
32
|
+
status: 'backlog',
|
|
33
|
+
agentId: null,
|
|
34
|
+
completedAt: null,
|
|
35
|
+
liveness: {
|
|
36
|
+
state: 'blocked',
|
|
37
|
+
reason: 'Referenced task is missing.',
|
|
38
|
+
checkedAt: now,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
id: task.id,
|
|
44
|
+
title: task.title || task.id,
|
|
45
|
+
status: task.status,
|
|
46
|
+
agentId: task.agentId || null,
|
|
47
|
+
completedAt: task.completedAt ?? null,
|
|
48
|
+
liveness: computeTaskLiveness(task, tasks, { now }),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveLatestRunBrief(task: BoardTask): RunBrief | null {
|
|
53
|
+
const candidateIds = [
|
|
54
|
+
task.checkpoint?.lastRunId,
|
|
55
|
+
task.protocolRunId,
|
|
56
|
+
].filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
|
|
57
|
+
|
|
58
|
+
for (const runId of candidateIds) {
|
|
59
|
+
const run = getUnifiedRunById(runId)
|
|
60
|
+
if (run) return buildRunBrief(run, listUnifiedRunEvents(runId, 300))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!task.sessionId) return null
|
|
64
|
+
const latestOwnedRun = listUnifiedRuns({ sessionId: task.sessionId, limit: 50 })
|
|
65
|
+
.find((run) => run.ownerType === 'task' && run.ownerId === task.id)
|
|
66
|
+
if (!latestOwnedRun) return null
|
|
67
|
+
return buildRunBrief(latestOwnedRun, listUnifiedRunEvents(latestOwnedRun.id, 300))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function check(id: string, label: string, status: TaskHandoffCheck['status'], detail?: string | null, taskIds?: string[]): TaskHandoffCheck {
|
|
71
|
+
return { id, label, status, detail: detail || null, ...(taskIds?.length ? { taskIds } : {}) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function qualityChecks(task: BoardTask, runBrief: RunBrief | null): TaskHandoffCheck[] {
|
|
75
|
+
const gate = task.qualityGate || null
|
|
76
|
+
if (!gate?.enabled) return [check('quality-gate', 'Quality gate', 'ok', 'No task-specific gate is enabled.')]
|
|
77
|
+
|
|
78
|
+
const checks: TaskHandoffCheck[] = []
|
|
79
|
+
const resultLength = (task.result || '').trim().length
|
|
80
|
+
const minResultChars = Math.max(0, Math.trunc(gate.minResultChars ?? 0))
|
|
81
|
+
if (minResultChars > 0) {
|
|
82
|
+
checks.push(check(
|
|
83
|
+
'quality-result',
|
|
84
|
+
'Result length',
|
|
85
|
+
resultLength >= minResultChars ? 'ok' : 'warning',
|
|
86
|
+
resultLength >= minResultChars
|
|
87
|
+
? `Result has ${resultLength} characters.`
|
|
88
|
+
: `Result has ${resultLength} of ${minResultChars} required characters.`,
|
|
89
|
+
))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const evidenceCount =
|
|
93
|
+
(Array.isArray(task.outputFiles) ? task.outputFiles.length : 0)
|
|
94
|
+
+ (Array.isArray(task.artifacts) ? task.artifacts.length : 0)
|
|
95
|
+
+ (runBrief?.evidence.length || 0)
|
|
96
|
+
const minEvidenceItems = Math.max(0, Math.trunc(gate.minEvidenceItems ?? 0))
|
|
97
|
+
if (minEvidenceItems > 0) {
|
|
98
|
+
checks.push(check(
|
|
99
|
+
'quality-evidence',
|
|
100
|
+
'Evidence',
|
|
101
|
+
evidenceCount >= minEvidenceItems ? 'ok' : 'warning',
|
|
102
|
+
evidenceCount >= minEvidenceItems
|
|
103
|
+
? `${evidenceCount} evidence signal${evidenceCount === 1 ? '' : 's'} available.`
|
|
104
|
+
: `${evidenceCount} of ${minEvidenceItems} required evidence signals are available.`,
|
|
105
|
+
))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (gate.requireVerification) {
|
|
109
|
+
const hasVerification = Boolean(compactText(task.verificationSummary, 240) || runBrief?.warnings.length === 0 && runBrief?.status === 'completed')
|
|
110
|
+
checks.push(check(
|
|
111
|
+
'quality-verification',
|
|
112
|
+
'Verification',
|
|
113
|
+
hasVerification ? 'ok' : 'warning',
|
|
114
|
+
hasVerification ? 'Verification signal is present.' : 'Add verification notes before handoff.',
|
|
115
|
+
))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (gate.requireArtifact) {
|
|
119
|
+
const artifactCount = Array.isArray(task.artifacts) ? task.artifacts.length : 0
|
|
120
|
+
checks.push(check(
|
|
121
|
+
'quality-artifact',
|
|
122
|
+
'Artifact',
|
|
123
|
+
artifactCount > 0 ? 'ok' : 'warning',
|
|
124
|
+
artifactCount > 0 ? `${artifactCount} artifact${artifactCount === 1 ? '' : 's'} attached.` : 'Attach at least one artifact.',
|
|
125
|
+
))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (gate.requireReport) {
|
|
129
|
+
checks.push(check(
|
|
130
|
+
'quality-report',
|
|
131
|
+
'Task report',
|
|
132
|
+
task.completionReportPath ? 'ok' : 'warning',
|
|
133
|
+
task.completionReportPath ? 'Task report path is present.' : 'Generate or attach a task report.',
|
|
134
|
+
))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return checks.length > 0 ? checks : [check('quality-gate', 'Quality gate', 'ok', 'Enabled gate has no active requirements.')]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readinessStatus(checks: TaskHandoffCheck[]): TaskHandoffReadinessStatus {
|
|
141
|
+
if (checks.some((item) => item.status === 'blocked')) return 'blocked'
|
|
142
|
+
if (checks.some((item) => item.status === 'warning')) return 'needs_attention'
|
|
143
|
+
return 'ready'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function recommendedActions(checks: TaskHandoffCheck[]): string[] {
|
|
147
|
+
const actions: string[] = []
|
|
148
|
+
for (const item of checks) {
|
|
149
|
+
if (item.status === 'ok') continue
|
|
150
|
+
if (item.id === 'dependencies') actions.push('Complete or remove unresolved blockers before queueing the task.')
|
|
151
|
+
else if (item.id === 'workspace') actions.push('Prepare or refresh the task workspace before handing it to another operator.')
|
|
152
|
+
else if (item.id === 'liveness') actions.push('Inspect the current run state and clear stale or failed execution before resuming.')
|
|
153
|
+
else if (item.id.startsWith('quality-')) actions.push(item.detail || 'Resolve the task quality gate before handoff.')
|
|
154
|
+
else if (item.detail) actions.push(item.detail)
|
|
155
|
+
}
|
|
156
|
+
return Array.from(new Set(actions)).slice(0, 8)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function buildTaskHandoffPacket(
|
|
160
|
+
task: BoardTask,
|
|
161
|
+
tasks: Record<string, BoardTask>,
|
|
162
|
+
options: { now?: number; runBrief?: RunBrief | null } = {},
|
|
163
|
+
): TaskHandoffPacket {
|
|
164
|
+
const now = options.now ?? Date.now()
|
|
165
|
+
const liveness = computeTaskLiveness(task, tasks, { now })
|
|
166
|
+
const blockedBy = (task.blockedBy || []).map((id) => taskRef(id, tasks, now))
|
|
167
|
+
const blocks = (task.blocks || []).map((id) => taskRef(id, tasks, now))
|
|
168
|
+
const unresolvedBlockerIds = blockedBy
|
|
169
|
+
.filter((ref) => ref.status !== 'completed')
|
|
170
|
+
.map((ref) => ref.id)
|
|
171
|
+
const runBrief = options.runBrief === undefined ? resolveLatestRunBrief(task) : options.runBrief
|
|
172
|
+
const gateChecks = qualityChecks(task, runBrief)
|
|
173
|
+
const executionWorkspace = task.executionWorkspace || null
|
|
174
|
+
const previewLinks = task.previewLinks && task.previewLinks.length > 0
|
|
175
|
+
? task.previewLinks
|
|
176
|
+
: executionWorkspace?.previewLinks || []
|
|
177
|
+
const runtimeServices = task.runtimeServices && task.runtimeServices.length > 0
|
|
178
|
+
? task.runtimeServices
|
|
179
|
+
: executionWorkspace?.runtimeServices || []
|
|
180
|
+
|
|
181
|
+
const checks: TaskHandoffCheck[] = [
|
|
182
|
+
check(
|
|
183
|
+
'owner',
|
|
184
|
+
'Owner',
|
|
185
|
+
task.agentId ? 'ok' : 'warning',
|
|
186
|
+
task.agentId ? `Assigned to ${task.agentId}.` : 'Assign an agent before execution.',
|
|
187
|
+
),
|
|
188
|
+
check(
|
|
189
|
+
'dependencies',
|
|
190
|
+
'Dependencies',
|
|
191
|
+
unresolvedBlockerIds.length > 0 ? 'blocked' : 'ok',
|
|
192
|
+
unresolvedBlockerIds.length > 0
|
|
193
|
+
? `Waiting on ${unresolvedBlockerIds.length} blocker${unresolvedBlockerIds.length === 1 ? '' : 's'}.`
|
|
194
|
+
: 'No unresolved blockers.',
|
|
195
|
+
unresolvedBlockerIds,
|
|
196
|
+
),
|
|
197
|
+
check(
|
|
198
|
+
'workspace',
|
|
199
|
+
'Execution workspace',
|
|
200
|
+
executionWorkspace ? 'ok' : 'warning',
|
|
201
|
+
executionWorkspace ? 'Workspace is prepared.' : 'No task workspace is prepared.',
|
|
202
|
+
),
|
|
203
|
+
check(
|
|
204
|
+
'liveness',
|
|
205
|
+
'Liveness',
|
|
206
|
+
liveness.state === 'failed' || liveness.state === 'dead_lettered' || liveness.state === 'cancelled'
|
|
207
|
+
? 'blocked'
|
|
208
|
+
: liveness.state === 'stale' || liveness.state === 'retrying'
|
|
209
|
+
? 'warning'
|
|
210
|
+
: 'ok',
|
|
211
|
+
liveness.reason,
|
|
212
|
+
),
|
|
213
|
+
...gateChecks,
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
const status = readinessStatus(checks)
|
|
217
|
+
return {
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
taskId: task.id,
|
|
220
|
+
title: task.title || task.id,
|
|
221
|
+
description: task.description || null,
|
|
222
|
+
objective: task.objective || null,
|
|
223
|
+
status: task.status,
|
|
224
|
+
priority: task.priority,
|
|
225
|
+
generatedAt: now,
|
|
226
|
+
updatedAt: task.updatedAt,
|
|
227
|
+
owner: {
|
|
228
|
+
agentId: task.agentId || null,
|
|
229
|
+
projectId: task.projectId || null,
|
|
230
|
+
sessionId: task.sessionId || null,
|
|
231
|
+
createdByAgentId: task.createdByAgentId || null,
|
|
232
|
+
delegatedByAgentId: task.delegatedByAgentId || null,
|
|
233
|
+
},
|
|
234
|
+
liveness,
|
|
235
|
+
execution: {
|
|
236
|
+
workspacePath: executionWorkspace?.path || null,
|
|
237
|
+
sourceCwd: executionWorkspace?.sourceCwd || task.cwd || null,
|
|
238
|
+
mode: executionWorkspace?.mode || null,
|
|
239
|
+
contextPath: executionWorkspace?.contextPath || null,
|
|
240
|
+
envPath: executionWorkspace?.envPath || null,
|
|
241
|
+
previewLinks,
|
|
242
|
+
runtimeServices,
|
|
243
|
+
},
|
|
244
|
+
dependencies: {
|
|
245
|
+
blockedBy,
|
|
246
|
+
blocks,
|
|
247
|
+
},
|
|
248
|
+
qualityGate: {
|
|
249
|
+
enabled: Boolean(task.qualityGate?.enabled),
|
|
250
|
+
config: task.qualityGate || null,
|
|
251
|
+
checks: gateChecks,
|
|
252
|
+
},
|
|
253
|
+
outputs: {
|
|
254
|
+
result: task.result || null,
|
|
255
|
+
error: task.error || null,
|
|
256
|
+
outputFiles: Array.isArray(task.outputFiles) ? task.outputFiles : [],
|
|
257
|
+
artifacts: Array.isArray(task.artifacts) ? task.artifacts : [],
|
|
258
|
+
completionReportPath: task.completionReportPath || null,
|
|
259
|
+
verificationSummary: task.verificationSummary || null,
|
|
260
|
+
},
|
|
261
|
+
resume: {
|
|
262
|
+
cliProvider: task.cliProvider || null,
|
|
263
|
+
cliResumeId: task.cliResumeId || null,
|
|
264
|
+
claudeResumeId: task.claudeResumeId || null,
|
|
265
|
+
codexResumeId: task.codexResumeId || null,
|
|
266
|
+
opencodeResumeId: task.opencodeResumeId || null,
|
|
267
|
+
geminiResumeId: task.geminiResumeId || null,
|
|
268
|
+
},
|
|
269
|
+
run: runBrief
|
|
270
|
+
? {
|
|
271
|
+
runId: runBrief.runId,
|
|
272
|
+
sessionId: runBrief.sessionId,
|
|
273
|
+
title: runBrief.title,
|
|
274
|
+
status: runBrief.status,
|
|
275
|
+
result: runBrief.result,
|
|
276
|
+
error: runBrief.error,
|
|
277
|
+
warnings: runBrief.warnings,
|
|
278
|
+
evidenceCount: runBrief.evidence.length,
|
|
279
|
+
}
|
|
280
|
+
: null,
|
|
281
|
+
readiness: {
|
|
282
|
+
status,
|
|
283
|
+
checks,
|
|
284
|
+
recommendedActions: status === 'ready'
|
|
285
|
+
? ['Handoff packet is ready to share.']
|
|
286
|
+
: recommendedActions(checks),
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function lineForRef(ref: TaskHandoffTaskRef): string {
|
|
292
|
+
const suffix = ref.liveness?.reason ? `, ${ref.liveness.reason}` : ''
|
|
293
|
+
return `- ${ref.title} (${ref.id}): ${ref.status}${suffix}`
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function appendSection(lines: string[], title: string, body: string[] = []) {
|
|
297
|
+
lines.push('', `## ${title}`)
|
|
298
|
+
if (body.length === 0) lines.push('None.')
|
|
299
|
+
else lines.push(...body)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function formatTaskHandoffMarkdown(packet: TaskHandoffPacket): string {
|
|
303
|
+
const lines = [
|
|
304
|
+
`# Task Handoff: ${packet.title}`,
|
|
305
|
+
'',
|
|
306
|
+
`Generated: ${toIso(packet.generatedAt)}`,
|
|
307
|
+
`Task ID: ${packet.taskId}`,
|
|
308
|
+
`Status: ${packet.status}`,
|
|
309
|
+
`Readiness: ${packet.readiness.status}`,
|
|
310
|
+
`Owner: ${packet.owner.agentId || 'unassigned'}`,
|
|
311
|
+
`Updated: ${toIso(packet.updatedAt)}`,
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
const objective = compactText(packet.objective || packet.description, 1400)
|
|
315
|
+
if (objective) appendSection(lines, 'Objective', [objective])
|
|
316
|
+
|
|
317
|
+
appendSection(lines, 'Liveness', [
|
|
318
|
+
`- State: ${packet.liveness.state}`,
|
|
319
|
+
`- Reason: ${packet.liveness.reason}`,
|
|
320
|
+
packet.liveness.nextWakeAt ? `- Next wake: ${toIso(packet.liveness.nextWakeAt)}` : '',
|
|
321
|
+
].filter(Boolean))
|
|
322
|
+
|
|
323
|
+
appendSection(lines, 'Workspace', [
|
|
324
|
+
packet.execution.workspacePath ? `- Workspace: ${packet.execution.workspacePath}` : '- Workspace: not prepared',
|
|
325
|
+
packet.execution.sourceCwd ? `- Source: ${packet.execution.sourceCwd}` : '',
|
|
326
|
+
packet.execution.contextPath ? `- Context: ${packet.execution.contextPath}` : '',
|
|
327
|
+
packet.execution.envPath ? `- Env: ${packet.execution.envPath}` : '',
|
|
328
|
+
].filter(Boolean))
|
|
329
|
+
|
|
330
|
+
appendSection(lines, 'Runtime', [
|
|
331
|
+
...packet.execution.previewLinks.map((link) => `- Preview: ${link.label || 'Preview'} ${link.url}`),
|
|
332
|
+
...packet.execution.runtimeServices.map((service) => `- Service: ${service.name} (${service.status})${service.url ? ` ${service.url}` : ''}`),
|
|
333
|
+
])
|
|
334
|
+
|
|
335
|
+
appendSection(lines, 'Dependencies', [
|
|
336
|
+
...packet.dependencies.blockedBy.map(lineForRef),
|
|
337
|
+
...packet.dependencies.blocks.map((ref) => `- Blocks ${ref.title} (${ref.id}): ${ref.status}`),
|
|
338
|
+
])
|
|
339
|
+
|
|
340
|
+
appendSection(lines, 'Quality Checks', packet.readiness.checks.map((item) => {
|
|
341
|
+
const detail = item.detail ? `, ${item.detail}` : ''
|
|
342
|
+
return `- ${item.label}: ${item.status}${detail}`
|
|
343
|
+
}))
|
|
344
|
+
|
|
345
|
+
appendSection(lines, 'Evidence', [
|
|
346
|
+
packet.outputs.result ? `- Result: ${compactText(packet.outputs.result, 500)}` : '',
|
|
347
|
+
packet.outputs.error ? `- Error: ${compactText(packet.outputs.error, 500)}` : '',
|
|
348
|
+
...packet.outputs.outputFiles.map((file) => `- Output file: ${file}`),
|
|
349
|
+
...packet.outputs.artifacts.map((artifact) => `- Artifact: ${artifact.filename} ${artifact.url}`),
|
|
350
|
+
packet.outputs.completionReportPath ? `- Task report: ${packet.outputs.completionReportPath}` : '',
|
|
351
|
+
packet.run ? `- Latest run: ${packet.run.title} (${packet.run.status}, ${packet.run.evidenceCount} evidence items)` : '',
|
|
352
|
+
].filter(Boolean))
|
|
353
|
+
|
|
354
|
+
appendSection(lines, 'Resume Handles', [
|
|
355
|
+
packet.resume.cliResumeId ? `- ${packet.resume.cliProvider || 'CLI'}: ${packet.resume.cliResumeId}` : '',
|
|
356
|
+
packet.resume.claudeResumeId ? `- Claude: ${packet.resume.claudeResumeId}` : '',
|
|
357
|
+
packet.resume.codexResumeId ? `- Codex: ${packet.resume.codexResumeId}` : '',
|
|
358
|
+
packet.resume.opencodeResumeId ? `- OpenCode: ${packet.resume.opencodeResumeId}` : '',
|
|
359
|
+
packet.resume.geminiResumeId ? `- Gemini: ${packet.resume.geminiResumeId}` : '',
|
|
360
|
+
].filter(Boolean))
|
|
361
|
+
|
|
362
|
+
appendSection(lines, 'Recommended Actions', packet.readiness.recommendedActions.map((action) => `- ${action}`))
|
|
363
|
+
|
|
364
|
+
return `${lines.join('\n')}\n`
|
|
365
|
+
}
|
package/src/lib/tasks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { api } from './app/api-client'
|
|
2
|
-
import type { BoardTask, TaskComment, TaskPreviewLink, TaskRuntimeService } from '../types'
|
|
2
|
+
import type { BoardTask, TaskComment, TaskHandoffPacket, 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,6 +29,14 @@ export interface GitHubIssueImportResult {
|
|
|
29
29
|
skipped: GitHubIssueImportItem[]
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export interface TaskHandoffSnapshotResult {
|
|
33
|
+
packet: TaskHandoffPacket
|
|
34
|
+
files: {
|
|
35
|
+
markdownPath: string
|
|
36
|
+
jsonPath: string
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
export type TaskWriteInput = Partial<BoardTask> & {
|
|
33
41
|
title?: string
|
|
34
42
|
description?: string
|
|
@@ -49,6 +57,17 @@ export const createTask = (data: TaskWriteInput & {
|
|
|
49
57
|
export const updateTask = (id: string, data: TaskWriteInput) =>
|
|
50
58
|
api<BoardTask>('PUT', `/tasks/${id}`, data)
|
|
51
59
|
|
|
60
|
+
export const fetchTaskHandoff = (id: string) =>
|
|
61
|
+
api<TaskHandoffPacket>('GET', `/tasks/${encodeURIComponent(id)}/handoff`)
|
|
62
|
+
|
|
63
|
+
export const fetchTaskHandoffMarkdown = (id: string) =>
|
|
64
|
+
api<string>('GET', `/tasks/${encodeURIComponent(id)}/handoff?format=markdown`)
|
|
65
|
+
|
|
66
|
+
export const saveTaskHandoffSnapshot = (id: string, options: { prepareWorkspace?: boolean } = {}) =>
|
|
67
|
+
api<TaskHandoffSnapshotResult>('POST', `/tasks/${encodeURIComponent(id)}/handoff`, {
|
|
68
|
+
prepareWorkspace: options.prepareWorkspace ?? true,
|
|
69
|
+
}, { timeoutMs: 30_000 })
|
|
70
|
+
|
|
52
71
|
export const deleteTask = (id: string) =>
|
|
53
72
|
api<BoardTask>('DELETE', `/tasks/${id}`)
|
|
54
73
|
|
package/src/types/task.ts
CHANGED
|
@@ -240,3 +240,94 @@ export interface BoardTask {
|
|
|
240
240
|
/** Customizable workflow state (separate from `status` lifecycle). */
|
|
241
241
|
workflowStateId?: string | null
|
|
242
242
|
}
|
|
243
|
+
|
|
244
|
+
export type TaskHandoffCheckStatus = 'ok' | 'warning' | 'blocked'
|
|
245
|
+
export type TaskHandoffReadinessStatus = 'ready' | 'needs_attention' | 'blocked'
|
|
246
|
+
|
|
247
|
+
export interface TaskHandoffTaskRef {
|
|
248
|
+
id: string
|
|
249
|
+
title: string
|
|
250
|
+
status: BoardTaskStatus
|
|
251
|
+
agentId?: string | null
|
|
252
|
+
completedAt?: number | null
|
|
253
|
+
liveness?: TaskLivenessSnapshot | null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface TaskHandoffCheck {
|
|
257
|
+
id: string
|
|
258
|
+
label: string
|
|
259
|
+
status: TaskHandoffCheckStatus
|
|
260
|
+
detail?: string | null
|
|
261
|
+
taskIds?: string[]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface TaskHandoffRunSummary {
|
|
265
|
+
runId: string
|
|
266
|
+
sessionId: string
|
|
267
|
+
title: string
|
|
268
|
+
status: string
|
|
269
|
+
result: string | null
|
|
270
|
+
error: string | null
|
|
271
|
+
warnings: string[]
|
|
272
|
+
evidenceCount: number
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface TaskHandoffPacket {
|
|
276
|
+
schemaVersion: 1
|
|
277
|
+
taskId: string
|
|
278
|
+
title: string
|
|
279
|
+
description?: string | null
|
|
280
|
+
objective?: string | null
|
|
281
|
+
status: BoardTaskStatus
|
|
282
|
+
priority?: BoardTask['priority']
|
|
283
|
+
generatedAt: number
|
|
284
|
+
updatedAt: number
|
|
285
|
+
owner: {
|
|
286
|
+
agentId: string | null
|
|
287
|
+
projectId?: string | null
|
|
288
|
+
sessionId?: string | null
|
|
289
|
+
createdByAgentId?: string | null
|
|
290
|
+
delegatedByAgentId?: string | null
|
|
291
|
+
}
|
|
292
|
+
liveness: TaskLivenessSnapshot
|
|
293
|
+
execution: {
|
|
294
|
+
workspacePath?: string | null
|
|
295
|
+
sourceCwd?: string | null
|
|
296
|
+
mode?: TaskExecutionWorkspaceMode | null
|
|
297
|
+
contextPath?: string | null
|
|
298
|
+
envPath?: string | null
|
|
299
|
+
previewLinks: TaskPreviewLink[]
|
|
300
|
+
runtimeServices: TaskRuntimeService[]
|
|
301
|
+
}
|
|
302
|
+
dependencies: {
|
|
303
|
+
blockedBy: TaskHandoffTaskRef[]
|
|
304
|
+
blocks: TaskHandoffTaskRef[]
|
|
305
|
+
}
|
|
306
|
+
qualityGate: {
|
|
307
|
+
enabled: boolean
|
|
308
|
+
config: TaskQualityGateConfig | null
|
|
309
|
+
checks: TaskHandoffCheck[]
|
|
310
|
+
}
|
|
311
|
+
outputs: {
|
|
312
|
+
result?: string | null
|
|
313
|
+
error?: string | null
|
|
314
|
+
outputFiles: string[]
|
|
315
|
+
artifacts: NonNullable<BoardTask['artifacts']>
|
|
316
|
+
completionReportPath?: string | null
|
|
317
|
+
verificationSummary?: string | null
|
|
318
|
+
}
|
|
319
|
+
resume: {
|
|
320
|
+
cliProvider?: string | null
|
|
321
|
+
cliResumeId?: string | null
|
|
322
|
+
claudeResumeId?: string | null
|
|
323
|
+
codexResumeId?: string | null
|
|
324
|
+
opencodeResumeId?: string | null
|
|
325
|
+
geminiResumeId?: string | null
|
|
326
|
+
}
|
|
327
|
+
run: TaskHandoffRunSummary | null
|
|
328
|
+
readiness: {
|
|
329
|
+
status: TaskHandoffReadinessStatus
|
|
330
|
+
checks: TaskHandoffCheck[]
|
|
331
|
+
recommendedActions: string[]
|
|
332
|
+
}
|
|
333
|
+
}
|