@swarmclawai/swarmclaw 1.9.14 → 1.9.15
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/runs/[id]/handoff/route.ts +26 -0
- package/src/app/api/runs/run-handoff-route.test.ts +120 -0
- package/src/cli/index.js +1 -0
- package/src/cli/spec.js +1 -0
- package/src/components/runs/run-list.tsx +44 -6
- package/src/lib/server/agents/main-agent-loop.test.ts +1 -1
- package/src/lib/server/runs/run-handoff.test.ts +112 -0
- package/src/lib/server/runs/run-handoff.ts +171 -0
- package/src/types/index.ts +1 -0
- package/src/types/run-handoff.ts +48 -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.15 Highlights
|
|
403
|
+
|
|
404
|
+
Run handoff release: SwarmClaw now turns completed, failed, queued, or running execution records into copyable handoff packets with outcome, evidence, artifacts, timeline, usage, resume commands, and recommended next actions.
|
|
405
|
+
|
|
406
|
+
- **Run handoff API.** `GET /api/runs/:id/handoff` returns structured handoff JSON, and `?format=markdown` returns copyable markdown.
|
|
407
|
+
- **Run Review copy action.** The run detail sheet exposes a copy handoff button so operators can move outcome evidence into another session without replaying the full event log.
|
|
408
|
+
- **CLI access.** `swarmclaw runs handoff <runId> --query format=markdown` exposes the same packet for scripts and release automation.
|
|
409
|
+
- **Readiness guidance.** Packets mark failed, cancelled, running, warning, or under-evidenced runs as blocked or needing attention before another operator relies on the result.
|
|
410
|
+
|
|
402
411
|
### v1.9.14 Highlights
|
|
403
412
|
|
|
404
413
|
Session context-pack release: SwarmClaw now turns a live chat into a concise handoff packet with session metadata, recent visible turns, linked tasks, attachments, resume handles, and next actions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.15",
|
|
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",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"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",
|
|
89
89
|
"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",
|
|
90
90
|
"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",
|
|
91
|
-
"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/chats/session-context-pack.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/quality/architecture-health.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-execution-policy.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-pack-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
|
+
"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/chats/session-context-pack.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/runs/run-handoff.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/quality/architecture-health.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-execution-policy.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-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/runs/run-handoff-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",
|
|
92
92
|
"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",
|
|
93
93
|
"test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
|
|
94
94
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { listEvidenceArtifacts } from '@/lib/server/artifacts/artifact-resolver'
|
|
3
|
+
import { buildRunBrief } from '@/lib/server/runs/run-brief'
|
|
4
|
+
import { buildRunHandoffPacket, formatRunHandoffMarkdown } from '@/lib/server/runs/run-handoff'
|
|
5
|
+
import { getUnifiedRunById, listUnifiedRunEvents } from '@/lib/server/runs/unified-run-queries'
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic'
|
|
8
|
+
|
|
9
|
+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
10
|
+
const { id } = await params
|
|
11
|
+
const run = getUnifiedRunById(id)
|
|
12
|
+
if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
|
|
13
|
+
|
|
14
|
+
const events = listUnifiedRunEvents(id, 300)
|
|
15
|
+
const brief = buildRunBrief(run, events)
|
|
16
|
+
const packet = buildRunHandoffPacket(run, brief, listEvidenceArtifacts({ runId: id }))
|
|
17
|
+
const url = new URL(req.url)
|
|
18
|
+
|
|
19
|
+
if (url.searchParams.get('format') === 'markdown') {
|
|
20
|
+
return new NextResponse(formatRunHandoffMarkdown(packet), {
|
|
21
|
+
headers: { 'content-type': 'text/markdown; charset=utf-8' },
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return NextResponse.json(packet)
|
|
26
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
5
|
+
|
|
6
|
+
test('GET /api/runs/[id]/handoff returns structured and markdown handoff context', () => {
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
status: number
|
|
9
|
+
markdownStatus: number
|
|
10
|
+
missingStatus: number
|
|
11
|
+
schemaVersion: number
|
|
12
|
+
readiness: string
|
|
13
|
+
artifactCount: number
|
|
14
|
+
markdownContentType: string
|
|
15
|
+
markdownIncludesTitle: boolean
|
|
16
|
+
markdownIncludesCommand: boolean
|
|
17
|
+
}>(`
|
|
18
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
19
|
+
const ledgerMod = await import('./src/lib/server/runtime/run-ledger')
|
|
20
|
+
const routeMod = await import('./src/app/api/runs/[id]/handoff/route')
|
|
21
|
+
const storage = storageMod.default || storageMod
|
|
22
|
+
const ledger = ledgerMod.default || ledgerMod
|
|
23
|
+
const route = routeMod.default || routeMod
|
|
24
|
+
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
storage.saveSessions({
|
|
27
|
+
sess_run_handoff: {
|
|
28
|
+
id: 'sess_run_handoff',
|
|
29
|
+
name: 'Run handoff session',
|
|
30
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
31
|
+
user: 'tester',
|
|
32
|
+
provider: 'openai',
|
|
33
|
+
model: 'gpt-4o-mini',
|
|
34
|
+
messages: [],
|
|
35
|
+
createdAt: now,
|
|
36
|
+
lastActiveAt: now,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
ledger.persistRun({
|
|
41
|
+
id: 'run_handoff_1',
|
|
42
|
+
sessionId: 'sess_run_handoff',
|
|
43
|
+
source: 'task',
|
|
44
|
+
internal: false,
|
|
45
|
+
mode: 'direct',
|
|
46
|
+
status: 'completed',
|
|
47
|
+
messagePreview: 'Ship the next fix',
|
|
48
|
+
queuedAt: now - 5000,
|
|
49
|
+
startedAt: now - 4000,
|
|
50
|
+
endedAt: now - 1000,
|
|
51
|
+
resultPreview: 'Fix shipped and verified.',
|
|
52
|
+
totalInputTokens: 11,
|
|
53
|
+
totalOutputTokens: 22,
|
|
54
|
+
retrievalSummary: { citationCount: 1, sourceIds: ['source_1'] },
|
|
55
|
+
})
|
|
56
|
+
ledger.appendPersistedRunEvent({
|
|
57
|
+
runId: 'run_handoff_1',
|
|
58
|
+
sessionId: 'sess_run_handoff',
|
|
59
|
+
phase: 'status',
|
|
60
|
+
status: 'completed',
|
|
61
|
+
timestamp: now - 1000,
|
|
62
|
+
event: { t: 'md', text: 'Fix shipped and verified.' },
|
|
63
|
+
citations: [{
|
|
64
|
+
sourceId: 'source_1',
|
|
65
|
+
sourceTitle: 'Release evidence',
|
|
66
|
+
sourceKind: 'manual',
|
|
67
|
+
sourceUrl: 'https://example.test/release',
|
|
68
|
+
sourceLabel: null,
|
|
69
|
+
chunkId: 'chunk_1',
|
|
70
|
+
chunkIndex: 0,
|
|
71
|
+
chunkCount: 1,
|
|
72
|
+
charStart: 0,
|
|
73
|
+
charEnd: 20,
|
|
74
|
+
sectionLabel: null,
|
|
75
|
+
snippet: 'Release checks passed.',
|
|
76
|
+
whyMatched: null,
|
|
77
|
+
score: 0.9,
|
|
78
|
+
}],
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const response = await route.GET(
|
|
82
|
+
new Request('http://local/api/runs/run_handoff_1/handoff'),
|
|
83
|
+
{ params: Promise.resolve({ id: 'run_handoff_1' }) },
|
|
84
|
+
)
|
|
85
|
+
const payload = await response.json()
|
|
86
|
+
|
|
87
|
+
const markdownResponse = await route.GET(
|
|
88
|
+
new Request('http://local/api/runs/run_handoff_1/handoff?format=markdown'),
|
|
89
|
+
{ params: Promise.resolve({ id: 'run_handoff_1' }) },
|
|
90
|
+
)
|
|
91
|
+
const markdown = await markdownResponse.text()
|
|
92
|
+
|
|
93
|
+
const missingResponse = await route.GET(
|
|
94
|
+
new Request('http://local/api/runs/missing/handoff'),
|
|
95
|
+
{ params: Promise.resolve({ id: 'missing' }) },
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
console.log(JSON.stringify({
|
|
99
|
+
status: response.status,
|
|
100
|
+
markdownStatus: markdownResponse.status,
|
|
101
|
+
missingStatus: missingResponse.status,
|
|
102
|
+
schemaVersion: payload.schemaVersion,
|
|
103
|
+
readiness: payload.readiness.status,
|
|
104
|
+
artifactCount: payload.artifacts.length,
|
|
105
|
+
markdownContentType: markdownResponse.headers.get('content-type') || '',
|
|
106
|
+
markdownIncludesTitle: markdown.includes('# Run Handoff: Ship the next fix'),
|
|
107
|
+
markdownIncludesCommand: markdown.includes('swarmclaw runs handoff run_handoff_1'),
|
|
108
|
+
}))
|
|
109
|
+
`, { prefix: 'swarmclaw-run-handoff-route-' })
|
|
110
|
+
|
|
111
|
+
assert.equal(output.status, 200)
|
|
112
|
+
assert.equal(output.markdownStatus, 200)
|
|
113
|
+
assert.equal(output.missingStatus, 404)
|
|
114
|
+
assert.equal(output.schemaVersion, 1)
|
|
115
|
+
assert.equal(output.readiness, 'ready')
|
|
116
|
+
assert.equal(output.artifactCount >= 1, true)
|
|
117
|
+
assert.match(output.markdownContentType, /text\/markdown/)
|
|
118
|
+
assert.equal(output.markdownIncludesTitle, true)
|
|
119
|
+
assert.equal(output.markdownIncludesCommand, true)
|
|
120
|
+
})
|
package/src/cli/index.js
CHANGED
|
@@ -572,6 +572,7 @@ const COMMAND_GROUPS = [
|
|
|
572
572
|
cmd('get', 'GET', '/runs/:id', 'Get run by id'),
|
|
573
573
|
cmd('events', 'GET', '/runs/:id/events', 'Get run event history by run id'),
|
|
574
574
|
cmd('brief', 'GET', '/runs/:id/brief', 'Get deterministic run brief by run id'),
|
|
575
|
+
cmd('handoff', 'GET', '/runs/:id/handoff', 'Get run handoff packet by run id'),
|
|
575
576
|
],
|
|
576
577
|
},
|
|
577
578
|
{
|
package/src/cli/spec.js
CHANGED
|
@@ -552,6 +552,7 @@ const COMMAND_GROUPS = {
|
|
|
552
552
|
get: { description: 'Get run by id', method: 'GET', path: '/runs/:id', params: ['id'] },
|
|
553
553
|
events: { description: 'Get run event history by run id', method: 'GET', path: '/runs/:id/events', params: ['id'] },
|
|
554
554
|
brief: { description: 'Get deterministic run brief by run id', method: 'GET', path: '/runs/:id/brief', params: ['id'] },
|
|
555
|
+
handoff: { description: 'Get run handoff packet by run id', method: 'GET', path: '/runs/:id/handoff', params: ['id'] },
|
|
555
556
|
},
|
|
556
557
|
},
|
|
557
558
|
webhooks: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useState, useCallback } from 'react'
|
|
4
|
+
import { ClipboardList } from 'lucide-react'
|
|
4
5
|
import { api } from '@/lib/app/api-client'
|
|
5
6
|
import { useNow } from '@/hooks/use-now'
|
|
6
7
|
import { useWs } from '@/hooks/use-ws'
|
|
@@ -10,6 +11,7 @@ import type { EvidenceArtifact, RunBrief, RunEventRecord, SessionRunRecord, Sess
|
|
|
10
11
|
import { PageLoader } from '@/components/ui/page-loader'
|
|
11
12
|
import { formatElapsed } from '@/lib/format-display'
|
|
12
13
|
import { GroundingPanel } from '@/components/knowledge/grounding-panel'
|
|
14
|
+
import { copyTextToClipboard } from '@/lib/clipboard'
|
|
13
15
|
|
|
14
16
|
const STATUS_COLORS: Record<SessionRunStatus, { bg: string; text: string }> = {
|
|
15
17
|
queued: { bg: 'bg-yellow-500/10', text: 'text-yellow-400' },
|
|
@@ -47,6 +49,9 @@ export function RunList() {
|
|
|
47
49
|
const [eventsLoading, setEventsLoading] = useState(false)
|
|
48
50
|
const [briefLoading, setBriefLoading] = useState(false)
|
|
49
51
|
const [artifactsLoading, setArtifactsLoading] = useState(false)
|
|
52
|
+
const [handoffCopying, setHandoffCopying] = useState(false)
|
|
53
|
+
const [handoffCopied, setHandoffCopied] = useState(false)
|
|
54
|
+
const [handoffError, setHandoffError] = useState<string | null>(null)
|
|
50
55
|
|
|
51
56
|
const fetchRuns = useCallback(async () => {
|
|
52
57
|
try {
|
|
@@ -57,7 +62,6 @@ export function RunList() {
|
|
|
57
62
|
}, [])
|
|
58
63
|
|
|
59
64
|
useEffect(() => {
|
|
60
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
61
65
|
fetchRuns()
|
|
62
66
|
}, [fetchRuns])
|
|
63
67
|
|
|
@@ -102,8 +106,30 @@ export function RunList() {
|
|
|
102
106
|
setEventsLoading(true)
|
|
103
107
|
setBriefLoading(true)
|
|
104
108
|
setArtifactsLoading(true)
|
|
109
|
+
setHandoffCopied(false)
|
|
110
|
+
setHandoffError(null)
|
|
105
111
|
}, [])
|
|
106
112
|
|
|
113
|
+
const copyRunHandoff = useCallback(async () => {
|
|
114
|
+
if (!selected || handoffCopying) return
|
|
115
|
+
setHandoffCopying(true)
|
|
116
|
+
setHandoffError(null)
|
|
117
|
+
try {
|
|
118
|
+
const markdown = await api<string>('GET', `/runs/${selected.id}/handoff?format=markdown`)
|
|
119
|
+
const copied = await copyTextToClipboard(markdown)
|
|
120
|
+
if (!copied) {
|
|
121
|
+
setHandoffError('Clipboard unavailable.')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
setHandoffCopied(true)
|
|
125
|
+
setTimeout(() => setHandoffCopied(false), 2000)
|
|
126
|
+
} catch {
|
|
127
|
+
setHandoffError('Could not copy handoff.')
|
|
128
|
+
} finally {
|
|
129
|
+
setHandoffCopying(false)
|
|
130
|
+
}
|
|
131
|
+
}, [handoffCopying, selected])
|
|
132
|
+
|
|
107
133
|
const sources = useMemo(() => {
|
|
108
134
|
return Array.from(new Set(runs.map((run) => run.source).filter(Boolean))).sort((a, b) => a.localeCompare(b))
|
|
109
135
|
}, [runs])
|
|
@@ -248,12 +274,24 @@ export function RunList() {
|
|
|
248
274
|
{selected && (
|
|
249
275
|
<div style={{ animation: 'fade-in 0.3s ease' }}>
|
|
250
276
|
<div className="mb-6">
|
|
251
|
-
<div className="flex items-center gap-3
|
|
252
|
-
<
|
|
253
|
-
{selected.status}
|
|
254
|
-
|
|
255
|
-
|
|
277
|
+
<div className="mb-3 flex flex-wrap items-center gap-3">
|
|
278
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
279
|
+
<span className={`text-[11px] font-700 uppercase tracking-wider px-2.5 py-1 rounded-[6px] ${STATUS_COLORS[selected.status].bg} ${STATUS_COLORS[selected.status].text}`}>
|
|
280
|
+
{selected.status}
|
|
281
|
+
</span>
|
|
282
|
+
<span className="text-[12px] font-mono text-text-3/60">{selected.source}</span>
|
|
283
|
+
</div>
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={copyRunHandoff}
|
|
287
|
+
disabled={handoffCopying}
|
|
288
|
+
className="ml-auto inline-flex items-center gap-1.5 rounded-[8px] border border-white/[0.07] bg-white/[0.04] px-2.5 py-1.5 text-[11px] font-700 text-text-2 transition-colors hover:bg-white/[0.07] disabled:cursor-not-allowed disabled:opacity-60"
|
|
289
|
+
>
|
|
290
|
+
<ClipboardList size={13} />
|
|
291
|
+
{handoffCopied ? 'Copied' : handoffCopying ? 'Copying...' : 'Copy Handoff'}
|
|
292
|
+
</button>
|
|
256
293
|
</div>
|
|
294
|
+
{handoffError && <p className="mb-3 text-[11px] font-600 text-red-400">{handoffError}</p>}
|
|
257
295
|
<h2 className="font-display text-[20px] font-700 tracking-[-0.02em] mb-2 leading-snug">
|
|
258
296
|
Run Details
|
|
259
297
|
</h2>
|
|
@@ -307,7 +307,7 @@ describe('main-agent-loop', () => {
|
|
|
307
307
|
agentId: 'agent-a',
|
|
308
308
|
taskIds: [],
|
|
309
309
|
currentStep: 'Verify the release checklist',
|
|
310
|
-
plannerSummary: 'Use the mission
|
|
310
|
+
plannerSummary: 'Use the mission runtime instead of legacy tags.',
|
|
311
311
|
verifierSummary: null,
|
|
312
312
|
blockerSummary: null,
|
|
313
313
|
waitState: null,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildRunHandoffPacket,
|
|
6
|
+
formatRunHandoffMarkdown,
|
|
7
|
+
} from './run-handoff'
|
|
8
|
+
import type { EvidenceArtifact, RunBrief, SessionRunRecord } from '@/types'
|
|
9
|
+
|
|
10
|
+
function run(overrides: Partial<SessionRunRecord> = {}): SessionRunRecord {
|
|
11
|
+
return {
|
|
12
|
+
id: overrides.id || 'run_1',
|
|
13
|
+
sessionId: overrides.sessionId || 'sess_1',
|
|
14
|
+
source: overrides.source || 'task',
|
|
15
|
+
internal: overrides.internal ?? false,
|
|
16
|
+
mode: overrides.mode || 'direct',
|
|
17
|
+
status: overrides.status || 'completed',
|
|
18
|
+
messagePreview: overrides.messagePreview || 'Verify the release',
|
|
19
|
+
queuedAt: overrides.queuedAt ?? 1000,
|
|
20
|
+
startedAt: overrides.startedAt ?? 1500,
|
|
21
|
+
endedAt: overrides.endedAt ?? 4500,
|
|
22
|
+
resultPreview: overrides.resultPreview || 'Release verified with browser smoke evidence.',
|
|
23
|
+
ownerType: overrides.ownerType ?? 'task',
|
|
24
|
+
ownerId: overrides.ownerId ?? 'task_1',
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function brief(overrides: Partial<RunBrief> = {}): RunBrief {
|
|
30
|
+
return {
|
|
31
|
+
runId: overrides.runId || 'run_1',
|
|
32
|
+
sessionId: overrides.sessionId || 'sess_1',
|
|
33
|
+
title: overrides.title || 'Verify the release',
|
|
34
|
+
objective: overrides.objective || 'Verify the release',
|
|
35
|
+
status: overrides.status || 'completed',
|
|
36
|
+
source: overrides.source || 'task',
|
|
37
|
+
owner: overrides.owner ?? { type: 'task', id: 'task_1' },
|
|
38
|
+
timeline: overrides.timeline || [
|
|
39
|
+
{ label: 'Queued', status: 'queued', at: 1000 },
|
|
40
|
+
{ label: 'Started', status: 'running', at: 1500 },
|
|
41
|
+
{ label: 'Ended', status: 'completed', at: 4500 },
|
|
42
|
+
],
|
|
43
|
+
result: overrides.result ?? 'Release verified with browser smoke evidence.',
|
|
44
|
+
error: overrides.error ?? null,
|
|
45
|
+
warnings: overrides.warnings || [],
|
|
46
|
+
usage: overrides.usage || {
|
|
47
|
+
inputTokens: 10,
|
|
48
|
+
outputTokens: 20,
|
|
49
|
+
estimatedCost: 0.01,
|
|
50
|
+
citationCount: 1,
|
|
51
|
+
sourceIds: ['source_1'],
|
|
52
|
+
},
|
|
53
|
+
evidence: overrides.evidence || [{
|
|
54
|
+
id: 'evidence_1',
|
|
55
|
+
kind: 'event',
|
|
56
|
+
title: 'Smoke test',
|
|
57
|
+
summary: 'Browser smoke passed.',
|
|
58
|
+
createdAt: 4300,
|
|
59
|
+
}],
|
|
60
|
+
generatedAt: overrides.generatedAt ?? 5000,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function artifact(overrides: Partial<EvidenceArtifact> = {}): EvidenceArtifact {
|
|
65
|
+
return {
|
|
66
|
+
id: overrides.id || 'artifact_1',
|
|
67
|
+
kind: overrides.kind || 'run_result',
|
|
68
|
+
title: overrides.title || 'Run result',
|
|
69
|
+
preview: overrides.preview || 'Release verified.',
|
|
70
|
+
createdAt: overrides.createdAt ?? 4500,
|
|
71
|
+
source: overrides.source || { type: 'run', id: 'run_1', label: 'Verify the release' },
|
|
72
|
+
...overrides,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('run handoff packets', () => {
|
|
77
|
+
it('summarizes a completed run with evidence, artifacts, and resume commands', () => {
|
|
78
|
+
const packet = buildRunHandoffPacket(run(), brief(), [artifact()], 6000)
|
|
79
|
+
|
|
80
|
+
assert.equal(packet.schemaVersion, 1)
|
|
81
|
+
assert.equal(packet.runId, 'run_1')
|
|
82
|
+
assert.equal(packet.readiness.status, 'ready')
|
|
83
|
+
assert.equal(packet.timing.durationMs, 3000)
|
|
84
|
+
assert.equal(packet.evidence.length, 1)
|
|
85
|
+
assert.equal(packet.artifacts.length, 1)
|
|
86
|
+
assert.ok(packet.resume.commands.some((command) => command.includes('swarmclaw runs handoff run_1')))
|
|
87
|
+
assert.deepEqual(packet.readiness.recommendedActions, ['Handoff packet is ready to share.'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('marks failed and under-evidenced runs as needing attention', () => {
|
|
91
|
+
const packet = buildRunHandoffPacket(
|
|
92
|
+
run({ status: 'failed', error: 'Provider timed out.', resultPreview: undefined }),
|
|
93
|
+
brief({ status: 'failed', result: null, error: 'Provider timed out.', warnings: ['Run failed and needs review before using the result.'], evidence: [] }),
|
|
94
|
+
[],
|
|
95
|
+
6000,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert.equal(packet.readiness.status, 'blocked')
|
|
99
|
+
assert.ok(packet.readiness.recommendedActions.some((action) => action.includes('Review the run error')))
|
|
100
|
+
assert.ok(packet.outcome.warnings.length > 0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('formats concise markdown for handoff into another operator context', () => {
|
|
104
|
+
const markdown = formatRunHandoffMarkdown(buildRunHandoffPacket(run(), brief(), [artifact({ url: '/api/files/serve?path=result.md' })], 6000))
|
|
105
|
+
|
|
106
|
+
assert.match(markdown, /^# Run Handoff: Verify the release/)
|
|
107
|
+
assert.match(markdown, /Run ID: run_1/)
|
|
108
|
+
assert.match(markdown, /## Outcome/)
|
|
109
|
+
assert.match(markdown, /Browser smoke passed/)
|
|
110
|
+
assert.match(markdown, /swarmclaw chats context-pack sess_1/)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { EvidenceArtifact, RunBrief, RunHandoffPacket, RunHandoffReadinessStatus, SessionRunRecord } from '@/types'
|
|
2
|
+
|
|
3
|
+
const MAX_TEXT = 900
|
|
4
|
+
const MAX_EVIDENCE = 12
|
|
5
|
+
const MAX_ARTIFACTS = 16
|
|
6
|
+
|
|
7
|
+
function compactText(value: string | null | undefined, maxChars = MAX_TEXT): string | null {
|
|
8
|
+
const text = (value || '').split(/\s+/).filter(Boolean).join(' ').trim()
|
|
9
|
+
if (!text) return null
|
|
10
|
+
return text.length > maxChars ? `${text.slice(0, maxChars - 3)}...` : text
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toIso(value: number | null | undefined): string {
|
|
14
|
+
return value && Number.isFinite(value) ? new Date(value).toISOString() : 'n/a'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function durationMs(run: SessionRunRecord, now: number): number | null {
|
|
18
|
+
if (!run.startedAt) return null
|
|
19
|
+
const end = run.endedAt || now
|
|
20
|
+
if (!Number.isFinite(end) || end < run.startedAt) return null
|
|
21
|
+
return Math.max(0, Math.trunc(end - run.startedAt))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readinessStatus(run: SessionRunRecord, brief: RunBrief, artifacts: EvidenceArtifact[]): RunHandoffReadinessStatus {
|
|
25
|
+
if (run.status === 'failed') return 'blocked'
|
|
26
|
+
if (run.status === 'cancelled') return 'needs_attention'
|
|
27
|
+
if (run.status === 'queued' || run.status === 'running') return 'needs_attention'
|
|
28
|
+
if (brief.warnings.length > 0) return 'needs_attention'
|
|
29
|
+
if (!compactText(brief.result || run.resultPreview)) return 'needs_attention'
|
|
30
|
+
if (brief.evidence.length === 0 && artifacts.length === 0) return 'needs_attention'
|
|
31
|
+
return 'ready'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function recommendedActions(run: SessionRunRecord, brief: RunBrief, artifacts: EvidenceArtifact[]): string[] {
|
|
35
|
+
const actions: string[] = []
|
|
36
|
+
if (run.status === 'failed') actions.push('Review the run error, fix the cause, then rerun from the source session or owner.')
|
|
37
|
+
if (run.status === 'cancelled') actions.push('Review why the run was cancelled before continuing the handoff.')
|
|
38
|
+
if (run.status === 'queued' || run.status === 'running') actions.push('Wait for the run to finish or cancel it before using the result as final.')
|
|
39
|
+
if (!compactText(brief.result || run.resultPreview) && run.status === 'completed') actions.push('Record a result summary before sharing this run.')
|
|
40
|
+
if (brief.evidence.length === 0 && artifacts.length === 0 && run.status === 'completed') {
|
|
41
|
+
actions.push('Attach evidence, artifacts, or a task report if another operator will continue from this run.')
|
|
42
|
+
}
|
|
43
|
+
for (const warning of brief.warnings) {
|
|
44
|
+
actions.push(warning)
|
|
45
|
+
}
|
|
46
|
+
return actions.length > 0 ? Array.from(new Set(actions)).slice(0, 8) : ['Handoff packet is ready to share.']
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resumeCommands(run: SessionRunRecord): string[] {
|
|
50
|
+
return [
|
|
51
|
+
`swarmclaw runs handoff ${run.id} --query format=markdown`,
|
|
52
|
+
`swarmclaw runs brief ${run.id}`,
|
|
53
|
+
`swarmclaw chats context-pack ${run.sessionId} --query format=markdown`,
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildRunHandoffPacket(
|
|
58
|
+
run: SessionRunRecord,
|
|
59
|
+
brief: RunBrief,
|
|
60
|
+
artifacts: EvidenceArtifact[] = [],
|
|
61
|
+
now = Date.now(),
|
|
62
|
+
): RunHandoffPacket {
|
|
63
|
+
const limitedArtifacts = artifacts.slice(0, MAX_ARTIFACTS)
|
|
64
|
+
return {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
runId: run.id,
|
|
67
|
+
sessionId: run.sessionId,
|
|
68
|
+
title: compactText(brief.title || run.messagePreview, 160) || run.id,
|
|
69
|
+
objective: compactText(brief.objective || run.messagePreview, 1400) || run.source,
|
|
70
|
+
source: run.source,
|
|
71
|
+
mode: run.mode,
|
|
72
|
+
status: run.status,
|
|
73
|
+
owner: brief.owner || (run.ownerType && run.ownerId ? { type: run.ownerType, id: run.ownerId } : null),
|
|
74
|
+
generatedAt: now,
|
|
75
|
+
timing: {
|
|
76
|
+
queuedAt: run.queuedAt,
|
|
77
|
+
startedAt: run.startedAt || null,
|
|
78
|
+
endedAt: run.endedAt || null,
|
|
79
|
+
durationMs: durationMs(run, now),
|
|
80
|
+
},
|
|
81
|
+
outcome: {
|
|
82
|
+
result: compactText(brief.result || run.resultPreview, 1400),
|
|
83
|
+
error: compactText(brief.error || run.error, 1400),
|
|
84
|
+
warnings: brief.warnings.slice(0, 12),
|
|
85
|
+
},
|
|
86
|
+
usage: brief.usage,
|
|
87
|
+
timeline: brief.timeline.slice(0, 20),
|
|
88
|
+
evidence: brief.evidence.slice(0, MAX_EVIDENCE),
|
|
89
|
+
artifacts: limitedArtifacts,
|
|
90
|
+
resume: {
|
|
91
|
+
sessionId: run.sessionId,
|
|
92
|
+
commands: resumeCommands(run),
|
|
93
|
+
links: [
|
|
94
|
+
{ label: 'Run events', href: `/api/runs/${encodeURIComponent(run.id)}/events` },
|
|
95
|
+
{ label: 'Run brief', href: `/api/runs/${encodeURIComponent(run.id)}/brief` },
|
|
96
|
+
{ label: 'Session context pack', href: `/api/chats/${encodeURIComponent(run.sessionId)}/context-pack?format=markdown` },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
readiness: {
|
|
100
|
+
status: readinessStatus(run, brief, limitedArtifacts),
|
|
101
|
+
recommendedActions: recommendedActions(run, brief, limitedArtifacts),
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function appendSection(lines: string[], title: string, body: string[] = []) {
|
|
107
|
+
lines.push('', `## ${title}`)
|
|
108
|
+
if (body.length === 0) lines.push('None.')
|
|
109
|
+
else lines.push(...body)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function artifactLine(artifact: EvidenceArtifact): string {
|
|
113
|
+
const target = artifact.url || artifact.href || ''
|
|
114
|
+
const preview = compactText(artifact.preview || artifact.description, 280)
|
|
115
|
+
return `- ${artifact.title} (${artifact.kind})${target ? ` ${target}` : ''}${preview ? `: ${preview}` : ''}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatRunHandoffMarkdown(packet: RunHandoffPacket): string {
|
|
119
|
+
const owner = packet.owner ? `${packet.owner.type}:${packet.owner.id}` : 'unassigned'
|
|
120
|
+
const duration = packet.timing.durationMs == null ? 'n/a' : `${Math.round(packet.timing.durationMs / 1000)}s`
|
|
121
|
+
const lines = [
|
|
122
|
+
`# Run Handoff: ${packet.title}`,
|
|
123
|
+
'',
|
|
124
|
+
`Generated: ${toIso(packet.generatedAt)}`,
|
|
125
|
+
`Run ID: ${packet.runId}`,
|
|
126
|
+
`Session ID: ${packet.sessionId}`,
|
|
127
|
+
`Status: ${packet.status}`,
|
|
128
|
+
`Readiness: ${packet.readiness.status}`,
|
|
129
|
+
`Source: ${packet.source}`,
|
|
130
|
+
`Owner: ${owner}`,
|
|
131
|
+
`Duration: ${duration}`,
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
appendSection(lines, 'Objective', [packet.objective])
|
|
135
|
+
|
|
136
|
+
appendSection(lines, 'Outcome', [
|
|
137
|
+
packet.outcome.result ? `- Result: ${packet.outcome.result}` : '',
|
|
138
|
+
packet.outcome.error ? `- Error: ${packet.outcome.error}` : '',
|
|
139
|
+
...packet.outcome.warnings.map((warning) => `- Warning: ${warning}`),
|
|
140
|
+
].filter(Boolean))
|
|
141
|
+
|
|
142
|
+
appendSection(lines, 'Timeline', packet.timeline.map((item) => {
|
|
143
|
+
const status = item.status ? ` (${item.status})` : ''
|
|
144
|
+
const detail = item.detail ? `: ${compactText(item.detail, 260)}` : ''
|
|
145
|
+
return `- ${item.label}${status} at ${toIso(item.at)}${detail}`
|
|
146
|
+
}))
|
|
147
|
+
|
|
148
|
+
appendSection(lines, 'Evidence', packet.evidence.map((item) => {
|
|
149
|
+
const url = item.url ? ` ${item.url}` : ''
|
|
150
|
+
return `- ${item.title} (${item.kind})${url}: ${item.summary}`
|
|
151
|
+
}))
|
|
152
|
+
|
|
153
|
+
appendSection(lines, 'Artifacts', packet.artifacts.map(artifactLine))
|
|
154
|
+
|
|
155
|
+
appendSection(lines, 'Usage', [
|
|
156
|
+
`- Input tokens: ${packet.usage.inputTokens ?? 0}`,
|
|
157
|
+
`- Output tokens: ${packet.usage.outputTokens ?? 0}`,
|
|
158
|
+
packet.usage.estimatedCost != null ? `- Estimated cost: $${packet.usage.estimatedCost.toFixed(4)}` : '',
|
|
159
|
+
`- Citations: ${packet.usage.citationCount}`,
|
|
160
|
+
packet.usage.sourceIds.length > 0 ? `- Sources: ${packet.usage.sourceIds.join(', ')}` : '',
|
|
161
|
+
].filter(Boolean))
|
|
162
|
+
|
|
163
|
+
appendSection(lines, 'Resume', [
|
|
164
|
+
...packet.resume.commands.map((command) => `- \`${command}\``),
|
|
165
|
+
...packet.resume.links.map((link) => `- ${link.label}: ${link.href}`),
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
appendSection(lines, 'Recommended Actions', packet.readiness.recommendedActions.map((action) => `- ${action}`))
|
|
169
|
+
|
|
170
|
+
return `${lines.join('\n')}\n`
|
|
171
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EvidenceArtifact } from './artifact'
|
|
2
|
+
import type { ExecutionOwnerType, SessionRunStatus } from './run'
|
|
3
|
+
import type { RunBriefEvidenceItem, RunBriefTimelineItem } from './run-brief'
|
|
4
|
+
|
|
5
|
+
export type RunHandoffReadinessStatus = 'ready' | 'needs_attention' | 'blocked'
|
|
6
|
+
|
|
7
|
+
export interface RunHandoffPacket {
|
|
8
|
+
schemaVersion: 1
|
|
9
|
+
runId: string
|
|
10
|
+
sessionId: string
|
|
11
|
+
title: string
|
|
12
|
+
objective: string
|
|
13
|
+
source: string
|
|
14
|
+
mode: string
|
|
15
|
+
status: SessionRunStatus
|
|
16
|
+
owner: { type: ExecutionOwnerType; id: string } | null
|
|
17
|
+
generatedAt: number
|
|
18
|
+
timing: {
|
|
19
|
+
queuedAt: number
|
|
20
|
+
startedAt: number | null
|
|
21
|
+
endedAt: number | null
|
|
22
|
+
durationMs: number | null
|
|
23
|
+
}
|
|
24
|
+
outcome: {
|
|
25
|
+
result: string | null
|
|
26
|
+
error: string | null
|
|
27
|
+
warnings: string[]
|
|
28
|
+
}
|
|
29
|
+
usage: {
|
|
30
|
+
inputTokens: number | null
|
|
31
|
+
outputTokens: number | null
|
|
32
|
+
estimatedCost: number | null
|
|
33
|
+
citationCount: number
|
|
34
|
+
sourceIds: string[]
|
|
35
|
+
}
|
|
36
|
+
timeline: RunBriefTimelineItem[]
|
|
37
|
+
evidence: RunBriefEvidenceItem[]
|
|
38
|
+
artifacts: EvidenceArtifact[]
|
|
39
|
+
resume: {
|
|
40
|
+
sessionId: string
|
|
41
|
+
commands: string[]
|
|
42
|
+
links: Array<{ label: string; href: string }>
|
|
43
|
+
}
|
|
44
|
+
readiness: {
|
|
45
|
+
status: RunHandoffReadinessStatus
|
|
46
|
+
recommendedActions: string[]
|
|
47
|
+
}
|
|
48
|
+
}
|