@swarmclawai/swarmclaw 1.9.3 → 1.9.4
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 +15 -5
- package/package.json +1 -1
- package/src/app/api/gateways/[id]/environments/[environmentId]/route.ts +16 -0
- package/src/app/api/gateways/[id]/environments/route.ts +13 -0
- package/src/app/api/gateways/topology-route.test.ts +30 -0
- package/src/app/api/tasks/task-workspace-route.test.ts +4 -0
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/providers/provider-list.tsx +34 -1
- package/src/components/tasks/task-sheet.tsx +50 -0
- package/src/features/gateways/queries.ts +3 -0
- package/src/lib/server/gateways/gateway-profile-service.ts +2 -0
- package/src/lib/server/gateways/gateway-topology.test.ts +59 -3
- package/src/lib/server/gateways/gateway-topology.ts +129 -3
- package/src/lib/server/operations/operation-pulse.test.ts +29 -0
- package/src/lib/server/operations/operation-pulse.ts +9 -0
- package/src/lib/server/tasks/task-execution-workspace.test.ts +14 -0
- package/src/lib/server/tasks/task-execution-workspace.ts +133 -6
- package/src/types/misc.ts +31 -0
- package/src/types/task.ts +30 -0
package/README.md
CHANGED
|
@@ -399,19 +399,29 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.9.4 Highlights
|
|
403
|
+
|
|
404
|
+
Bundled runtime-environment release: gateway execution visibility, task context handoff, and operator triage in one release cycle.
|
|
405
|
+
|
|
406
|
+
- **OpenClaw environments.** Gateway topology now calls `environments.list`, stores available environment counts, exposes `/api/gateways/:id/environments`, and adds CLI commands for list/status checks.
|
|
407
|
+
- **Provider dashboard visibility.** The Providers screen now shows fleet-wide and per-gateway execution environment availability alongside nodes, sessions, presence, and pairings.
|
|
408
|
+
- **Task context packets.** Prepared task workspaces now write `context.json` with task, preview, runtime, blocker, tag, and upstream-result context for external workers.
|
|
409
|
+
- **Runtime env handoff.** Workspaces now include `.env.swarmclaw` plus SwarmClaw, portable task/workspace, and `AGENT_HOME` env hints without embedding secrets.
|
|
410
|
+
- **Operations Pulse triage.** Gateway actions now surface zero-available-environment states as high-priority operator work.
|
|
411
|
+
|
|
402
412
|
### v1.9.3 Highlights
|
|
403
413
|
|
|
404
|
-
Bundled extension-orchestration release:
|
|
414
|
+
Bundled extension-orchestration release: managed plugin resources, gateway/setup declarations, and safer local folder access in one release cycle.
|
|
405
415
|
|
|
406
|
-
- **Managed extension resources.** Extensions can now declare provisionable agents, schedules/routines, local folders, gateway platforms, and setup checks through `managedResources` or
|
|
416
|
+
- **Managed extension resources.** Extensions can now declare provisionable agents, schedules/routines, local folders, gateway platforms, and setup checks through `managedResources` or top-level manifest aliases.
|
|
407
417
|
- **Deterministic reconciliation.** `/api/extensions/managed-resources` can preview and reconcile extension-owned agents and routines with stable IDs and `managedByExtension` markers.
|
|
408
418
|
- **Trusted local folders.** Extension-declared local folders support root-bounded inspection and recursive listing with traversal and symlink-escape protection.
|
|
409
419
|
- **Operator UI.** The Extensions screen now shows managed-resource badges and a Managed tab with totals plus per-extension reconcile controls.
|
|
410
|
-
- **Extension authoring spec.** `extension_creator` now documents managed resources, gateway declarations, setup checks, and
|
|
420
|
+
- **Extension authoring spec.** `extension_creator` now documents managed resources, gateway declarations, setup checks, and manifest aliases.
|
|
411
421
|
|
|
412
422
|
### v1.9.2 Highlights
|
|
413
423
|
|
|
414
|
-
Bundled
|
|
424
|
+
Bundled runtime-polish release: reasoning hygiene, deterministic delegation routing, task workflow polish, OpenClaw export hardening, and timeout hygiene.
|
|
415
425
|
|
|
416
426
|
- **Stateful reasoning tag scrubber.** String-streamed `<think>`, `<thinking>`, `<reasoning>`, `<thought>`, and `<REASONING_SCRATCHPAD>` blocks are removed across split deltas and routed into SwarmClaw's thinking stream instead of leaking into visible answers.
|
|
417
427
|
- **Deterministic delegation profiles.** `manage_tasks` now accepts explicit `workType` and `requiredCapabilities` routing hints, returns a stable `routeKey`, and can auto-assign unowned work without a classifier call when the profile is explicit.
|
|
@@ -421,7 +431,7 @@ Bundled competitor-parity release: Hermes-style reasoning hygiene, deterministic
|
|
|
421
431
|
|
|
422
432
|
### v1.9.1 Highlights
|
|
423
433
|
|
|
424
|
-
Task execution workspace release:
|
|
434
|
+
Task execution workspace release: task-scoped workspaces, preview handoffs, and liveness evidence.
|
|
425
435
|
|
|
426
436
|
- **Task-scoped execution workspaces.** Tasks can now provision a deterministic workspace under the SwarmClaw workspace root, preserving source cwd and project context while creating a task-local README for artifacts and handoffs.
|
|
427
437
|
- **Preview and runtime metadata.** Tasks can carry preview links and runtime services, and the task board surfaces those links directly on task cards and sheets.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.4",
|
|
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",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
4
|
+
import { getOpenClawGatewayEnvironmentStatus } from '@/lib/server/gateways/gateway-topology'
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
export async function GET(
|
|
9
|
+
_req: Request,
|
|
10
|
+
{ params }: { params: Promise<{ id: string; environmentId: string }> },
|
|
11
|
+
) {
|
|
12
|
+
const { id, environmentId } = await params
|
|
13
|
+
const snapshot = await getOpenClawGatewayEnvironmentStatus(id, decodeURIComponent(environmentId))
|
|
14
|
+
if (!snapshot) return notFound()
|
|
15
|
+
return NextResponse.json(snapshot, { status: snapshot.errors.length > 0 ? 502 : 200 })
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
4
|
+
import { listOpenClawGatewayEnvironments } from '@/lib/server/gateways/gateway-topology'
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
9
|
+
const { id } = await params
|
|
10
|
+
const snapshot = await listOpenClawGatewayEnvironments(id)
|
|
11
|
+
if (!snapshot) return notFound()
|
|
12
|
+
return NextResponse.json(snapshot)
|
|
13
|
+
}
|
|
@@ -18,6 +18,36 @@ test('gateway topology route returns 404 for unknown profiles', () => {
|
|
|
18
18
|
assert.equal(output.body.error, 'Not found')
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
+
test('gateway environments route returns 404 for unknown profiles', () => {
|
|
22
|
+
const output = runWithTempDataDir<{ status: number; body: { error: string } }>(`
|
|
23
|
+
const routeMod = await import('./src/app/api/gateways/[id]/environments/route')
|
|
24
|
+
const route = routeMod.default || routeMod
|
|
25
|
+
const response = await route.GET(
|
|
26
|
+
new Request('http://local/api/gateways/missing/environments'),
|
|
27
|
+
{ params: Promise.resolve({ id: 'missing' }) },
|
|
28
|
+
)
|
|
29
|
+
console.log(JSON.stringify({ status: response.status, body: await response.json() }))
|
|
30
|
+
`, { prefix: 'swarmclaw-gateway-environments-route-test-' })
|
|
31
|
+
|
|
32
|
+
assert.equal(output.status, 404)
|
|
33
|
+
assert.equal(output.body.error, 'Not found')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('gateway environment status route returns 404 for unknown profiles', () => {
|
|
37
|
+
const output = runWithTempDataDir<{ status: number; body: { error: string } }>(`
|
|
38
|
+
const routeMod = await import('./src/app/api/gateways/[id]/environments/[environmentId]/route')
|
|
39
|
+
const route = routeMod.default || routeMod
|
|
40
|
+
const response = await route.GET(
|
|
41
|
+
new Request('http://local/api/gateways/missing/environments/gateway'),
|
|
42
|
+
{ params: Promise.resolve({ id: 'missing', environmentId: 'gateway' }) },
|
|
43
|
+
)
|
|
44
|
+
console.log(JSON.stringify({ status: response.status, body: await response.json() }))
|
|
45
|
+
`, { prefix: 'swarmclaw-gateway-environment-status-route-test-' })
|
|
46
|
+
|
|
47
|
+
assert.equal(output.status, 404)
|
|
48
|
+
assert.equal(output.body.error, 'Not found')
|
|
49
|
+
})
|
|
50
|
+
|
|
21
51
|
test('gateway fleet route reports empty totals when no OpenClaw profiles exist', () => {
|
|
22
52
|
const output = runWithTempDataDir<{
|
|
23
53
|
status: number
|
|
@@ -81,9 +81,13 @@ test('PUT /api/tasks/:id provisions an execution workspace and preview links', a
|
|
|
81
81
|
assert.equal(response.status, 200)
|
|
82
82
|
const body = await response.json() as BoardTask
|
|
83
83
|
assert.equal(body.executionWorkspace?.sourceCwd, '/source/repo')
|
|
84
|
+
assert.equal(body.executionWorkspace?.context?.taskId, 'task-route-workspace')
|
|
85
|
+
assert.equal(body.executionWorkspace?.envHints?.some((hint) => hint.key === 'WORKSPACE_CWD'), true)
|
|
84
86
|
assert.equal(body.previewLinks?.[0]?.url, 'http://127.0.0.1:3456')
|
|
85
87
|
assert.equal(body.runtimeServices?.[0]?.name, 'Next dev')
|
|
86
88
|
assert.equal(fs.existsSync(body.executionWorkspace?.path || ''), true)
|
|
89
|
+
assert.equal(fs.existsSync(body.executionWorkspace?.contextPath || ''), true)
|
|
90
|
+
assert.equal(fs.existsSync(body.executionWorkspace?.envPath || ''), true)
|
|
87
91
|
})
|
|
88
92
|
|
|
89
93
|
test('GET /api/tasks returns computed blocked liveness without persisting a task patch', async () => {
|
package/src/cli/index.js
CHANGED
|
@@ -273,6 +273,8 @@ const COMMAND_GROUPS = [
|
|
|
273
273
|
cmd('delete', 'DELETE', '/gateways/:id', 'Delete a gateway profile'),
|
|
274
274
|
cmd('health', 'GET', '/gateways/:id/health', 'Run a gateway health check'),
|
|
275
275
|
cmd('topology', 'GET', '/gateways/:id/topology', 'Refresh and return one gateway topology snapshot'),
|
|
276
|
+
cmd('environments', 'GET', '/gateways/:id/environments', 'List OpenClaw gateway execution environments'),
|
|
277
|
+
cmd('environment-status', 'GET', '/gateways/:id/environments/:environmentId', 'Get one OpenClaw gateway execution environment status'),
|
|
276
278
|
cmd('fleet', 'GET', '/gateways/fleet', 'Refresh and return fleet-wide gateway topology'),
|
|
277
279
|
],
|
|
278
280
|
},
|
package/src/cli/spec.js
CHANGED
|
@@ -221,6 +221,8 @@ const COMMAND_GROUPS = {
|
|
|
221
221
|
delete: { description: 'Delete a gateway profile', method: 'DELETE', path: '/gateways/:id', params: ['id'] },
|
|
222
222
|
health: { description: 'Run a gateway health check', method: 'GET', path: '/gateways/:id/health', params: ['id'] },
|
|
223
223
|
topology: { description: 'Refresh and return one gateway topology snapshot', method: 'GET', path: '/gateways/:id/topology', params: ['id'] },
|
|
224
|
+
environments: { description: 'List OpenClaw gateway execution environments', method: 'GET', path: '/gateways/:id/environments', params: ['id'] },
|
|
225
|
+
'environment-status': { description: 'Get one OpenClaw gateway execution environment status', method: 'GET', path: '/gateways/:id/environments/:environmentId', params: ['id', 'environmentId'] },
|
|
224
226
|
fleet: { description: 'Refresh and return fleet-wide gateway topology', method: 'GET', path: '/gateways/fleet' },
|
|
225
227
|
},
|
|
226
228
|
},
|
|
@@ -475,10 +475,14 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
475
475
|
<div className="text-[12px] font-800 text-text">Gateway fleet topology</div>
|
|
476
476
|
<div className="mt-1 text-[11px] text-text-3/70">
|
|
477
477
|
{gatewayFleetTopology.totals.connectedGatewayCount}/{gatewayFleetTopology.totals.gatewayCount} gateways connected ·{' '}
|
|
478
|
-
{gatewayFleetTopology.totals.connectedNodeCount}/{gatewayFleetTopology.totals.nodeCount} nodes connected
|
|
478
|
+
{gatewayFleetTopology.totals.connectedNodeCount}/{gatewayFleetTopology.totals.nodeCount} nodes connected ·{' '}
|
|
479
|
+
{gatewayFleetTopology.totals.availableEnvironmentCount || 0}/{gatewayFleetTopology.totals.environmentCount || 0} environments available
|
|
479
480
|
</div>
|
|
480
481
|
</div>
|
|
481
482
|
<div className="flex flex-wrap gap-2 text-[11px] text-text-3/70">
|
|
483
|
+
<span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
|
|
484
|
+
{gatewayFleetTopology.totals.environmentCount || 0} environments
|
|
485
|
+
</span>
|
|
482
486
|
<span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
|
|
483
487
|
{gatewayFleetTopology.totals.sessionCount || 0} sessions
|
|
484
488
|
</span>
|
|
@@ -536,6 +540,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
536
540
|
const topologyErrors = topology?.errors || []
|
|
537
541
|
const pendingPairings = (stats?.pendingNodePairings || 0) + (stats?.pendingDevicePairings || 0)
|
|
538
542
|
const topologyErrorCount = topologyErrors.length || stats?.lastTopologyErrorCount || 0
|
|
543
|
+
const environments = topology?.environments || []
|
|
539
544
|
return (
|
|
540
545
|
<div
|
|
541
546
|
key={gateway.id}
|
|
@@ -602,6 +607,12 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
602
607
|
{stats?.connectedNodeCount ?? 0}/{stats?.nodeCount ?? 0} nodes · {stats?.pairedDeviceCount ?? 0} devices
|
|
603
608
|
</div>
|
|
604
609
|
</div>
|
|
610
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
611
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Environments</div>
|
|
612
|
+
<div className="mt-1 text-text-2">
|
|
613
|
+
{stats?.availableEnvironmentCount ?? 0}/{stats?.environmentCount ?? 0} available
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
605
616
|
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
606
617
|
<div className="uppercase tracking-[0.08em] text-text-3/50">Sessions</div>
|
|
607
618
|
<div className="mt-1 text-text-2">
|
|
@@ -616,6 +627,28 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
616
627
|
</div>
|
|
617
628
|
</div>
|
|
618
629
|
)}
|
|
630
|
+
{!inSidebar && environments.length > 0 && (
|
|
631
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
632
|
+
{environments.slice(0, 4).map((environment) => (
|
|
633
|
+
<span
|
|
634
|
+
key={`${gateway.id}-${environment.id}`}
|
|
635
|
+
className={`rounded-full border px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] ${
|
|
636
|
+
environment.status === 'available'
|
|
637
|
+
? 'border-emerald-400/20 bg-emerald-400/[0.06] text-emerald-300'
|
|
638
|
+
: 'border-white/[0.06] bg-white/[0.03] text-text-3/70'
|
|
639
|
+
}`}
|
|
640
|
+
title={environment.capabilities?.join(', ') || environment.id}
|
|
641
|
+
>
|
|
642
|
+
{environment.label || environment.id}
|
|
643
|
+
</span>
|
|
644
|
+
))}
|
|
645
|
+
{environments.length > 4 && (
|
|
646
|
+
<span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] text-text-3/70">
|
|
647
|
+
+{environments.length - 4}
|
|
648
|
+
</span>
|
|
649
|
+
)}
|
|
650
|
+
</div>
|
|
651
|
+
)}
|
|
619
652
|
{!inSidebar && (pendingPairings > 0 || topologyErrorCount > 0) && (
|
|
620
653
|
<div className={`mt-3 rounded-[10px] border px-3 py-2 text-[11px] leading-relaxed ${
|
|
621
654
|
topologyErrorCount > 0
|
|
@@ -462,6 +462,31 @@ export function TaskSheet() {
|
|
|
462
462
|
{editing.executionWorkspace?.path && (
|
|
463
463
|
<code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
|
|
464
464
|
)}
|
|
465
|
+
{(editing.executionWorkspace?.contextPath || editing.executionWorkspace?.envPath) && (
|
|
466
|
+
<div className="grid grid-cols-1 gap-2 text-[11px] text-text-3/70">
|
|
467
|
+
{editing.executionWorkspace.contextPath && (
|
|
468
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
469
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Context</div>
|
|
470
|
+
<code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.contextPath}</code>
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
{editing.executionWorkspace.envPath && (
|
|
474
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
475
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Env</div>
|
|
476
|
+
<code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.envPath}</code>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
{editing.executionWorkspace?.envHints?.length ? (
|
|
482
|
+
<div className="flex flex-wrap gap-1.5">
|
|
483
|
+
{editing.executionWorkspace.envHints.slice(0, 8).map((hint) => (
|
|
484
|
+
<InfoChip key={hint.key} tone="neutral" title={hint.value}>
|
|
485
|
+
<span className="max-w-[220px] truncate">{hint.key}</span>
|
|
486
|
+
</InfoChip>
|
|
487
|
+
))}
|
|
488
|
+
</div>
|
|
489
|
+
) : null}
|
|
465
490
|
{previewLinks.length > 0 && (
|
|
466
491
|
<div className="flex flex-wrap gap-2">
|
|
467
492
|
{previewLinks.map((link) => (
|
|
@@ -942,6 +967,31 @@ export function TaskSheet() {
|
|
|
942
967
|
{editing.executionWorkspace?.path && (
|
|
943
968
|
<code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
|
|
944
969
|
)}
|
|
970
|
+
{(editing.executionWorkspace?.contextPath || editing.executionWorkspace?.envPath) && (
|
|
971
|
+
<div className="grid grid-cols-1 gap-2 text-[11px] text-text-3/70">
|
|
972
|
+
{editing.executionWorkspace.contextPath && (
|
|
973
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
974
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Context</div>
|
|
975
|
+
<code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.contextPath}</code>
|
|
976
|
+
</div>
|
|
977
|
+
)}
|
|
978
|
+
{editing.executionWorkspace.envPath && (
|
|
979
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
980
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Env</div>
|
|
981
|
+
<code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.envPath}</code>
|
|
982
|
+
</div>
|
|
983
|
+
)}
|
|
984
|
+
</div>
|
|
985
|
+
)}
|
|
986
|
+
{editing.executionWorkspace?.envHints?.length ? (
|
|
987
|
+
<div className="flex flex-wrap gap-1.5">
|
|
988
|
+
{editing.executionWorkspace.envHints.slice(0, 8).map((hint) => (
|
|
989
|
+
<InfoChip key={hint.key} tone="neutral" title={hint.value}>
|
|
990
|
+
<span className="max-w-[220px] truncate">{hint.key}</span>
|
|
991
|
+
</InfoChip>
|
|
992
|
+
))}
|
|
993
|
+
</div>
|
|
994
|
+
) : null}
|
|
945
995
|
{previewLinks.length > 0 && (
|
|
946
996
|
<div className="flex flex-wrap gap-2">
|
|
947
997
|
{previewLinks.map((link) => (
|
|
@@ -3,6 +3,7 @@ import { api } from '@/lib/app/api-client'
|
|
|
3
3
|
import { credentialQueryKeys } from '@/features/credentials/queries'
|
|
4
4
|
import type {
|
|
5
5
|
GatewayProfile,
|
|
6
|
+
OpenClawEnvironmentSummary,
|
|
6
7
|
OpenClawDevicePairRequest,
|
|
7
8
|
OpenClawGatewayFleetTopology,
|
|
8
9
|
OpenClawGatewayPresenceEntry,
|
|
@@ -60,6 +61,7 @@ export interface RefreshGatewayTopologyResult {
|
|
|
60
61
|
pairedDevices: OpenClawPairedDevice[]
|
|
61
62
|
sessions: OpenClawGatewaySession[]
|
|
62
63
|
presence: OpenClawGatewayPresenceEntry[]
|
|
64
|
+
environments: OpenClawEnvironmentSummary[]
|
|
63
65
|
errors: OpenClawGatewayRpcError[]
|
|
64
66
|
topology: OpenClawGatewayTopology
|
|
65
67
|
}
|
|
@@ -190,6 +192,7 @@ export function useRefreshGatewayTopologyMutation() {
|
|
|
190
192
|
pairedDevices: topology.pairedDevices,
|
|
191
193
|
sessions: topology.sessions,
|
|
192
194
|
presence: topology.presence,
|
|
195
|
+
environments: topology.environments,
|
|
193
196
|
errors: topology.errors,
|
|
194
197
|
topology,
|
|
195
198
|
}
|
|
@@ -70,6 +70,8 @@ function normalizeStats(value: unknown): OpenClawGatewayStats | null {
|
|
|
70
70
|
externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
|
|
71
71
|
sessionCount: normalizeNullableNumber(stats.sessionCount) ?? undefined,
|
|
72
72
|
presenceCount: normalizeNullableNumber(stats.presenceCount) ?? undefined,
|
|
73
|
+
environmentCount: normalizeNullableNumber(stats.environmentCount) ?? undefined,
|
|
74
|
+
availableEnvironmentCount: normalizeNullableNumber(stats.availableEnvironmentCount) ?? undefined,
|
|
73
75
|
lastTopologyCheckedAt: normalizeNullableNumber(stats.lastTopologyCheckedAt) ?? undefined,
|
|
74
76
|
lastTopologyErrorCount: normalizeNullableNumber(stats.lastTopologyErrorCount) ?? undefined,
|
|
75
77
|
lastTopologyError: normalizeText(stats.lastTopologyError),
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { describe, it } from 'node:test'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildOpenClawGatewayTopology,
|
|
6
|
+
getOpenClawGatewayEnvironmentStatus,
|
|
7
|
+
getOpenClawGatewayFleetTopology,
|
|
8
|
+
} from './gateway-topology'
|
|
5
9
|
import type { GatewayProfile, OpenClawGatewayStats } from '@/types'
|
|
6
10
|
|
|
7
11
|
const now = 1_800_000_000_000
|
|
@@ -60,6 +64,25 @@ describe('OpenClaw gateway topology', () => {
|
|
|
60
64
|
},
|
|
61
65
|
'sessions.list': { sessions: [{ sessionId: 'session_1', title: 'Release room' }] },
|
|
62
66
|
'system-presence': { presence: [{ deviceId: 'phone_1', mode: 'active' }] },
|
|
67
|
+
'environments.list': {
|
|
68
|
+
environments: [
|
|
69
|
+
{
|
|
70
|
+
id: 'gateway',
|
|
71
|
+
type: 'local',
|
|
72
|
+
label: 'Gateway local',
|
|
73
|
+
status: 'available',
|
|
74
|
+
capabilities: ['agent.run', 'sessions', 'tools', 'workspace'],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'node:node_1',
|
|
78
|
+
type: 'node',
|
|
79
|
+
label: 'Mac Studio',
|
|
80
|
+
status: 'available',
|
|
81
|
+
capabilities: ['shell.exec'],
|
|
82
|
+
},
|
|
83
|
+
{ id: 'node:node_2', type: 'node', label: 'Builder', status: 'unavailable' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
63
86
|
}) as never,
|
|
64
87
|
persistStats: (id, input) => {
|
|
65
88
|
assert.equal(id, 'gateway_1')
|
|
@@ -77,6 +100,10 @@ describe('OpenClaw gateway topology', () => {
|
|
|
77
100
|
assert.equal(topology.stats.pairedDeviceCount, 1)
|
|
78
101
|
assert.equal(topology.stats.sessionCount, 1)
|
|
79
102
|
assert.equal(topology.stats.presenceCount, 1)
|
|
103
|
+
assert.equal(topology.stats.environmentCount, 3)
|
|
104
|
+
assert.equal(topology.stats.availableEnvironmentCount, 2)
|
|
105
|
+
assert.equal(topology.environments[0]?.id, 'gateway')
|
|
106
|
+
assert.equal(topology.environments[1]?.capabilities?.[0], 'shell.exec')
|
|
80
107
|
assert.equal(topology.stats.pendingPairingCount, 2)
|
|
81
108
|
assert.equal(topology.stats.hasErrors, false)
|
|
82
109
|
assert.equal(topology.stats.lastTopologyCheckedAt, now)
|
|
@@ -92,15 +119,17 @@ describe('OpenClaw gateway topology', () => {
|
|
|
92
119
|
'device.pair.list': { pending: [], paired: [] },
|
|
93
120
|
'sessions.list': new Error('sessions unavailable'),
|
|
94
121
|
'system-presence': new Error('presence unavailable'),
|
|
122
|
+
'environments.list': new Error('environments unavailable'),
|
|
95
123
|
}) as never,
|
|
96
124
|
persistStats: (id, input) => ({ ...profile({ id }), stats: input.stats as OpenClawGatewayStats }),
|
|
97
125
|
})
|
|
98
126
|
|
|
99
127
|
assert.equal(topology.nodes.length, 1)
|
|
100
128
|
assert.equal(topology.sessions.length, 0)
|
|
101
|
-
assert.
|
|
129
|
+
assert.equal(topology.environments.length, 2)
|
|
130
|
+
assert.deepEqual(topology.errors.map((error) => error.method), ['sessions.list', 'system-presence', 'environments.list'])
|
|
102
131
|
assert.equal(topology.stats.hasErrors, true)
|
|
103
|
-
assert.equal(topology.stats.lastTopologyErrorCount,
|
|
132
|
+
assert.equal(topology.stats.lastTopologyErrorCount, 3)
|
|
104
133
|
assert.equal(topology.stats.lastTopologyError, 'sessions unavailable')
|
|
105
134
|
})
|
|
106
135
|
|
|
@@ -113,6 +142,7 @@ describe('OpenClaw gateway topology', () => {
|
|
|
113
142
|
assert.equal(topology.connected, false)
|
|
114
143
|
assert.equal(topology.stats.hasErrors, true)
|
|
115
144
|
assert.equal(topology.stats.nodeCount, 0)
|
|
145
|
+
assert.equal(topology.stats.environmentCount, 0)
|
|
116
146
|
assert.equal(topology.errors[0]?.method, 'gateway.connect')
|
|
117
147
|
})
|
|
118
148
|
|
|
@@ -132,6 +162,11 @@ describe('OpenClaw gateway topology', () => {
|
|
|
132
162
|
'device.pair.list': { pending: [], paired: [] },
|
|
133
163
|
'sessions.list': { sessions: target?.profileId === 'gateway_a' ? [{ id: 'session_a' }] : [] },
|
|
134
164
|
'system-presence': { presence: [] },
|
|
165
|
+
'environments.list': {
|
|
166
|
+
environments: target?.profileId === 'gateway_a'
|
|
167
|
+
? [{ id: 'gateway', type: 'local', status: 'available' }]
|
|
168
|
+
: [{ id: 'gateway', type: 'local', status: 'available' }, { id: 'node:node_b', type: 'node', status: 'unavailable' }],
|
|
169
|
+
},
|
|
135
170
|
}) as never,
|
|
136
171
|
persistStats: (id, input) => ({
|
|
137
172
|
...(id === 'gateway_a' ? first : second),
|
|
@@ -144,8 +179,29 @@ describe('OpenClaw gateway topology', () => {
|
|
|
144
179
|
assert.equal(fleet.totals.connectedGatewayCount, 2)
|
|
145
180
|
assert.equal(fleet.totals.nodeCount, 2)
|
|
146
181
|
assert.equal(fleet.totals.connectedNodeCount, 1)
|
|
182
|
+
assert.equal(fleet.totals.environmentCount, 3)
|
|
183
|
+
assert.equal(fleet.totals.availableEnvironmentCount, 2)
|
|
147
184
|
assert.equal(fleet.totals.pendingNodePairings, 1)
|
|
148
185
|
assert.equal(fleet.totals.sessionCount, 1)
|
|
149
186
|
assert.equal(fleet.gateways.length, 2)
|
|
150
187
|
})
|
|
188
|
+
|
|
189
|
+
it('returns direct environment status through the gateway protocol', async () => {
|
|
190
|
+
const calls: Array<{ method: string; params?: Record<string, unknown> }> = []
|
|
191
|
+
const status = await getOpenClawGatewayEnvironmentStatus('gateway_1', 'node:node_1', {
|
|
192
|
+
now: () => now,
|
|
193
|
+
getGatewayProfile: () => profile(),
|
|
194
|
+
ensureGatewayConnected: async () => ({
|
|
195
|
+
connected: true,
|
|
196
|
+
rpc: async (method: string, params?: Record<string, unknown>) => {
|
|
197
|
+
calls.push({ method, params })
|
|
198
|
+
return { id: 'node:node_1', type: 'node', label: 'Mac Studio', status: 'available', capabilities: ['shell.exec'] }
|
|
199
|
+
},
|
|
200
|
+
}) as never,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
assert.equal(status?.environment?.id, 'node:node_1')
|
|
204
|
+
assert.equal(status?.environment?.status, 'available')
|
|
205
|
+
assert.deepEqual(calls, [{ method: 'environments.status', params: { environmentId: 'node:node_1' } }])
|
|
206
|
+
})
|
|
151
207
|
})
|
|
@@ -6,8 +6,11 @@ import {
|
|
|
6
6
|
} from './gateway-profile-service'
|
|
7
7
|
import { ensureGatewayConnected } from '@/lib/server/openclaw/gateway'
|
|
8
8
|
import type {
|
|
9
|
+
OpenClawEnvironmentSummary,
|
|
9
10
|
GatewayProfile,
|
|
10
11
|
OpenClawDevicePairRequest,
|
|
12
|
+
OpenClawGatewayEnvironmentList,
|
|
13
|
+
OpenClawGatewayEnvironmentStatusSnapshot,
|
|
11
14
|
OpenClawGatewayFleetTopology,
|
|
12
15
|
OpenClawGatewayPresenceEntry,
|
|
13
16
|
OpenClawGatewayRpcError,
|
|
@@ -26,6 +29,7 @@ type GatewayRpcClient = {
|
|
|
26
29
|
|
|
27
30
|
interface GatewayTopologyDeps {
|
|
28
31
|
ensureGatewayConnected?: typeof ensureGatewayConnected
|
|
32
|
+
getGatewayProfile?: typeof getGatewayProfileById
|
|
29
33
|
listGatewayProfiles?: typeof listOpenClawGatewayProfiles
|
|
30
34
|
now?: () => number
|
|
31
35
|
persistStats?: typeof updateGatewayProfile
|
|
@@ -176,14 +180,72 @@ function normalizePresence(value: unknown): OpenClawGatewayPresenceEntry | null
|
|
|
176
180
|
}
|
|
177
181
|
}
|
|
178
182
|
|
|
183
|
+
function uniqueStrings(...items: Array<readonly string[] | undefined>): string[] {
|
|
184
|
+
const values = new Set<string>()
|
|
185
|
+
for (const item of items) {
|
|
186
|
+
for (const value of item || []) {
|
|
187
|
+
const trimmed = asString(value)
|
|
188
|
+
if (trimmed) values.add(trimmed)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return [...values].sort((left, right) => left.localeCompare(right))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeEnvironmentStatus(value: unknown): OpenClawEnvironmentSummary['status'] {
|
|
195
|
+
return value === 'available'
|
|
196
|
+
|| value === 'starting'
|
|
197
|
+
|| value === 'stopping'
|
|
198
|
+
|| value === 'error'
|
|
199
|
+
? value
|
|
200
|
+
: 'unavailable'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeEnvironment(value: unknown): OpenClawEnvironmentSummary | null {
|
|
204
|
+
const record = asObject(value)
|
|
205
|
+
const id = asString(record?.id) || asString(record?.environmentId)
|
|
206
|
+
if (!record || !id) return null
|
|
207
|
+
const capabilities = Array.isArray(record.capabilities)
|
|
208
|
+
? uniqueStrings(record.capabilities.map(asString).filter(Boolean) as string[])
|
|
209
|
+
: undefined
|
|
210
|
+
return {
|
|
211
|
+
id,
|
|
212
|
+
type: asString(record.type) || 'local',
|
|
213
|
+
label: asString(record.label) || asString(record.name),
|
|
214
|
+
status: normalizeEnvironmentStatus(record.status),
|
|
215
|
+
capabilities: capabilities && capabilities.length > 0 ? capabilities : undefined,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function deriveEnvironments(profile: GatewayProfile, nodes: OpenClawNode[], connected: boolean): OpenClawEnvironmentSummary[] {
|
|
220
|
+
const gatewayEnvironment: OpenClawEnvironmentSummary = {
|
|
221
|
+
id: 'gateway',
|
|
222
|
+
type: 'local',
|
|
223
|
+
label: profile.name || 'Gateway local',
|
|
224
|
+
status: connected ? 'available' : 'unavailable',
|
|
225
|
+
capabilities: ['agent.run', 'sessions', 'tools', 'workspace'],
|
|
226
|
+
}
|
|
227
|
+
const nodeEnvironments = nodes.map((node) => {
|
|
228
|
+
const capabilities = uniqueStrings(node.caps, node.commands)
|
|
229
|
+
return {
|
|
230
|
+
id: `node:${node.nodeId}`,
|
|
231
|
+
type: 'node',
|
|
232
|
+
label: node.displayName || node.nodeId,
|
|
233
|
+
status: node.connected === true ? 'available' : 'unavailable',
|
|
234
|
+
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
|
235
|
+
} satisfies OpenClawEnvironmentSummary
|
|
236
|
+
})
|
|
237
|
+
return [gatewayEnvironment, ...nodeEnvironments]
|
|
238
|
+
}
|
|
239
|
+
|
|
179
240
|
async function safeRpc<T>(
|
|
180
241
|
gateway: GatewayRpcClient,
|
|
181
242
|
method: string,
|
|
182
243
|
errors: OpenClawGatewayRpcError[],
|
|
183
244
|
normalize: (value: unknown) => T,
|
|
245
|
+
params: Record<string, unknown> = {},
|
|
184
246
|
): Promise<T> {
|
|
185
247
|
try {
|
|
186
|
-
return normalize(await gateway.rpc(method,
|
|
248
|
+
return normalize(await gateway.rpc(method, params))
|
|
187
249
|
} catch (err: unknown) {
|
|
188
250
|
errors.push({ method, message: errorMessage(err) })
|
|
189
251
|
return normalize(null)
|
|
@@ -197,6 +259,7 @@ function topologyStats(params: {
|
|
|
197
259
|
pairedDevices: OpenClawPairedDevice[]
|
|
198
260
|
sessions: OpenClawGatewaySession[]
|
|
199
261
|
presence: OpenClawGatewayPresenceEntry[]
|
|
262
|
+
environments: OpenClawEnvironmentSummary[]
|
|
200
263
|
errors: OpenClawGatewayRpcError[]
|
|
201
264
|
refreshedAt: number
|
|
202
265
|
}): OpenClawGatewayTopologyStats {
|
|
@@ -208,6 +271,8 @@ function topologyStats(params: {
|
|
|
208
271
|
pendingDevicePairings: params.devicePairings.length,
|
|
209
272
|
sessionCount: params.sessions.length,
|
|
210
273
|
presenceCount: params.presence.length,
|
|
274
|
+
environmentCount: params.environments.length,
|
|
275
|
+
availableEnvironmentCount: params.environments.filter((environment) => environment.status === 'available').length,
|
|
211
276
|
pendingPairingCount: params.nodePairings.length + params.devicePairings.length,
|
|
212
277
|
hasErrors: params.errors.length > 0,
|
|
213
278
|
lastTopologyCheckedAt: params.refreshedAt,
|
|
@@ -235,6 +300,7 @@ export async function buildOpenClawGatewayTopology(
|
|
|
235
300
|
pairedDevices: [],
|
|
236
301
|
sessions: [],
|
|
237
302
|
presence: [],
|
|
303
|
+
environments: [],
|
|
238
304
|
errors,
|
|
239
305
|
refreshedAt,
|
|
240
306
|
})
|
|
@@ -249,11 +315,12 @@ export async function buildOpenClawGatewayTopology(
|
|
|
249
315
|
pairedDevices: [],
|
|
250
316
|
sessions: [],
|
|
251
317
|
presence: [],
|
|
318
|
+
environments: [],
|
|
252
319
|
errors,
|
|
253
320
|
}
|
|
254
321
|
}
|
|
255
322
|
|
|
256
|
-
const [nodes, nodePairingsRaw, devicePairingsRaw, sessions, presence] = await Promise.all([
|
|
323
|
+
const [nodes, nodePairingsRaw, devicePairingsRaw, sessions, presence, rpcEnvironments] = await Promise.all([
|
|
257
324
|
safeRpc(gateway, 'node.list', errors, (value) =>
|
|
258
325
|
extractArray(value, ['nodes']).map(normalizeNode).filter(Boolean) as OpenClawNode[],
|
|
259
326
|
),
|
|
@@ -265,6 +332,9 @@ export async function buildOpenClawGatewayTopology(
|
|
|
265
332
|
safeRpc(gateway, 'system-presence', errors, (value) =>
|
|
266
333
|
extractArray(value, ['presence']).map(normalizePresence).filter(Boolean) as OpenClawGatewayPresenceEntry[],
|
|
267
334
|
),
|
|
335
|
+
safeRpc(gateway, 'environments.list', errors, (value) =>
|
|
336
|
+
extractArray(value, ['environments']).map(normalizeEnvironment).filter(Boolean) as OpenClawEnvironmentSummary[],
|
|
337
|
+
),
|
|
268
338
|
])
|
|
269
339
|
|
|
270
340
|
const devicePairingsRecord = asObject(devicePairingsRaw) || {}
|
|
@@ -277,6 +347,9 @@ export async function buildOpenClawGatewayTopology(
|
|
|
277
347
|
const pairedDevices = extractArray(devicePairingsRecord.paired)
|
|
278
348
|
.map(normalizePairedDevice)
|
|
279
349
|
.filter(Boolean) as OpenClawPairedDevice[]
|
|
350
|
+
const environments = rpcEnvironments.length > 0
|
|
351
|
+
? rpcEnvironments
|
|
352
|
+
: deriveEnvironments(profile, nodes, gateway.connected)
|
|
280
353
|
const stats = topologyStats({
|
|
281
354
|
nodes,
|
|
282
355
|
nodePairings,
|
|
@@ -284,6 +357,7 @@ export async function buildOpenClawGatewayTopology(
|
|
|
284
357
|
pairedDevices,
|
|
285
358
|
sessions,
|
|
286
359
|
presence,
|
|
360
|
+
environments,
|
|
287
361
|
errors,
|
|
288
362
|
refreshedAt,
|
|
289
363
|
})
|
|
@@ -301,6 +375,54 @@ export async function buildOpenClawGatewayTopology(
|
|
|
301
375
|
pairedDevices,
|
|
302
376
|
sessions,
|
|
303
377
|
presence,
|
|
378
|
+
environments,
|
|
379
|
+
errors,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function listOpenClawGatewayEnvironments(
|
|
384
|
+
id: string,
|
|
385
|
+
deps: GatewayTopologyDeps = {},
|
|
386
|
+
): Promise<OpenClawGatewayEnvironmentList | null> {
|
|
387
|
+
const topology = await getOpenClawGatewayTopology(id, deps)
|
|
388
|
+
if (!topology) return null
|
|
389
|
+
return {
|
|
390
|
+
profile: topology.profile,
|
|
391
|
+
connected: topology.connected,
|
|
392
|
+
refreshedAt: topology.refreshedAt,
|
|
393
|
+
environments: topology.environments,
|
|
394
|
+
errors: topology.errors,
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function getOpenClawGatewayEnvironmentStatus(
|
|
399
|
+
id: string,
|
|
400
|
+
environmentId: string,
|
|
401
|
+
deps: GatewayTopologyDeps = {},
|
|
402
|
+
): Promise<OpenClawGatewayEnvironmentStatusSnapshot | null> {
|
|
403
|
+
const profile = (deps.getGatewayProfile ?? getGatewayProfileById)(id)
|
|
404
|
+
if (!profile) return null
|
|
405
|
+
const now = deps.now ?? (() => Date.now())
|
|
406
|
+
const refreshedAt = now()
|
|
407
|
+
const ensureConnected = deps.ensureGatewayConnected ?? ensureGatewayConnected
|
|
408
|
+
const errors: OpenClawGatewayRpcError[] = []
|
|
409
|
+
const gateway = await ensureConnected({ profileId: profile.id }) as GatewayRpcClient | null
|
|
410
|
+
if (!gateway) {
|
|
411
|
+
errors.push({ method: 'gateway.connect', message: 'OpenClaw gateway not connected' })
|
|
412
|
+
return { profile, connected: false, refreshedAt, environment: null, errors }
|
|
413
|
+
}
|
|
414
|
+
const environment = await safeRpc(
|
|
415
|
+
gateway,
|
|
416
|
+
'environments.status',
|
|
417
|
+
errors,
|
|
418
|
+
(value) => normalizeEnvironment(value),
|
|
419
|
+
{ environmentId },
|
|
420
|
+
)
|
|
421
|
+
return {
|
|
422
|
+
profile,
|
|
423
|
+
connected: gateway.connected,
|
|
424
|
+
refreshedAt,
|
|
425
|
+
environment,
|
|
304
426
|
errors,
|
|
305
427
|
}
|
|
306
428
|
}
|
|
@@ -311,6 +433,8 @@ function emptyTotals(generatedAt: number): OpenClawGatewayFleetTopology['totals'
|
|
|
311
433
|
connectedGatewayCount: 0,
|
|
312
434
|
nodeCount: 0,
|
|
313
435
|
connectedNodeCount: 0,
|
|
436
|
+
environmentCount: 0,
|
|
437
|
+
availableEnvironmentCount: 0,
|
|
314
438
|
pendingNodePairings: 0,
|
|
315
439
|
pairedDeviceCount: 0,
|
|
316
440
|
pendingDevicePairings: 0,
|
|
@@ -328,7 +452,7 @@ export async function getOpenClawGatewayTopology(
|
|
|
328
452
|
id: string,
|
|
329
453
|
deps: GatewayTopologyDeps = {},
|
|
330
454
|
): Promise<OpenClawGatewayTopology | null> {
|
|
331
|
-
const profile = getGatewayProfileById(id)
|
|
455
|
+
const profile = (deps.getGatewayProfile ?? getGatewayProfileById)(id)
|
|
332
456
|
if (!profile) return null
|
|
333
457
|
return buildOpenClawGatewayTopology(profile, deps)
|
|
334
458
|
}
|
|
@@ -355,6 +479,8 @@ export async function getOpenClawGatewayFleetTopology(
|
|
|
355
479
|
acc.pendingDevicePairings = (acc.pendingDevicePairings || 0) + (topology.stats.pendingDevicePairings || 0)
|
|
356
480
|
acc.sessionCount = (acc.sessionCount || 0) + (topology.stats.sessionCount || 0)
|
|
357
481
|
acc.presenceCount = (acc.presenceCount || 0) + (topology.stats.presenceCount || 0)
|
|
482
|
+
acc.environmentCount = (acc.environmentCount || 0) + (topology.stats.environmentCount || 0)
|
|
483
|
+
acc.availableEnvironmentCount = (acc.availableEnvironmentCount || 0) + (topology.stats.availableEnvironmentCount || 0)
|
|
358
484
|
acc.pendingPairingCount += topology.stats.pendingPairingCount
|
|
359
485
|
acc.hasErrors = acc.hasErrors || topology.stats.hasErrors
|
|
360
486
|
acc.lastTopologyErrorCount = (acc.lastTopologyErrorCount || 0) + (topology.stats.lastTopologyErrorCount || 0)
|
|
@@ -161,4 +161,33 @@ describe('operation pulse', () => {
|
|
|
161
161
|
assert.ok((pulse.actions[0]?.summary || '').includes('pending OpenClaw pairing'))
|
|
162
162
|
assert.equal(pulse.actions[0]?.href, '/providers')
|
|
163
163
|
})
|
|
164
|
+
|
|
165
|
+
it('raises gateway attention when no execution environments are available', () => {
|
|
166
|
+
const pulse = buildOperationPulse({
|
|
167
|
+
range: '24h',
|
|
168
|
+
now,
|
|
169
|
+
missions: [],
|
|
170
|
+
runs: [],
|
|
171
|
+
approvals: [],
|
|
172
|
+
connectors: [],
|
|
173
|
+
gateways: [
|
|
174
|
+
gateway({
|
|
175
|
+
status: 'healthy',
|
|
176
|
+
stats: {
|
|
177
|
+
nodeCount: 1,
|
|
178
|
+
connectedNodeCount: 1,
|
|
179
|
+
environmentCount: 2,
|
|
180
|
+
availableEnvironmentCount: 0,
|
|
181
|
+
lastTopologyCheckedAt: now - 1000,
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
],
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
assert.equal(pulse.kpis.gatewayAttention, 1)
|
|
188
|
+
assert.equal(pulse.actions[0]?.kind, 'gateway')
|
|
189
|
+
assert.equal(pulse.actions[0]?.severity, 'high')
|
|
190
|
+
assert.ok((pulse.actions[0]?.summary || '').includes('no available OpenClaw execution environments'))
|
|
191
|
+
assert.equal(pulse.actions[0]?.evidence.includes('0/2 environments'), true)
|
|
192
|
+
})
|
|
164
193
|
})
|
|
@@ -87,6 +87,7 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
|
|
|
87
87
|
const evidence = [
|
|
88
88
|
`status:${gateway.status}`,
|
|
89
89
|
`${gateway.stats?.connectedNodeCount || 0}/${gateway.stats?.nodeCount || 0} nodes`,
|
|
90
|
+
`${gateway.stats?.availableEnvironmentCount || 0}/${gateway.stats?.environmentCount || 0} environments`,
|
|
90
91
|
]
|
|
91
92
|
|
|
92
93
|
if (gateway.status === 'offline') {
|
|
@@ -113,6 +114,14 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
if ((gateway.stats?.environmentCount || 0) > 0 && (gateway.stats?.availableEnvironmentCount || 0) === 0) {
|
|
118
|
+
return {
|
|
119
|
+
severity: 'high',
|
|
120
|
+
summary: `${gateway.name} has no available OpenClaw execution environments.`,
|
|
121
|
+
evidence,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
if (pendingPairings > 0) {
|
|
117
126
|
return {
|
|
118
127
|
severity: 'medium',
|
|
@@ -66,10 +66,24 @@ describe('task execution workspaces', () => {
|
|
|
66
66
|
assert.match(patch.executionWorkspace.path, /task-alpha-launch-qa-preview/)
|
|
67
67
|
assert.equal(fs.existsSync(patch.executionWorkspace.path), true)
|
|
68
68
|
assert.equal(fs.existsSync(patch.executionWorkspace.readmePath || ''), true)
|
|
69
|
+
assert.equal(fs.existsSync(patch.executionWorkspace.contextPath || ''), true)
|
|
70
|
+
assert.equal(fs.existsSync(patch.executionWorkspace.envPath || ''), true)
|
|
69
71
|
assert.equal(patch.executionWorkspace.sourceCwd, '/repo/source')
|
|
72
|
+
assert.equal(patch.executionWorkspace.context?.taskId, 'task-alpha')
|
|
73
|
+
assert.equal(patch.executionWorkspace.context?.workspacePath, patch.executionWorkspace.path)
|
|
74
|
+
assert.equal(patch.executionWorkspace.envHints?.some((hint) => hint.key === 'WORKSPACE_CWD'), true)
|
|
75
|
+
assert.equal(patch.executionWorkspace.envHints?.some((hint) => hint.key === 'KANBAN_TASK_ID'), true)
|
|
70
76
|
assert.equal(patch.executionWorkspace.previewLinks[0]?.label, 'Local preview')
|
|
71
77
|
assert.equal(patch.previewLinks[0]?.url, 'http://127.0.0.1:3456')
|
|
72
78
|
assert.equal(patch.runtimeServices[0]?.status, 'planned')
|
|
79
|
+
|
|
80
|
+
const context = JSON.parse(fs.readFileSync(patch.executionWorkspace.contextPath || '', 'utf8'))
|
|
81
|
+
assert.equal(context.taskId, 'task-alpha')
|
|
82
|
+
assert.equal(context.previewLinks[0]?.url, 'http://127.0.0.1:3456')
|
|
83
|
+
const envFile = fs.readFileSync(patch.executionWorkspace.envPath || '', 'utf8')
|
|
84
|
+
assert.equal(envFile.includes('SWARMCLAW_TASK_ID="task-alpha"'), true)
|
|
85
|
+
assert.equal(envFile.includes('WORKSPACE_SOURCE="/repo/source"'), true)
|
|
86
|
+
assert.equal(envFile.includes('KANBAN_WORKSPACE='), true)
|
|
73
87
|
})
|
|
74
88
|
|
|
75
89
|
it('deduplicates preview URLs and computes blocked, stale, and retrying liveness', () => {
|
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
TaskExecutionWorkspace,
|
|
8
8
|
TaskLivenessSnapshot,
|
|
9
9
|
TaskPreviewLink,
|
|
10
|
+
TaskRuntimeContextPacket,
|
|
11
|
+
TaskRuntimeEnvHint,
|
|
10
12
|
TaskRuntimeService,
|
|
11
13
|
} from '@/types'
|
|
12
14
|
|
|
@@ -182,11 +184,106 @@ function writeWorkspaceReadme(task: BoardTask, workspacePath: string, now: numbe
|
|
|
182
184
|
]
|
|
183
185
|
if (task.projectId) lines.push(`Project ID: ${task.projectId}`)
|
|
184
186
|
if (task.cwd) lines.push(`Source cwd: ${task.cwd}`)
|
|
185
|
-
lines.push(
|
|
187
|
+
lines.push(
|
|
188
|
+
'',
|
|
189
|
+
'Runtime context: ./context.json',
|
|
190
|
+
'Environment hints: ./.env.swarmclaw',
|
|
191
|
+
'',
|
|
192
|
+
'Use this directory for task-local notes, generated artifacts, and preview handoff files.',
|
|
193
|
+
)
|
|
186
194
|
fs.writeFileSync(readmePath, `${lines.join('\n')}\n`, 'utf8')
|
|
187
195
|
return readmePath
|
|
188
196
|
}
|
|
189
197
|
|
|
198
|
+
function addEnvHint(out: TaskRuntimeEnvHint[], key: string, value: unknown, description?: string) {
|
|
199
|
+
if (typeof value !== 'string' || !value) return
|
|
200
|
+
out.push({ key, value, ...(description ? { description } : {}) })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildRuntimeEnvHints(params: {
|
|
204
|
+
task: BoardTask
|
|
205
|
+
workspacePath: string
|
|
206
|
+
sourceCwd?: string | null
|
|
207
|
+
mode: TaskExecutionWorkspace['mode']
|
|
208
|
+
contextPath: string
|
|
209
|
+
envPath: string
|
|
210
|
+
}): TaskRuntimeEnvHint[] {
|
|
211
|
+
const { task, workspacePath, sourceCwd, mode, contextPath, envPath } = params
|
|
212
|
+
const hints: TaskRuntimeEnvHint[] = []
|
|
213
|
+
const workspaceId = taskWorkspaceSlug(task)
|
|
214
|
+
addEnvHint(hints, 'SWARMCLAW_TASK_ID', task.id, 'SwarmClaw task id')
|
|
215
|
+
addEnvHint(hints, 'SWARMCLAW_TASK_TITLE', task.title || 'Task', 'SwarmClaw task title')
|
|
216
|
+
addEnvHint(hints, 'SWARMCLAW_TASK_STATUS', task.status, 'SwarmClaw task status')
|
|
217
|
+
addEnvHint(hints, 'SWARMCLAW_TASK_AGENT_ID', task.agentId, 'Assigned SwarmClaw agent id')
|
|
218
|
+
addEnvHint(hints, 'SWARMCLAW_WORKSPACE_ID', workspaceId, 'Stable task workspace id')
|
|
219
|
+
addEnvHint(hints, 'SWARMCLAW_WORKSPACE_CWD', workspacePath, 'Task workspace directory')
|
|
220
|
+
addEnvHint(hints, 'SWARMCLAW_WORKSPACE_MODE', mode, 'Task workspace mode')
|
|
221
|
+
addEnvHint(hints, 'SWARMCLAW_WORKSPACE_CONTEXT', contextPath, 'Runtime context packet path')
|
|
222
|
+
addEnvHint(hints, 'SWARMCLAW_WORKSPACE_ENV', envPath, 'Reusable runtime env file')
|
|
223
|
+
addEnvHint(hints, 'SWARMCLAW_PROJECT_ID', task.projectId || '', 'SwarmClaw project id')
|
|
224
|
+
addEnvHint(hints, 'SWARMCLAW_SOURCE_CWD', sourceCwd || '', 'Original source directory')
|
|
225
|
+
addEnvHint(hints, 'AGENT_HOME', workspacePath, 'Agent-local home directory')
|
|
226
|
+
addEnvHint(hints, 'TASK_ID', task.id, 'Portable task id')
|
|
227
|
+
addEnvHint(hints, 'TASK_TITLE', task.title || 'Task', 'Portable task title')
|
|
228
|
+
addEnvHint(hints, 'WORKSPACE_ID', workspaceId, 'Portable workspace id')
|
|
229
|
+
addEnvHint(hints, 'WORKSPACE_CWD', workspacePath, 'Portable workspace cwd')
|
|
230
|
+
addEnvHint(hints, 'WORKSPACE_SOURCE', sourceCwd || workspacePath, 'Portable source path')
|
|
231
|
+
addEnvHint(
|
|
232
|
+
hints,
|
|
233
|
+
'WORKSPACE_STRATEGY',
|
|
234
|
+
mode === 'project' ? 'project-task-workspace' : 'task-workspace',
|
|
235
|
+
'Portable workspace strategy',
|
|
236
|
+
)
|
|
237
|
+
addEnvHint(hints, 'KANBAN_TASK_ID', task.id, 'Portable board task id')
|
|
238
|
+
addEnvHint(hints, 'KANBAN_WORKSPACE', workspacePath, 'Portable board workspace path')
|
|
239
|
+
return hints
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function envLine(hint: TaskRuntimeEnvHint): string {
|
|
243
|
+
return `${hint.key}=${JSON.stringify(hint.value)}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function writeWorkspaceEnv(envPath: string, hints: TaskRuntimeEnvHint[]) {
|
|
247
|
+
const lines = [
|
|
248
|
+
'# Generated by SwarmClaw. Contains task context only, not secrets.',
|
|
249
|
+
...hints.map(envLine),
|
|
250
|
+
]
|
|
251
|
+
fs.writeFileSync(envPath, `${lines.join('\n')}\n`, 'utf8')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildTaskRuntimeContext(params: {
|
|
255
|
+
task: BoardTask
|
|
256
|
+
executionWorkspace: Omit<TaskExecutionWorkspace, 'context'>
|
|
257
|
+
previewLinks: TaskPreviewLink[]
|
|
258
|
+
runtimeServices: TaskRuntimeService[]
|
|
259
|
+
generatedAt: number
|
|
260
|
+
}): TaskRuntimeContextPacket {
|
|
261
|
+
const { task, executionWorkspace, previewLinks, runtimeServices, generatedAt } = params
|
|
262
|
+
return {
|
|
263
|
+
taskId: task.id,
|
|
264
|
+
title: task.title || 'Task',
|
|
265
|
+
description: task.description || undefined,
|
|
266
|
+
status: task.status,
|
|
267
|
+
agentId: task.agentId,
|
|
268
|
+
projectId: executionWorkspace.projectId || null,
|
|
269
|
+
workspacePath: executionWorkspace.path,
|
|
270
|
+
sourceCwd: executionWorkspace.sourceCwd || null,
|
|
271
|
+
mode: executionWorkspace.mode,
|
|
272
|
+
preparedAt: executionWorkspace.preparedAt,
|
|
273
|
+
generatedAt,
|
|
274
|
+
previewLinks,
|
|
275
|
+
runtimeServices,
|
|
276
|
+
blockedBy: task.blockedBy,
|
|
277
|
+
blocks: task.blocks,
|
|
278
|
+
tags: task.tags,
|
|
279
|
+
upstreamResults: task.upstreamResults,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function writeWorkspaceContext(contextPath: string, context: TaskRuntimeContextPacket) {
|
|
284
|
+
fs.writeFileSync(contextPath, `${JSON.stringify(context, null, 2)}\n`, 'utf8')
|
|
285
|
+
}
|
|
286
|
+
|
|
190
287
|
export function computeTaskLiveness(
|
|
191
288
|
task: BoardTask,
|
|
192
289
|
tasks: Record<string, BoardTask> = {},
|
|
@@ -284,6 +381,8 @@ export function prepareTaskExecutionWorkspace(
|
|
|
284
381
|
const existing = task.executionWorkspace || null
|
|
285
382
|
const workspacePath = existing?.path || path.join(taskWorkspaceRoot(task, workspaceRoot), taskWorkspaceSlug(task))
|
|
286
383
|
fs.mkdirSync(workspacePath, { recursive: true })
|
|
384
|
+
const contextPath = path.join(workspacePath, 'context.json')
|
|
385
|
+
const envPath = path.join(workspacePath, '.env.swarmclaw')
|
|
287
386
|
const readmePath = writeWorkspaceReadme(task, workspacePath, now)
|
|
288
387
|
const previewLinks = normalizeTaskPreviewLinks(
|
|
289
388
|
task.previewLinks || existing?.previewLinks,
|
|
@@ -295,17 +394,45 @@ export function prepareTaskExecutionWorkspace(
|
|
|
295
394
|
options.runtimeServices,
|
|
296
395
|
now,
|
|
297
396
|
)
|
|
298
|
-
const
|
|
397
|
+
const mode: TaskExecutionWorkspace['mode'] = task.projectId ? 'project' : 'task'
|
|
398
|
+
const sourceCwd = task.cwd || existing?.sourceCwd || null
|
|
399
|
+
const projectId = task.projectId || existing?.projectId || null
|
|
400
|
+
const preparedAt = existing?.preparedAt || now
|
|
401
|
+
const envHints = buildRuntimeEnvHints({
|
|
402
|
+
task,
|
|
403
|
+
workspacePath,
|
|
404
|
+
sourceCwd,
|
|
405
|
+
mode,
|
|
406
|
+
contextPath,
|
|
407
|
+
envPath,
|
|
408
|
+
})
|
|
409
|
+
const executionWorkspaceBase: Omit<TaskExecutionWorkspace, 'context'> = {
|
|
299
410
|
path: workspacePath,
|
|
300
|
-
mode
|
|
301
|
-
sourceCwd
|
|
302
|
-
projectId
|
|
303
|
-
preparedAt
|
|
411
|
+
mode,
|
|
412
|
+
sourceCwd,
|
|
413
|
+
projectId,
|
|
414
|
+
preparedAt,
|
|
304
415
|
preparedBy: options.actor || existing?.preparedBy || null,
|
|
305
416
|
readmePath,
|
|
417
|
+
contextPath,
|
|
418
|
+
envPath,
|
|
419
|
+
envHints,
|
|
306
420
|
previewLinks,
|
|
307
421
|
runtimeServices,
|
|
308
422
|
}
|
|
423
|
+
const context = buildTaskRuntimeContext({
|
|
424
|
+
task,
|
|
425
|
+
executionWorkspace: executionWorkspaceBase,
|
|
426
|
+
previewLinks,
|
|
427
|
+
runtimeServices,
|
|
428
|
+
generatedAt: now,
|
|
429
|
+
})
|
|
430
|
+
writeWorkspaceContext(contextPath, context)
|
|
431
|
+
writeWorkspaceEnv(envPath, envHints)
|
|
432
|
+
const executionWorkspace: TaskExecutionWorkspace = {
|
|
433
|
+
...executionWorkspaceBase,
|
|
434
|
+
context,
|
|
435
|
+
}
|
|
309
436
|
const taskForLiveness = {
|
|
310
437
|
...task,
|
|
311
438
|
executionWorkspace,
|
package/src/types/misc.ts
CHANGED
|
@@ -672,6 +672,8 @@ export interface OpenClawGatewayStats {
|
|
|
672
672
|
externalRuntimeCount?: number
|
|
673
673
|
sessionCount?: number
|
|
674
674
|
presenceCount?: number
|
|
675
|
+
environmentCount?: number
|
|
676
|
+
availableEnvironmentCount?: number
|
|
675
677
|
lastTopologyCheckedAt?: number
|
|
676
678
|
lastTopologyErrorCount?: number
|
|
677
679
|
lastTopologyError?: string | null
|
|
@@ -803,7 +805,19 @@ export interface OpenClawGatewayPresenceEntry {
|
|
|
803
805
|
updatedAt?: number | null
|
|
804
806
|
}
|
|
805
807
|
|
|
808
|
+
export type OpenClawEnvironmentStatus = 'available' | 'unavailable' | 'starting' | 'stopping' | 'error'
|
|
809
|
+
|
|
810
|
+
export interface OpenClawEnvironmentSummary {
|
|
811
|
+
id: string
|
|
812
|
+
type: string
|
|
813
|
+
label?: string | null
|
|
814
|
+
status: OpenClawEnvironmentStatus
|
|
815
|
+
capabilities?: string[]
|
|
816
|
+
}
|
|
817
|
+
|
|
806
818
|
export interface OpenClawGatewayTopologyStats extends OpenClawGatewayStats {
|
|
819
|
+
environmentCount: number
|
|
820
|
+
availableEnvironmentCount: number
|
|
807
821
|
pendingPairingCount: number
|
|
808
822
|
hasErrors: boolean
|
|
809
823
|
}
|
|
@@ -819,6 +833,23 @@ export interface OpenClawGatewayTopology {
|
|
|
819
833
|
pairedDevices: OpenClawPairedDevice[]
|
|
820
834
|
sessions: OpenClawGatewaySession[]
|
|
821
835
|
presence: OpenClawGatewayPresenceEntry[]
|
|
836
|
+
environments: OpenClawEnvironmentSummary[]
|
|
837
|
+
errors: OpenClawGatewayRpcError[]
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export interface OpenClawGatewayEnvironmentList {
|
|
841
|
+
profile: GatewayProfile
|
|
842
|
+
connected: boolean
|
|
843
|
+
refreshedAt: number
|
|
844
|
+
environments: OpenClawEnvironmentSummary[]
|
|
845
|
+
errors: OpenClawGatewayRpcError[]
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export interface OpenClawGatewayEnvironmentStatusSnapshot {
|
|
849
|
+
profile: GatewayProfile
|
|
850
|
+
connected: boolean
|
|
851
|
+
refreshedAt: number
|
|
852
|
+
environment: OpenClawEnvironmentSummary | null
|
|
822
853
|
errors: OpenClawGatewayRpcError[]
|
|
823
854
|
}
|
|
824
855
|
|
package/src/types/task.ts
CHANGED
|
@@ -43,6 +43,32 @@ export interface TaskRuntimeService {
|
|
|
43
43
|
updatedAt: number
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export interface TaskRuntimeEnvHint {
|
|
47
|
+
key: string
|
|
48
|
+
value: string
|
|
49
|
+
description?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TaskRuntimeContextPacket {
|
|
53
|
+
taskId: string
|
|
54
|
+
title: string
|
|
55
|
+
description?: string
|
|
56
|
+
status: BoardTaskStatus
|
|
57
|
+
agentId: string
|
|
58
|
+
projectId?: string | null
|
|
59
|
+
workspacePath: string
|
|
60
|
+
sourceCwd?: string | null
|
|
61
|
+
mode: TaskExecutionWorkspaceMode
|
|
62
|
+
preparedAt: number
|
|
63
|
+
generatedAt: number
|
|
64
|
+
previewLinks: TaskPreviewLink[]
|
|
65
|
+
runtimeServices: TaskRuntimeService[]
|
|
66
|
+
blockedBy?: string[]
|
|
67
|
+
blocks?: string[]
|
|
68
|
+
tags?: string[]
|
|
69
|
+
upstreamResults?: BoardTask['upstreamResults']
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
export interface TaskExecutionWorkspace {
|
|
47
73
|
path: string
|
|
48
74
|
mode: TaskExecutionWorkspaceMode
|
|
@@ -51,6 +77,10 @@ export interface TaskExecutionWorkspace {
|
|
|
51
77
|
preparedAt: number
|
|
52
78
|
preparedBy?: string | null
|
|
53
79
|
readmePath?: string | null
|
|
80
|
+
contextPath?: string | null
|
|
81
|
+
envPath?: string | null
|
|
82
|
+
envHints?: TaskRuntimeEnvHint[]
|
|
83
|
+
context?: TaskRuntimeContextPacket
|
|
54
84
|
previewLinks: TaskPreviewLink[]
|
|
55
85
|
runtimeServices: TaskRuntimeService[]
|
|
56
86
|
}
|