@flame0510/project-aether 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/agents/PageClient.tsx +31 -6
- package/app/agents/create/PageClient.tsx +19 -5
- package/app/agents/create/page.tsx +14 -28
- package/app/api/agents/create/route.ts +8 -6
- package/app/api/agents/ports/route.ts +19 -0
- package/app/api/version/route.ts +2 -1
- package/docs/ARCHITECTURE.md +23 -7
- package/docs/FRONTEND-ARCHITECTURE.md +1 -1
- package/docs/dev/API-REFERENCE.md +42 -7
- package/lib/agent-edit.ts +9 -17
- package/lib/agent-ports-server.ts +68 -17
- package/lib/agent-ports.ts +23 -0
- package/lib/agent-recreate.ts +59 -5
- package/lib/agent-restore.ts +15 -9
- package/lib/agent-update.ts +14 -6
- package/package.json +1 -1
|
@@ -770,6 +770,14 @@ function currentPortRange(ports: string): string {
|
|
|
770
770
|
return min === max ? `${min}` : `${min}-${max}`;
|
|
771
771
|
}
|
|
772
772
|
|
|
773
|
+
/** The agent's own published host ports, from the `3700:3000, 3701:3001` string. */
|
|
774
|
+
function ownHostPorts(ports: string): number[] {
|
|
775
|
+
return (ports || '')
|
|
776
|
+
.split(',')
|
|
777
|
+
.map((p) => parseInt(p.trim().split(':')[0] ?? '', 10))
|
|
778
|
+
.filter((n) => Number.isInteger(n));
|
|
779
|
+
}
|
|
780
|
+
|
|
773
781
|
function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
|
|
774
782
|
agent: Agent;
|
|
775
783
|
onClose: () => void;
|
|
@@ -780,20 +788,37 @@ function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
|
|
|
780
788
|
}) {
|
|
781
789
|
const initialName = agent.displayName || agent.name;
|
|
782
790
|
const initialPorts = currentPortRange(agent.ports);
|
|
791
|
+
const ownPorts = useMemo(() => ownHostPorts(agent.ports), [agent.ports]);
|
|
783
792
|
const [displayName, setDisplayName] = useState(initialName);
|
|
784
793
|
const [portRange, setPortRange] = useState(initialPorts);
|
|
785
794
|
const [saving, setSaving] = useState(false);
|
|
786
795
|
const [error, setError] = useState('');
|
|
796
|
+
// The ports other containers hold, fetched when the panel opens: the same set the
|
|
797
|
+
// create form gets as a page prop, from the same lookup (`GET /api/agents/ports`).
|
|
798
|
+
const [usedPorts, setUsedPorts] = useState<Set<number> | null>(null);
|
|
799
|
+
|
|
800
|
+
useEffect(() => {
|
|
801
|
+
const controller = new AbortController();
|
|
802
|
+
fetch('/api/agents/ports', { signal: controller.signal })
|
|
803
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
804
|
+
.then((data: { ports?: number[] } | null) => {
|
|
805
|
+
if (data && Array.isArray(data.ports)) setUsedPorts(new Set(data.ports));
|
|
806
|
+
})
|
|
807
|
+
.catch((e: unknown) => { if ((e as Error)?.name !== 'AbortError') setUsedPorts(null); });
|
|
808
|
+
return () => controller.abort();
|
|
809
|
+
}, []);
|
|
787
810
|
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
811
|
+
// The same rules and messages as agent creation (lib/agent-ports.ts). This agent's own
|
|
812
|
+
// block is excluded, so keeping or shifting it is not a conflict with itself; every
|
|
813
|
+
// other container counts, running or stopped.
|
|
791
814
|
const nameError = displayName.trim() ? '' : 'Display name must not be empty';
|
|
792
815
|
const portError = useMemo(() => {
|
|
793
816
|
if (!portRange.trim()) return '';
|
|
794
|
-
const
|
|
817
|
+
const others = new Set(usedPorts ?? []);
|
|
818
|
+
for (const p of ownPorts) others.delete(p);
|
|
819
|
+
const r = validatePortInput(portRange, others);
|
|
795
820
|
return r.valid ? '' : r.error;
|
|
796
|
-
}, [portRange]);
|
|
821
|
+
}, [portRange, usedPorts, ownPorts]);
|
|
797
822
|
|
|
798
823
|
const nameChanged = displayName.trim() !== initialName;
|
|
799
824
|
const portsChanged = portRange.trim() !== initialPorts;
|
|
@@ -882,7 +907,7 @@ function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
|
|
|
882
907
|
{error && <div style={{ fontSize: 11, color: '#ef4444' }}>{error}</div>}
|
|
883
908
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
884
909
|
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>Cancel</Button>
|
|
885
|
-
<Button variant="primary" size="sm" onClick={handleSave} disabled={saving}>
|
|
910
|
+
<Button variant="primary" size="sm" onClick={handleSave} disabled={saving || !!nameError || !!portError}>
|
|
886
911
|
{saving ? 'Saving...' : 'Save & Recreate'}
|
|
887
912
|
</Button>
|
|
888
913
|
</div>
|
|
@@ -60,6 +60,20 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
60
60
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
|
61
61
|
const [agentName, setAgentName] = useState('');
|
|
62
62
|
const [portRange, setPortRange] = useState<string>('');
|
|
63
|
+
// The server render seeds this; the same endpoint the Edit panel uses refreshes it, so
|
|
64
|
+
// both flows validate against the same numbers (every container, running or stopped).
|
|
65
|
+
const [usedPorts, setUsedPorts] = useState<number[]>(initialUsedPorts);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
fetch('/api/agents/ports', { signal: controller.signal })
|
|
70
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
71
|
+
.then((data: { ports?: number[] } | null) => {
|
|
72
|
+
if (data && Array.isArray(data.ports)) setUsedPorts(data.ports);
|
|
73
|
+
})
|
|
74
|
+
.catch(() => { /* the server still validates on submit */ });
|
|
75
|
+
return () => controller.abort();
|
|
76
|
+
}, []);
|
|
63
77
|
const [selectedModel, setSelectedModel] = useState<string>(
|
|
64
78
|
models.length > 0 ? models[0].id : ''
|
|
65
79
|
);
|
|
@@ -73,11 +87,11 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
73
87
|
setSelectedTemplate(id);
|
|
74
88
|
setAgentName('');
|
|
75
89
|
// Auto-assign port block: find first free 10-port block
|
|
76
|
-
if (
|
|
90
|
+
if (usedPorts.length === 0) {
|
|
77
91
|
setPortRange('3700-3709');
|
|
78
92
|
} else {
|
|
79
|
-
//
|
|
80
|
-
const block =
|
|
93
|
+
// Same block finder as the server: the first free 10-port block.
|
|
94
|
+
const block = findAvailablePortBlock(new Set(usedPorts));
|
|
81
95
|
setPortRange(block ? `${block}-${block + 9}` : '');
|
|
82
96
|
}
|
|
83
97
|
};
|
|
@@ -91,9 +105,9 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
|
|
|
91
105
|
|
|
92
106
|
const portError = useMemo(() => {
|
|
93
107
|
if (!portRange.trim()) return '';
|
|
94
|
-
const res = validatePortInput(portRange, new Set(
|
|
108
|
+
const res = validatePortInput(portRange, new Set(usedPorts));
|
|
95
109
|
return res.valid ? '' : res.error;
|
|
96
|
-
}, [portRange,
|
|
110
|
+
}, [portRange, usedPorts]);
|
|
97
111
|
|
|
98
112
|
const canProceed = (): boolean => {
|
|
99
113
|
if (step === 'template') return selectedTemplate !== '';
|
|
@@ -2,6 +2,8 @@ import { execSync } from 'child_process';
|
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { DB_FILE, AGENTS_TOKEN_FILE, templatesDir } from '@/lib/rev4a-paths';
|
|
5
|
+
import { REV4A_PORT } from '@/lib/agent-ports';
|
|
6
|
+
import { getUsedHostPorts } from '@/lib/agent-ports-server';
|
|
5
7
|
import { loadOfferedModels } from '@/lib/model-catalogue';
|
|
6
8
|
import CreatePageClient from './PageClient';
|
|
7
9
|
|
|
@@ -45,19 +47,21 @@ async function getModels(): Promise<ModelInfo[]> {
|
|
|
45
47
|
async function getUsedNames(): Promise<string[]> {
|
|
46
48
|
try {
|
|
47
49
|
const raw = execSync(
|
|
48
|
-
`docker ps --filter "label=AGENT_ID" --format '{{.Names}}'`,
|
|
50
|
+
`docker ps -a --filter "label=AGENT_ID" --format '{{.Names}}'`,
|
|
49
51
|
{ encoding: 'utf-8', timeout: 5000 },
|
|
50
52
|
).trim();
|
|
51
53
|
if (!raw) return [];
|
|
52
54
|
|
|
53
|
-
// Read AGENT_NAME
|
|
55
|
+
// Read AGENT_NAME from each container's inspect: a stopped agent still owns its
|
|
56
|
+
// display name, and `docker exec` cannot reach a stopped container.
|
|
54
57
|
const names: string[] = [];
|
|
55
58
|
for (const name of raw.split('\n').filter(Boolean)) {
|
|
56
59
|
try {
|
|
57
|
-
const
|
|
58
|
-
`docker
|
|
60
|
+
const env = execSync(
|
|
61
|
+
`docker inspect ${name} --format '{{range .Config.Env}}{{println .}}{{end}}'`,
|
|
59
62
|
{ encoding: 'utf-8', timeout: 3000 },
|
|
60
|
-
)
|
|
63
|
+
);
|
|
64
|
+
const displayName = env.match(/^AGENT_NAME=(.*)$/m)?.[1]?.trim();
|
|
61
65
|
if (displayName) names.push(displayName);
|
|
62
66
|
} catch { /* skip */ }
|
|
63
67
|
}
|
|
@@ -68,29 +72,11 @@ async function getUsedNames(): Promise<string[]> {
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
async function getUsedPorts(): Promise<number[]> {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const ports: number[] = [];
|
|
77
|
-
if (raw) {
|
|
78
|
-
for (const line of raw.split('\n')) {
|
|
79
|
-
const matches = line.match(/(\d+)->/g);
|
|
80
|
-
if (matches) {
|
|
81
|
-
matches.forEach((m) => {
|
|
82
|
-
const p = parseInt(m.replace('->', ''), 10);
|
|
83
|
-
if (!isNaN(p)) ports.push(p);
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
// Add Rev4a port as reserved so the client won't suggest it
|
|
89
|
-
ports.push(3740);
|
|
90
|
-
return [...new Set(ports)];
|
|
91
|
-
} catch {
|
|
92
|
-
return [3740];
|
|
93
|
-
}
|
|
75
|
+
// One source for port occupancy (lib/agent-ports-server.ts): it reads every
|
|
76
|
+
// container's bindings, so the block of a stopped agent is offered to nobody.
|
|
77
|
+
const ports = await getUsedHostPorts();
|
|
78
|
+
ports.add(REV4A_PORT);
|
|
79
|
+
return [...ports];
|
|
94
80
|
}
|
|
95
81
|
|
|
96
82
|
export default async function CreateAgentPage() {
|
|
@@ -123,25 +123,27 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
123
123
|
);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// Check if name is already in use by a running
|
|
126
|
+
// Check if name is already in use by a container, running or not: a stopped agent
|
|
127
|
+
// keeps its name and its port block, so both would clash on start.
|
|
127
128
|
const existing = execSync(
|
|
128
|
-
`docker ps --filter "label=AGENT_ID=${containerName}" --format '{{.Names}}'`,
|
|
129
|
+
`docker ps -a --filter "label=AGENT_ID=${containerName}" --format '{{.Names}}'`,
|
|
129
130
|
{ encoding: 'utf-8', timeout: 5000 },
|
|
130
131
|
).trim();
|
|
131
132
|
if (existing) {
|
|
132
133
|
return NextResponse.json(
|
|
133
|
-
{ success: false, error: `Agent '${displayName}'
|
|
134
|
+
{ success: false, error: `Agent '${displayName}' already exists (container: ${existing})` },
|
|
134
135
|
{ status: 409 },
|
|
135
136
|
);
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
// Resolve port range
|
|
139
|
+
// Resolve port range. One lookup, shared by both paths below.
|
|
140
|
+
const usedPorts = await getUsedHostPorts();
|
|
139
141
|
let isBlock = false;
|
|
140
142
|
let portStart: number = 0;
|
|
141
143
|
let portEnd: number = 0;
|
|
142
144
|
|
|
143
145
|
if (portInput) {
|
|
144
|
-
const v = validatePortInput(portInput,
|
|
146
|
+
const v = validatePortInput(portInput, usedPorts);
|
|
145
147
|
if (!v.valid) {
|
|
146
148
|
return NextResponse.json(
|
|
147
149
|
{ success: false, error: v.error },
|
|
@@ -152,7 +154,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
152
154
|
portStart = v.start;
|
|
153
155
|
portEnd = v.end;
|
|
154
156
|
} else {
|
|
155
|
-
portStart = findAvailablePortBlock(
|
|
157
|
+
portStart = findAvailablePortBlock(usedPorts);
|
|
156
158
|
portEnd = portStart + DEFAULT_BLOCK_SIZE - 1;
|
|
157
159
|
isBlock = true;
|
|
158
160
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
3
|
+
import { getUsedHostPorts } from '@/lib/agent-ports-server';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /api/agents/ports
|
|
9
|
+
*
|
|
10
|
+
* The host ports held by any container, running or stopped — the set the port validators
|
|
11
|
+
* in lib/agent-ports.ts take. The create form receives it as a page prop; the Edit panel
|
|
12
|
+
* fetches it when it opens, so both flows validate a range against the same numbers and
|
|
13
|
+
* the same rules, and give the same message.
|
|
14
|
+
*/
|
|
15
|
+
export async function GET(request: Request): Promise<NextResponse> {
|
|
16
|
+
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
|
17
|
+
const ports = await getUsedHostPorts();
|
|
18
|
+
return NextResponse.json({ ports: [...ports].sort((a, b) => a - b) });
|
|
19
|
+
}
|
package/app/api/version/route.ts
CHANGED
|
@@ -15,7 +15,8 @@ export const dynamic = 'force-dynamic';
|
|
|
15
15
|
* GET /api/version
|
|
16
16
|
*
|
|
17
17
|
* Returns the installed Rev4a version from package.json.
|
|
18
|
-
*
|
|
18
|
+
*
|
|
19
|
+
* Auth: the rev4a_token cookie or Authorization: Bearer <REV4A_TOKEN>.
|
|
19
20
|
*/
|
|
20
21
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
21
22
|
const denied = await requireAuthJWT(request); if (denied) return denied as any;
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -170,13 +170,29 @@ The central container, running the Next.js dashboard + orchestration API.
|
|
|
170
170
|
`prerecreate` archives are pruned to the newest two. The same `recreateAgentContainer()`
|
|
171
171
|
serves the edit route (`PATCH` → 202, no backup) and the Update action.
|
|
172
172
|
`agentBusyReason()` (`lib/agent-busy.ts`) covers it for every route that changes an agent.
|
|
173
|
-
- **Host ports**
|
|
174
|
-
(
|
|
175
|
-
`lib/agent-ports-server.ts`
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
173
|
+
- **Host ports** have one source of truth: `lib/agent-ports.ts` holds the pure rules
|
|
174
|
+
(`validatePortInput`, `findAvailablePortBlock`, `portMappingArgs`,
|
|
175
|
+
`hostPortsFromBindings`, `hostPortsFromArgs`) and `lib/agent-ports-server.ts` answers
|
|
176
|
+
which ports are taken (`getHostPortHolders()`, `getUsedHostPorts()`; server-only, it
|
|
177
|
+
talks to the Docker socket). Creation, the create form, the edit flow and every
|
|
178
|
+
container rebuild (update, recreate, rollback) read their numbers from there. One block
|
|
179
|
+
maps to the container's gateway port 3000 (`3700-3709` → `3700-3709:3000-3009`); all
|
|
180
|
+
flows produce the same validation messages, and a flow rebuilding a container excludes
|
|
181
|
+
that container's own ports, so its block never conflicts with itself.
|
|
182
|
+
**A stopped container still holds its block**, but neither `docker ps -a --format
|
|
183
|
+
'{{.Ports}}'` nor the Docker API's container list reports its ports (verified on Docker
|
|
184
|
+
29: both return nothing). The lookup therefore reads each container's
|
|
185
|
+
`HostConfig.PortBindings` — the only source covering the stopped ones. Without that, a
|
|
186
|
+
new agent could be handed the block of a stopped agent, and the clash only surfaced
|
|
187
|
+
later, when that agent was started again (`Bind for 0.0.0.0:3730 failed: port is
|
|
188
|
+
already allocated`), after the container it was replacing had been removed.
|
|
189
|
+
`recreateAgentContainer()` refuses such a clash **before** removing anything, naming the
|
|
190
|
+
container that holds each port, so the agent stays up and the operator changes the range
|
|
191
|
+
from the Edit panel; a failed `docker run` no longer leaves a container behind.
|
|
192
|
+
The two forms validate a range **as it is typed** against that same set — the create
|
|
193
|
+
wizard through its page prop plus `GET /api/agents/ports`, the Edit panel through the
|
|
194
|
+
endpoint when it opens — so both show the same message and disable their action while
|
|
195
|
+
the range is refused, instead of letting the server refuse it on submit.
|
|
180
196
|
- **Agent edits** are `lib/agent-edit.ts`, with their state in the `agent_edits` table
|
|
181
197
|
(`lib/agent-edit-state.ts`): the display name (`AGENT_NAME`) and/or the port range are
|
|
182
198
|
applied by rebuilding the container on the image it already runs — **no backup**, the
|
|
@@ -48,7 +48,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
|
|
|
48
48
|
| `ImageDownloadBanner` | `app/agents/ImageDownloadBanner.tsx` | Agent image banner on the Agents page. Polls `/api/agents/image-status` every 2 s; offers **Download Image** when no supported version is downloaded, **Download <version>** when the registry publishes a newer one, and shows the download in progress and its completion. Downloading changes no agent. |
|
|
49
49
|
| `BrowserAccessSection` / `OpenControlUiButton` | `app/agents/BrowserAccessSection.tsx` | Browser access to one agent's Control UI, in its detail panel: requests waiting for approval (Approve / Reject) and approved browsers (Rename / Revoke), refreshed every 5 s while mounted. A successful approve, reject, rename or revoke updates the list at once, since the refresh behind it runs the OpenClaw CLI and takes seconds; a read started before the mutation is discarded. On agents that require approval, "Invite link" fetches `/api/agents/[id]/invite-link` and shows the link in a read-only field with Copy, which uses the Clipboard API in a secure context and the field's selection over plain HTTP, plus a warning when the link uses localhost. `OpenControlUiButton` opens `/api/agents/[id]/open-control-ui` in a new tab inside the click; that route redirects to a one-time link that pairs the browser with no approval, or to the plain token link when none can be issued. Used on the agent cards and in the panel. |
|
|
50
50
|
| `ChannelManager` / `ChannelSection` | `app/agents/ChannelManager.tsx`, `ChannelSection` inline in `app/agents/PageClient.tsx` | Telegram, in the agent detail panel (`ChannelSection` is the card that opens the modal; the modal title is the agent's display name). Reads `GET /channels`; lists pending pairing requests with **Approve** (`POST /channels/pairing`) and approved senders with **Revoke** after a confirm (`DELETE /channels/pairing?senderId=`). Pending comes from `openclaw pairing list`, approved from OpenClaw's pairing store (`lib/channelManager.ts`). Polls pairings every 5 s while open; one keyed busy state per action. |
|
|
51
|
-
| `EditAgentModal` / `EditBanner` | `app/agents/PageClient.tsx` | Rename/ports editing. The modal has **Display Name** and **Host Port** (same input and validation messages as the create wizard, from `lib/agent-ports.ts`), sends `PATCH /api/agents/[id]` and closes on `202`; `EditBanner` (top of the agent detail) polls `GET /api/agents/[id]` every 2 s while an edit is `rebuilding`, resumes on mount, and refetches the agent once it finishes, so a reload or a navigation shows the running edit instead of allowing a second (the server answers 409). No backup is taken: the volume is untouched. |
|
|
51
|
+
| `EditAgentModal` / `EditBanner` | `app/agents/PageClient.tsx` | Rename/ports editing. The modal has **Display Name** and **Host Port** (same input and validation messages as the create wizard, from `lib/agent-ports.ts`), sends `PATCH /api/agents/[id]` and closes on `202`; `EditBanner` (top of the agent detail) polls `GET /api/agents/[id]` every 2 s while an edit is `rebuilding`, resumes on mount, and refetches the agent once it finishes, so a reload or a navigation shows the running edit instead of allowing a second (the server answers 409). No backup is taken: the volume is untouched. The port range is validated **as it is typed**: the modal fetches `GET /api/agents/ports` when it opens (the same set the create form uses, every container, running or stopped) and excludes the agent's own block, so an occupied range shows the API's message inline and the Save button stays disabled — the create wizard does the same with the same endpoint. |
|
|
52
52
|
| `ModelSection` | `app/agents/ModelSection.tsx` | Primary model and fallbacks for one agent, in its detail panel. Explicit save, no restart. The model is a property of the agent, not of the gateway. Tags models the catalogue marks `deprecated`. |
|
|
53
53
|
| `ModelsProvider` / `useModels` | `app/lib/models-context.tsx` | The client's single model list, from `/api/models`. Whatever changes what is offered calls `refresh()`: the Gateway page after a toggle, a key save or removal, or a sync, and the first-run wizard after saving keys. Everything else only reads, PulseChat included. |
|
|
54
54
|
| `WizardPageClient` + step components | `app/wizard/PageClient.tsx` | Multi-step first-run wizard plus its frame shell, with mobile-first CSS. |
|
|
@@ -704,6 +704,27 @@ List running agent containers (Docker containers with `AGENT_ID` label) with ful
|
|
|
704
704
|
|
|
705
705
|
---
|
|
706
706
|
|
|
707
|
+
### `GET /api/agents/ports`
|
|
708
|
+
The host ports held by any container, running or stopped: the set the validators in
|
|
709
|
+
`lib/agent-ports.ts` take, sorted ascending. Backed by the shared lookup
|
|
710
|
+
(`getUsedHostPorts()`, `lib/agent-ports-server.ts`), which reads every container's
|
|
711
|
+
`HostConfig.PortBindings` — a stopped container keeps its block while `docker ps -a` and
|
|
712
|
+
the API's container list report nothing for it.
|
|
713
|
+
|
|
714
|
+
**Auth:** `rev4a_token` cookie or `Authorization: Bearer <REV4A_TOKEN>`
|
|
715
|
+
|
|
716
|
+
**Response:**
|
|
717
|
+
```json
|
|
718
|
+
{ "ports": [3700, 3701, 3710, 3711, 3741, 5555] }
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
The Edit panel fetches it when it opens and the create form refreshes it on mount (the
|
|
722
|
+
page also receives it as a prop), so both validate a range as the operator types, against
|
|
723
|
+
the same numbers and with the same messages, and both disable their action while the range
|
|
724
|
+
is refused.
|
|
725
|
+
|
|
726
|
+
---
|
|
727
|
+
|
|
707
728
|
### `GET /api/agents/models-summary`
|
|
708
729
|
One row per agent container, running or not: the model it runs and whether that
|
|
709
730
|
model is still in good standing. Feeds the model chip on the agent cards. Running
|
|
@@ -795,7 +816,7 @@ Create a new agent container from a template.
|
|
|
795
816
|
|---|---|---|
|
|
796
817
|
| `name` | yes | Display name (`AGENT_NAME`); the container name and `AGENT_ID` are generated (`agent_<hex>`) |
|
|
797
818
|
| `template` | yes | Template name (directory in `agent-templates/`) |
|
|
798
|
-
| `portRange` | no | Optional port range (e.g. `3700-3709`) or single port (e.g. `3700`). Default: auto-assigned 10-port block |
|
|
819
|
+
| `portRange` | no | Optional port range (e.g. `3700-3709`) or single port (e.g. `3700`). Default: auto-assigned 10-port block, chosen by `findAvailablePortBlock()` |
|
|
799
820
|
| `model` | no | Primary model id. Must be offered by this deployment (in the catalogue, enabled, provider has a key), in any form the proxy accepts; otherwise `400`. Defaults to `rev4a/deepseek/deepseek-flash`, validated the same way |
|
|
800
821
|
| `fallbacks` | no | Array of fallback model IDs |
|
|
801
822
|
|
|
@@ -804,6 +825,9 @@ Create a new agent container from a template.
|
|
|
804
825
|
- Single port: `-p <port>:3000` — maps a single host port to container port 3000
|
|
805
826
|
- Control UI: the wizard's Open button goes through `GET /api/agents/[id]/open-control-ui`, like the Agents page
|
|
806
827
|
- Port 3740 is reserved for Rev4a
|
|
828
|
+
- A port held by any container, running or stopped, is refused (`400`): the shared lookup
|
|
829
|
+
reads every container's bindings (`lib/agent-ports-server.ts`), because a stopped
|
|
830
|
+
container keeps its allocation while `docker ps -a` and the API's list report none
|
|
807
831
|
- Image: `openclaw-agent-base:<version>`, the newest supported OpenClaw version downloaded here; `409` when none is
|
|
808
832
|
|
|
809
833
|
**Response:**
|
|
@@ -886,12 +910,23 @@ touch the agent answers `409`.
|
|
|
886
910
|
|
|
887
911
|
`portRange` is `"<start>"` or `"<start>-<end>"`, mapped onto the gateway port 3000
|
|
888
912
|
(`3700-3709` → `3700-3709:3000-3009`). It is validated with the same rules and messages as
|
|
889
|
-
agent creation (`lib/agent-ports.ts`)
|
|
890
|
-
|
|
913
|
+
agent creation (`lib/agent-ports.ts`) against the shared lookup
|
|
914
|
+
(`getUsedHostPorts()`, `lib/agent-ports-server.ts`): a range that holds Rev4a's port
|
|
915
|
+
`3740`, an invalid format, or a port already held by **another** container answers `400`
|
|
891
916
|
(`Ports already in use: 3711, 3712`) before anything is rebuilt. The agent's own published
|
|
892
917
|
ports are excluded, so keeping or shifting its block is not a conflict with itself.
|
|
918
|
+
A port held by a **stopped** container counts as in use: the lookup reads every
|
|
919
|
+
container's `HostConfig.PortBindings`, because both `docker ps -a --format '{{.Ports}}'`
|
|
920
|
+
and the API's container list report nothing for a container that is not running.
|
|
893
921
|
At least one field is required; an empty `displayName` is rejected.
|
|
894
922
|
|
|
923
|
+
The rebuild itself (`recreateAgentContainer()`) re-checks the ports it is about to
|
|
924
|
+
publish against every other container and refuses **before removing anything** when one
|
|
925
|
+
is taken, naming the holder: `Port 3700 is already published by agent_24a68ac9 (Drill
|
|
926
|
+
Wake). Change this agent's port range in the Edit panel, then retry: nothing was
|
|
927
|
+
changed.` The row lands in `failed` with that message and the agent keeps running. The
|
|
928
|
+
same guard protects Update and Rollback, whose rows record the same text.
|
|
929
|
+
|
|
895
930
|
**Response (202):**
|
|
896
931
|
```json
|
|
897
932
|
{ "started": true, "id": 4 }
|
|
@@ -2102,16 +2137,16 @@ Update audio or timezone config in `openclaw.json`.
|
|
|
2102
2137
|
## Version & WebSocket
|
|
2103
2138
|
|
|
2104
2139
|
### `GET /api/version`
|
|
2105
|
-
Return the installed
|
|
2140
|
+
Return the installed Rev4a version, read from `package.json`.
|
|
2106
2141
|
|
|
2107
|
-
**Auth:**
|
|
2142
|
+
**Auth:** `rev4a_token` cookie or `Authorization: Bearer <REV4A_TOKEN>`
|
|
2108
2143
|
|
|
2109
2144
|
**Response:**
|
|
2110
2145
|
```json
|
|
2111
|
-
{ "version": "
|
|
2146
|
+
{ "version": "1.4.0" }
|
|
2112
2147
|
```
|
|
2113
2148
|
|
|
2114
|
-
**Fallback:** `{ "version": "
|
|
2149
|
+
**Fallback:** `{ "version": "0.0.0" }` if `package.json` cannot be read.
|
|
2115
2150
|
|
|
2116
2151
|
### Terminal WebSocket
|
|
2117
2152
|
|
package/lib/agent-edit.ts
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* cannot start on top of the first (lib/agent-busy.ts answers 409 while one runs).
|
|
9
9
|
*/
|
|
10
10
|
import { isValidAgentId } from '@/lib/container';
|
|
11
|
-
import { portMappingArgs, validatePortInput } from '@/lib/agent-ports';
|
|
11
|
+
import { hostPortsFromBindings, portMappingArgs, validatePortInput } from '@/lib/agent-ports';
|
|
12
12
|
import { getUsedHostPorts } from '@/lib/agent-ports-server';
|
|
13
|
-
import { resolveRecreateImage
|
|
14
|
-
import { inspectAgentContainer, recreateAgentContainer } from '@/lib/agent-recreate';
|
|
13
|
+
import { resolveRecreateImage } from '@/lib/agent-images';
|
|
14
|
+
import { inspectAgentContainer, recreateAgentContainer, startAndVerifyContainer } from '@/lib/agent-recreate';
|
|
15
15
|
import { agentBusyReason } from '@/lib/agent-busy';
|
|
16
16
|
import { waitForGatewayReady } from '@/lib/agent-readiness';
|
|
17
17
|
import { applyRuntimeConfig } from '@/lib/agent-setup';
|
|
@@ -29,22 +29,14 @@ export class EditRefusedError extends Error {}
|
|
|
29
29
|
* with itself — while a conflict with another agent still fails **before** the container
|
|
30
30
|
* is removed.
|
|
31
31
|
*/
|
|
32
|
-
function parsePortArgs(portRange: string, ownPorts: number[]): string[] {
|
|
33
|
-
const used = getUsedHostPorts();
|
|
32
|
+
async function parsePortArgs(portRange: string, ownPorts: number[]): Promise<string[]> {
|
|
33
|
+
const used = await getUsedHostPorts();
|
|
34
34
|
for (const p of ownPorts) used.delete(p);
|
|
35
35
|
const result = validatePortInput(portRange, used);
|
|
36
36
|
if (!result.valid) throw new EditRefusedError(result.error);
|
|
37
37
|
return portMappingArgs(result.start, result.end);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** The agent's own published host ports, from its container inspect. */
|
|
41
|
-
function ownHostPorts(container: { HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> } }): number[] {
|
|
42
|
-
return Object.values(container.HostConfig?.PortBindings ?? {})
|
|
43
|
-
.flatMap((bindings) => bindings ?? [])
|
|
44
|
-
.map((b) => Number(b?.HostPort))
|
|
45
|
-
.filter((n) => Number.isInteger(n));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
40
|
/** Edit jobs running in this process, so a second start for the same agent is refused. */
|
|
49
41
|
const running = new Set<string>();
|
|
50
42
|
|
|
@@ -74,7 +66,7 @@ export async function startAgentEdit(agentId: string, opts: EditOptions): Promis
|
|
|
74
66
|
if (!container) throw new EditRefusedError(`No container found with AGENT_ID '${agentId}'`);
|
|
75
67
|
// A bad range is refused before any state is written, and before the container is
|
|
76
68
|
// removed — the only failure mode that could leave the agent without one.
|
|
77
|
-
const portArgs = opts.portRange ? parsePortArgs(opts.portRange,
|
|
69
|
+
const portArgs = opts.portRange ? await parsePortArgs(opts.portRange, hostPortsFromBindings(container)) : undefined;
|
|
78
70
|
|
|
79
71
|
const id = insertEdit(agentId, displayName ?? null, portRange);
|
|
80
72
|
void runEdit(id, agentId, container, displayName ?? null, portArgs).finally(() => running.delete(agentId));
|
|
@@ -149,9 +141,9 @@ export async function recoverInterruptedEdits(): Promise<number> {
|
|
|
149
141
|
for (const agentId of ids) {
|
|
150
142
|
const container = await inspectAgentContainer(agentId).catch(() => null);
|
|
151
143
|
const name = container?.Name?.replace(/^\//, '');
|
|
152
|
-
if (name
|
|
153
|
-
|
|
154
|
-
}
|
|
144
|
+
if (!name || container?.State?.Running) continue;
|
|
145
|
+
const started = await startAndVerifyContainer(agentId, name);
|
|
146
|
+
if (!started.ok) console.warn(`[agent:edit] ${agentId} did not come back after an interrupted edit: ${started.error}`);
|
|
155
147
|
}
|
|
156
148
|
return interrupted;
|
|
157
149
|
}
|
|
@@ -1,27 +1,78 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server-side host-port lookup for lib/agent-ports.ts. Split out because it uses
|
|
3
|
-
*
|
|
2
|
+
* Server-side host-port lookup for lib/agent-ports.ts. Split out because it uses the
|
|
3
|
+
* Docker socket, which client components cannot reach.
|
|
4
|
+
*
|
|
5
|
+
* This is the only place that answers "which host ports are taken": agent creation,
|
|
6
|
+
* the create form, the edit (rename/ports) flow and every container rebuild (update,
|
|
7
|
+
* recreate, rollback) all read their numbers from here.
|
|
8
|
+
*
|
|
9
|
+
* The numbers come from each container's `HostConfig.PortBindings`, the only source
|
|
10
|
+
* that also covers containers that are not running. Neither
|
|
11
|
+
* `docker ps -a --format '{{.Ports}}'` nor the API's container list reports ports for
|
|
12
|
+
* a stopped container (verified on Docker 29: both return nothing), while the
|
|
13
|
+
* allocation is still held — starting that container again fails with "port is
|
|
14
|
+
* already allocated". Reading the bindings is what stops a new agent from being given
|
|
15
|
+
* the block of a stopped one.
|
|
4
16
|
*/
|
|
5
|
-
import {
|
|
17
|
+
import { dockerFetch } from '@/lib/docker-socket';
|
|
18
|
+
|
|
19
|
+
export interface HostPortHolder {
|
|
20
|
+
/** Container name; agent containers are named after their id. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** The container's `AGENT_NAME`, the name the panel shows; null for non-agent containers. */
|
|
23
|
+
displayName: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ContainerInspect {
|
|
27
|
+
Id?: string;
|
|
28
|
+
Name?: string;
|
|
29
|
+
Config?: { Env?: string[] };
|
|
30
|
+
HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> };
|
|
31
|
+
}
|
|
6
32
|
|
|
7
33
|
/**
|
|
8
|
-
* Host ports
|
|
9
|
-
*
|
|
34
|
+
* Host ports held by containers, running or stopped, each mapped to the container that
|
|
35
|
+
* holds it. `excludeContainerId` drops one container from the result: the block of the
|
|
36
|
+
* container a rebuild is about to replace is not a conflict with itself.
|
|
10
37
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* every agent; `grep -P` is also GNU-only and absent on macOS.
|
|
38
|
+
* Best-effort: an unreachable daemon yields an empty map, as the previous shell-based
|
|
39
|
+
* lookup did, rather than failing the caller.
|
|
14
40
|
*/
|
|
15
|
-
export function
|
|
41
|
+
export async function getHostPortHolders(opts: { excludeContainerId?: string } = {}): Promise<Map<number, HostPortHolder>> {
|
|
42
|
+
const holders = new Map<number, HostPortHolder>();
|
|
43
|
+
let list: { Id?: string }[];
|
|
16
44
|
try {
|
|
17
|
-
|
|
18
|
-
const used = new Set<number>();
|
|
19
|
-
for (const [, port] of raw.matchAll(/(\d+)->/g)) {
|
|
20
|
-
const n = Number(port);
|
|
21
|
-
if (Number.isInteger(n)) used.add(n);
|
|
22
|
-
}
|
|
23
|
-
return used;
|
|
45
|
+
list = await dockerFetch<{ Id?: string }[]>('GET', '/containers/json?all=true');
|
|
24
46
|
} catch {
|
|
25
|
-
return
|
|
47
|
+
return holders;
|
|
26
48
|
}
|
|
49
|
+
const ids = (Array.isArray(list) ? list : [])
|
|
50
|
+
.map((c) => c.Id)
|
|
51
|
+
.filter((id): id is string => typeof id === 'string' && id !== opts.excludeContainerId);
|
|
52
|
+
const infos = await Promise.all(
|
|
53
|
+
ids.map((id) => dockerFetch<ContainerInspect>('GET', `/containers/${id}/json`).catch(() => null)),
|
|
54
|
+
);
|
|
55
|
+
for (const info of infos) {
|
|
56
|
+
if (!info) continue;
|
|
57
|
+
const name = (info.Name ?? '').replace(/^\//, '') || (info.Id ?? '').slice(0, 12);
|
|
58
|
+
const agentName = (info.Config?.Env ?? []).find((entry) => entry.startsWith('AGENT_NAME='));
|
|
59
|
+
const holder: HostPortHolder = { name, displayName: agentName ? agentName.slice('AGENT_NAME='.length) : null };
|
|
60
|
+
for (const bindings of Object.values(info.HostConfig?.PortBindings ?? {})) {
|
|
61
|
+
for (const binding of bindings ?? []) {
|
|
62
|
+
const port = Number(binding?.HostPort);
|
|
63
|
+
if (Number.isInteger(port)) holders.set(port, holder);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return holders;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The port set the validators in lib/agent-ports.ts take. */
|
|
71
|
+
export async function getUsedHostPorts(opts: { excludeContainerId?: string } = {}): Promise<Set<number>> {
|
|
72
|
+
return new Set((await getHostPortHolders(opts)).keys());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How a holder reads in an error: `agent_24a68ac9 (Drill Wake)`, or the name alone. */
|
|
76
|
+
export function describeHolder(holder: HostPortHolder): string {
|
|
77
|
+
return holder.displayName ? `${holder.name} (${holder.displayName})` : holder.name;
|
|
27
78
|
}
|
package/lib/agent-ports.ts
CHANGED
|
@@ -66,3 +66,26 @@ export function validatePortInput(input: string, usedPorts: ReadonlySet<number>
|
|
|
66
66
|
export function portMappingArgs(start: number, end: number): string[] {
|
|
67
67
|
return ['-p', end === start ? `${start}:3000` : `${start}-${end}:3000-${3000 + (end - start)}`];
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/** The host ports a container publishes, from its inspect (`HostConfig.PortBindings`). */
|
|
71
|
+
export function hostPortsFromBindings(container: {
|
|
72
|
+
HostConfig?: { PortBindings?: Record<string, { HostPort?: string }[] | null> };
|
|
73
|
+
}): number[] {
|
|
74
|
+
return Object.values(container.HostConfig?.PortBindings ?? {})
|
|
75
|
+
.flatMap((bindings) => bindings ?? [])
|
|
76
|
+
.map((b) => Number(b?.HostPort))
|
|
77
|
+
.filter((n) => Number.isInteger(n));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The host ports a `docker run` argument list publishes: `3700:3000` or `3700-3709:3000-3009`. */
|
|
81
|
+
export function hostPortsFromArgs(portArgs: readonly string[]): number[] {
|
|
82
|
+
const ports: number[] = [];
|
|
83
|
+
for (let i = 0; i < portArgs.length - 1; i++) {
|
|
84
|
+
if (portArgs[i] !== '-p') continue;
|
|
85
|
+
const host = (portArgs[i + 1] ?? '').split(':')[0];
|
|
86
|
+
const [start, end] = host.split('-').map(Number);
|
|
87
|
+
if (!Number.isInteger(start)) continue;
|
|
88
|
+
for (let p = start; p <= (Number.isInteger(end) ? end : start); p++) ports.push(p);
|
|
89
|
+
}
|
|
90
|
+
return ports;
|
|
91
|
+
}
|
package/lib/agent-recreate.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { dockerFetch } from '@/lib/docker-socket';
|
|
14
14
|
import { runDocker, resolveRecreateImage } from '@/lib/agent-images';
|
|
15
|
+
import { hostPortsFromArgs } from '@/lib/agent-ports';
|
|
16
|
+
import { getHostPortHolders, describeHolder, type HostPortHolder } from '@/lib/agent-ports-server';
|
|
15
17
|
import { getMountFlags, applyRuntimeConfig } from '@/lib/agent-setup';
|
|
16
18
|
import { isValidAgentId } from '@/lib/container';
|
|
17
19
|
import { BACKUP_VOLUME, coldBackupStatus, isColdBackupRunning, startColdBackup, waitForColdBackup } from '@/lib/cold-backup';
|
|
@@ -30,12 +32,13 @@ const RECREATE_READY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
30
32
|
const COLD_BACKUP_WAIT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
31
33
|
|
|
32
34
|
export interface AgentContainerInspect {
|
|
35
|
+
Id?: string;
|
|
33
36
|
Name?: string;
|
|
34
37
|
Image?: string;
|
|
35
38
|
Config?: { Image?: string; Env?: string[]; Labels?: Record<string, string> };
|
|
36
39
|
State?: { Running?: boolean; Status?: string };
|
|
37
40
|
HostConfig?: { NetworkMode?: string; PortBindings?: Record<string, { HostPort?: string }[] | null> };
|
|
38
|
-
NetworkSettings?: { Networks?: Record<string, unknown> };
|
|
41
|
+
NetworkSettings?: { Networks?: Record<string, unknown>; Ports?: Record<string, unknown> };
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/** The container carrying `AGENT_ID=<agentId>`, inspected; null when there is none. */
|
|
@@ -48,6 +51,31 @@ export async function inspectAgentContainer(agentId: string): Promise<AgentConta
|
|
|
48
51
|
return typeof info?.Id === 'string' ? info : null;
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Start the container and confirm it really came back. A container can report
|
|
56
|
+
* `running` while its network endpoint failed to attach — the state a failed port bind
|
|
57
|
+
* leaves behind — which would otherwise pass as a successful start.
|
|
58
|
+
*/
|
|
59
|
+
export async function startAndVerifyContainer(agentId: string, name: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
60
|
+
try {
|
|
61
|
+
await runDocker(['start', name], { timeoutMs: 60_000 });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return { ok: false, error: shortDockerError(e) };
|
|
64
|
+
}
|
|
65
|
+
const after = await inspectAgentContainer(agentId).catch(() => null);
|
|
66
|
+
if (!after?.State?.Running) return { ok: false, error: 'the container is not running' };
|
|
67
|
+
if (Object.keys(after.NetworkSettings?.Networks ?? {}).length === 0) {
|
|
68
|
+
return { ok: false, error: 'the container came up without a network (one of its ports is taken by another container)' };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A docker failure carries a whole stack trace; the first line is the useful part. */
|
|
74
|
+
function shortDockerError(e: unknown): string {
|
|
75
|
+
const message = (e as Error)?.message ?? String(e);
|
|
76
|
+
return message.split('\n').map((l) => l.trim()).filter(Boolean)[0] ?? message;
|
|
77
|
+
}
|
|
78
|
+
|
|
51
79
|
/**
|
|
52
80
|
* Remove the container and run a new one on `image` with the same parameters. The new
|
|
53
81
|
* container is named after the agent id, as the create route names it. `opts.env`
|
|
@@ -88,6 +116,23 @@ export async function recreateAgentContainer(
|
|
|
88
116
|
return ['-p', `${hostPort}:${containerPort.replace(/\/.*$/, '')}${proto}`];
|
|
89
117
|
});
|
|
90
118
|
|
|
119
|
+
// Refuse on a port clash before anything is removed: the old container is the
|
|
120
|
+
// fallback the caller restarts, so a recreate refused here leaves the agent up. A
|
|
121
|
+
// clash cannot be recovered from by retrying, so it must not reach `docker run`.
|
|
122
|
+
const wanted = hostPortsFromArgs(portArgs);
|
|
123
|
+
if (wanted.length && container.Id) {
|
|
124
|
+
const held = await getHostPortHolders({ excludeContainerId: container.Id }).catch(() => new Map<number, HostPortHolder>());
|
|
125
|
+
const conflicts = wanted.filter((p) => held.has(p));
|
|
126
|
+
if (conflicts.length) {
|
|
127
|
+
const holders = [...new Set(conflicts.map((p) => describeHolder(held.get(p)!)))];
|
|
128
|
+
const many = conflicts.length > 1;
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${many ? 'Ports' : 'Port'} ${conflicts.join(', ')} ${many ? 'are' : 'is'} already published by ${holders.join(', ')}. `
|
|
131
|
+
+ 'Change this agent\'s port range in the Edit panel, then retry: nothing was changed.',
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
91
136
|
const oldName = (container.Name ?? '').replace(/^\//, '') || agentId;
|
|
92
137
|
await runDocker(['rm', '-f', oldName], { timeoutMs: 60_000 });
|
|
93
138
|
|
|
@@ -115,7 +160,16 @@ export async function recreateAgentContainer(
|
|
|
115
160
|
console.warn(`[agent:recreate] docker run failed for ${agentId}, retrying:`, (first as Error).message);
|
|
116
161
|
await runDocker(['rm', '-f', agentId], { timeoutMs: 60_000 }).catch(() => {});
|
|
117
162
|
const retryImage = await resolveRecreateImage(container).catch(() => image);
|
|
118
|
-
|
|
163
|
+
try {
|
|
164
|
+
await runDocker(runArgs(retryImage), { timeoutMs: 120_000, env });
|
|
165
|
+
} catch (second) {
|
|
166
|
+
// `docker run` creates the container before its network step, so a failed start
|
|
167
|
+
// leaves a container behind — one that reports `running` with no network and
|
|
168
|
+
// would be picked up as if it were the agent. Remove it, so the state the caller
|
|
169
|
+
// reports matches reality.
|
|
170
|
+
await runDocker(['rm', '-f', agentId], { timeoutMs: 60_000 }).catch(() => {});
|
|
171
|
+
throw second;
|
|
172
|
+
}
|
|
119
173
|
}
|
|
120
174
|
}
|
|
121
175
|
|
|
@@ -267,9 +321,9 @@ async function finishInterruptedRecreate(agentId: string): Promise<void> {
|
|
|
267
321
|
if (running) await waitForColdBackup(agentId).catch(() => null);
|
|
268
322
|
const container = await inspectAgentContainer(agentId).catch(() => null);
|
|
269
323
|
const name = container?.Name?.replace(/^\//, '');
|
|
270
|
-
if (name
|
|
271
|
-
|
|
272
|
-
}
|
|
324
|
+
if (!name || container?.State?.Running) return;
|
|
325
|
+
const started = await startAndVerifyContainer(agentId, name);
|
|
326
|
+
if (!started.ok) console.warn(`[agent:recreate] ${agentId} did not come back after an interrupted recreate: ${started.error}`);
|
|
273
327
|
}
|
|
274
328
|
|
|
275
329
|
// ── Prerecreate retention ────────────────────────────────────────────────────
|
package/lib/agent-restore.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { isValidAgentId } from '@/lib/container';
|
|
11
11
|
import { runDocker } from '@/lib/agent-images';
|
|
12
12
|
import { BACKUP_VOLUME } from '@/lib/cold-backup';
|
|
13
|
-
import { inspectAgentContainer } from '@/lib/agent-recreate';
|
|
13
|
+
import { inspectAgentContainer, startAndVerifyContainer } from '@/lib/agent-recreate';
|
|
14
14
|
import { activeRestoreAgentIds, insertRestore, isRestoreActive, latestRestore, markInterruptedRestores, updateRestoreRow, type AgentRestoreRow } from '@/lib/agent-restore-state';
|
|
15
15
|
import { agentBusyReason } from '@/lib/agent-busy';
|
|
16
16
|
|
|
@@ -60,6 +60,7 @@ export async function startAgentRestore(agentId: string, file: string): Promise<
|
|
|
60
60
|
async function runRestore(id: number, agentId: string, file: string): Promise<void> {
|
|
61
61
|
const container = await inspectAgentContainer(agentId).catch(() => null);
|
|
62
62
|
const name = container?.Name?.replace(/^\//, '');
|
|
63
|
+
let failure: string | null = null;
|
|
63
64
|
try {
|
|
64
65
|
if (name && container?.State?.Running) {
|
|
65
66
|
await runDocker(['stop', '-t', '30', name], { timeoutMs: 45_000 }).catch(() => {});
|
|
@@ -77,16 +78,21 @@ async function runRestore(id: number, agentId: string, file: string): Promise<vo
|
|
|
77
78
|
],
|
|
78
79
|
{ timeoutMs: EXTRACT_TIMEOUT_MS, env: { FILE: file } },
|
|
79
80
|
);
|
|
80
|
-
|
|
81
|
-
updateRestoreRow(id, { status: 'done' });
|
|
82
81
|
} catch (e) {
|
|
83
|
-
|
|
82
|
+
failure = (e as Error)?.message ?? String(e);
|
|
84
83
|
} finally {
|
|
85
84
|
// The container is started again even when the extract failed, so the agent never
|
|
86
|
-
// stays down.
|
|
85
|
+
// stays down. The row is only `done` when it really came back: a container can
|
|
86
|
+
// report running while its network never attached, which is not a usable agent.
|
|
87
87
|
if (name) {
|
|
88
|
-
await
|
|
88
|
+
const started = await startAndVerifyContainer(agentId, name);
|
|
89
|
+
if (!started.ok) {
|
|
90
|
+
const note = `the container did not come back: ${started.error}`;
|
|
91
|
+
failure = failure ? `${failure}; ${note}` : `the volume was restored, but ${note}`;
|
|
92
|
+
}
|
|
89
93
|
}
|
|
94
|
+
if (failure) updateRestoreRow(id, { status: 'failed', error: failure });
|
|
95
|
+
else updateRestoreRow(id, { status: 'done' });
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
|
|
@@ -127,9 +133,9 @@ export async function recoverInterruptedRestores(): Promise<number> {
|
|
|
127
133
|
for (const agentId of ids) {
|
|
128
134
|
const container = await inspectAgentContainer(agentId).catch(() => null);
|
|
129
135
|
const name = container?.Name?.replace(/^\//, '');
|
|
130
|
-
if (name
|
|
131
|
-
|
|
132
|
-
}
|
|
136
|
+
if (!name || container?.State?.Running) continue;
|
|
137
|
+
const started = await startAndVerifyContainer(agentId, name);
|
|
138
|
+
if (!started.ok) console.warn(`[agent:restore] ${agentId} did not come back after an interrupted restore: ${started.error}`);
|
|
133
139
|
}
|
|
134
140
|
return interrupted;
|
|
135
141
|
}
|
package/lib/agent-update.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
runDocker,
|
|
33
33
|
} from '@/lib/agent-images';
|
|
34
34
|
import { BACKUP_VOLUME, coldBackupStatus, isColdBackupRunning, startColdBackup, waitForColdBackup } from '@/lib/cold-backup';
|
|
35
|
-
import { inspectAgentContainer, recreateAgentContainer, type AgentContainerInspect } from '@/lib/agent-recreate';
|
|
35
|
+
import { inspectAgentContainer, recreateAgentContainer, startAndVerifyContainer, type AgentContainerInspect } from '@/lib/agent-recreate';
|
|
36
36
|
import { applyRuntimeConfig } from '@/lib/agent-setup';
|
|
37
37
|
import { waitForGatewayReady } from '@/lib/agent-readiness';
|
|
38
38
|
import { patchRev4aProvider } from '@/app/api/gateway/sync';
|
|
@@ -251,12 +251,20 @@ async function runUpdate(id: number, agentId: string, name: string, fromVersion:
|
|
|
251
251
|
.catch((err: unknown) => console.warn('[agent:update] image cleanup failed:', (err as Error).message));
|
|
252
252
|
} catch (e) {
|
|
253
253
|
// A failure before the recreate leaves the old container stopped by the backup:
|
|
254
|
-
// start it again, so a failed update never leaves the agent down.
|
|
254
|
+
// start it again, so a failed update never leaves the agent down. When it does not
|
|
255
|
+
// come back the failure text says so — a container that is "running" with no network
|
|
256
|
+
// is not an agent the operator can use.
|
|
255
257
|
if (stopped) {
|
|
256
258
|
const container = await inspectAgentContainer(agentId).catch(() => null);
|
|
257
259
|
const containerName = container?.Name?.replace(/^\//, '');
|
|
258
260
|
if (containerName && !container?.State?.Running) {
|
|
259
|
-
await
|
|
261
|
+
const started = await startAndVerifyContainer(agentId, containerName);
|
|
262
|
+
if (!started.ok) {
|
|
263
|
+
const note = `the previous container did not come back: ${started.error}`;
|
|
264
|
+
const message = shortError(e);
|
|
265
|
+
updateRow(id, { status: 'failed', error: `${message}; ${note}` });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
260
268
|
}
|
|
261
269
|
}
|
|
262
270
|
updateRow(id, { status: 'failed', error: shortError(e) });
|
|
@@ -285,9 +293,9 @@ export async function recoverInterruptedUpdates(): Promise<number> {
|
|
|
285
293
|
for (const agentId of ids) {
|
|
286
294
|
const container = await inspectAgentContainer(agentId).catch(() => null);
|
|
287
295
|
const name = container?.Name?.replace(/^\//, '');
|
|
288
|
-
if (name
|
|
289
|
-
|
|
290
|
-
}
|
|
296
|
+
if (!name || container?.State?.Running) continue;
|
|
297
|
+
const started = await startAndVerifyContainer(agentId, name);
|
|
298
|
+
if (!started.ok) console.warn(`[agent:update] ${agentId} did not come back after an interrupted update: ${started.error}`);
|
|
291
299
|
}
|
|
292
300
|
return interrupted;
|
|
293
301
|
}
|