@flame0510/project-aether 1.4.0 → 1.4.1
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/create/page.tsx +14 -28
- package/app/api/agents/create/route.ts +8 -6
- package/app/api/version/route.ts +2 -1
- package/docs/ARCHITECTURE.md +19 -7
- package/docs/dev/API-REFERENCE.md +21 -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
|
@@ -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
|
}
|
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,25 @@ 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.
|
|
180
192
|
- **Agent edits** are `lib/agent-edit.ts`, with their state in the `agent_edits` table
|
|
181
193
|
(`lib/agent-edit-state.ts`): the display name (`AGENT_NAME`) and/or the port range are
|
|
182
194
|
applied by rebuilding the container on the image it already runs — **no backup**, the
|
|
@@ -795,7 +795,7 @@ Create a new agent container from a template.
|
|
|
795
795
|
|---|---|---|
|
|
796
796
|
| `name` | yes | Display name (`AGENT_NAME`); the container name and `AGENT_ID` are generated (`agent_<hex>`) |
|
|
797
797
|
| `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 |
|
|
798
|
+
| `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
799
|
| `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
800
|
| `fallbacks` | no | Array of fallback model IDs |
|
|
801
801
|
|
|
@@ -804,6 +804,9 @@ Create a new agent container from a template.
|
|
|
804
804
|
- Single port: `-p <port>:3000` — maps a single host port to container port 3000
|
|
805
805
|
- Control UI: the wizard's Open button goes through `GET /api/agents/[id]/open-control-ui`, like the Agents page
|
|
806
806
|
- Port 3740 is reserved for Rev4a
|
|
807
|
+
- A port held by any container, running or stopped, is refused (`400`): the shared lookup
|
|
808
|
+
reads every container's bindings (`lib/agent-ports-server.ts`), because a stopped
|
|
809
|
+
container keeps its allocation while `docker ps -a` and the API's list report none
|
|
807
810
|
- Image: `openclaw-agent-base:<version>`, the newest supported OpenClaw version downloaded here; `409` when none is
|
|
808
811
|
|
|
809
812
|
**Response:**
|
|
@@ -886,12 +889,23 @@ touch the agent answers `409`.
|
|
|
886
889
|
|
|
887
890
|
`portRange` is `"<start>"` or `"<start>-<end>"`, mapped onto the gateway port 3000
|
|
888
891
|
(`3700-3709` → `3700-3709:3000-3009`). It is validated with the same rules and messages as
|
|
889
|
-
agent creation (`lib/agent-ports.ts`)
|
|
890
|
-
|
|
892
|
+
agent creation (`lib/agent-ports.ts`) against the shared lookup
|
|
893
|
+
(`getUsedHostPorts()`, `lib/agent-ports-server.ts`): a range that holds Rev4a's port
|
|
894
|
+
`3740`, an invalid format, or a port already held by **another** container answers `400`
|
|
891
895
|
(`Ports already in use: 3711, 3712`) before anything is rebuilt. The agent's own published
|
|
892
896
|
ports are excluded, so keeping or shifting its block is not a conflict with itself.
|
|
897
|
+
A port held by a **stopped** container counts as in use: the lookup reads every
|
|
898
|
+
container's `HostConfig.PortBindings`, because both `docker ps -a --format '{{.Ports}}'`
|
|
899
|
+
and the API's container list report nothing for a container that is not running.
|
|
893
900
|
At least one field is required; an empty `displayName` is rejected.
|
|
894
901
|
|
|
902
|
+
The rebuild itself (`recreateAgentContainer()`) re-checks the ports it is about to
|
|
903
|
+
publish against every other container and refuses **before removing anything** when one
|
|
904
|
+
is taken, naming the holder: `Port 3700 is already published by agent_24a68ac9 (Drill
|
|
905
|
+
Wake). Change this agent's port range in the Edit panel, then retry: nothing was
|
|
906
|
+
changed.` The row lands in `failed` with that message and the agent keeps running. The
|
|
907
|
+
same guard protects Update and Rollback, whose rows record the same text.
|
|
908
|
+
|
|
895
909
|
**Response (202):**
|
|
896
910
|
```json
|
|
897
911
|
{ "started": true, "id": 4 }
|
|
@@ -2102,16 +2116,16 @@ Update audio or timezone config in `openclaw.json`.
|
|
|
2102
2116
|
## Version & WebSocket
|
|
2103
2117
|
|
|
2104
2118
|
### `GET /api/version`
|
|
2105
|
-
Return the installed
|
|
2119
|
+
Return the installed Rev4a version, read from `package.json`.
|
|
2106
2120
|
|
|
2107
|
-
**Auth:**
|
|
2121
|
+
**Auth:** `rev4a_token` cookie or `Authorization: Bearer <REV4A_TOKEN>`
|
|
2108
2122
|
|
|
2109
2123
|
**Response:**
|
|
2110
2124
|
```json
|
|
2111
|
-
{ "version": "
|
|
2125
|
+
{ "version": "1.4.0" }
|
|
2112
2126
|
```
|
|
2113
2127
|
|
|
2114
|
-
**Fallback:** `{ "version": "
|
|
2128
|
+
**Fallback:** `{ "version": "0.0.0" }` if `package.json` cannot be read.
|
|
2115
2129
|
|
|
2116
2130
|
### Terminal WebSocket
|
|
2117
2131
|
|
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
|
}
|