@flame0510/project-aether 1.1.13 → 1.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/agents/PageClient.tsx +56 -12
- package/app/agents/create/PageClient.tsx +11 -4
- package/app/api/agents/[id]/recreate/route.ts +3 -3
- package/app/api/agents/[id]/route.ts +3 -1
- package/app/api/agents/channels-summary/route.ts +48 -17
- package/app/api/agents/create/route.ts +17 -10
- package/app/api/agents/image-status/route.ts +92 -85
- package/app/api/agents/route.ts +25 -29
- package/app/api/gateway/agent/route.ts +8 -6
- package/app/api/gateway/route.ts +102 -136
- package/app/api/gateway/sync.ts +12 -15
- package/app/api/provider/upstream.ts +12 -3
- package/app/api/skills/route.js +88 -96
- package/app/components/Skeleton.tsx +71 -0
- package/app/crons/PageClient.tsx +32 -7
- package/docs/ARCHITECTURE.md +0 -1
- package/docs/dev/API-REFERENCE.md +4 -4
- package/lib/channelManager.ts +23 -16
- package/lib/docker-exec.ts +195 -0
- package/lib/docker-utils.ts +22 -48
- package/lib/openclaw-cron.ts +81 -48
- package/package.json +1 -1
|
@@ -95,6 +95,77 @@ export function CodeSkeleton({ lines = 12 }: { lines?: number }) {
|
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Cron job rows — mirrors the real row in the scheduled-jobs column so the
|
|
100
|
+
* layout doesn't jump when the data lands: same 12px/14px padding, same divider,
|
|
101
|
+
* same title/description/badge header, schedule line, and NEXT RUN / LAST RUN
|
|
102
|
+
* grid. Widths vary per row so it reads as a list rather than a stack of bars.
|
|
103
|
+
*/
|
|
104
|
+
export function CronJobsSkeleton({ rows = 5 }: { rows?: number }) {
|
|
105
|
+
const shapes = [
|
|
106
|
+
{ title: '58%', desc: '82%', badge: 54, expr: '34%' },
|
|
107
|
+
{ title: '42%', desc: null, badge: 40, expr: '28%' },
|
|
108
|
+
{ title: '66%', desc: '71%', badge: 62, expr: '40%' },
|
|
109
|
+
{ title: '50%', desc: null, badge: 46, expr: '31%' },
|
|
110
|
+
{ title: '61%', desc: '64%', badge: 58, expr: '36%' },
|
|
111
|
+
{ title: '45%', desc: null, badge: 50, expr: '26%' },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div aria-hidden="true">
|
|
116
|
+
{shapes.slice(0, rows).map((s, i) => (
|
|
117
|
+
<div key={i} style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
118
|
+
{/* Header: title + optional description on the left, agent pill right */}
|
|
119
|
+
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6, gap: 8 }}>
|
|
120
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
121
|
+
<Skeleton width={s.title} height={12} accent />
|
|
122
|
+
{s.desc && <Skeleton width={s.desc} height={9} style={{ marginTop: 4 }} />}
|
|
123
|
+
</div>
|
|
124
|
+
<Skeleton width={s.badge} height={13} style={{ flexShrink: 0 }} />
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Schedule line: label + cron expression */}
|
|
128
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
|
129
|
+
<Skeleton width={46} height={9} />
|
|
130
|
+
<Skeleton width={s.expr} height={10} />
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* NEXT RUN / LAST RUN grid */}
|
|
134
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px' }}>
|
|
135
|
+
{[0, 1].map((c) => (
|
|
136
|
+
<div key={c}>
|
|
137
|
+
<Skeleton width={52} height={8} style={{ marginBottom: 3 }} />
|
|
138
|
+
<Skeleton width="72%" height={10} />
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Cron run-history entries — one timestamp line plus a status/duration row,
|
|
150
|
+
* matching the entries the runs column renders once a job is selected.
|
|
151
|
+
*/
|
|
152
|
+
export function CronRunsSkeleton({ rows = 6 }: { rows?: number }) {
|
|
153
|
+
const widths = ['64%', '48%', '71%', '55%', '60%', '44%'];
|
|
154
|
+
return (
|
|
155
|
+
<div aria-hidden="true">
|
|
156
|
+
{widths.slice(0, rows).map((w, i) => (
|
|
157
|
+
<div key={i} style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)' }}>
|
|
158
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 5 }}>
|
|
159
|
+
<Skeleton width={w} height={10} />
|
|
160
|
+
<Skeleton width={38} height={11} style={{ flexShrink: 0 }} />
|
|
161
|
+
</div>
|
|
162
|
+
<Skeleton width="30%" height={9} />
|
|
163
|
+
</div>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
98
169
|
/* ─── Legacy aliases (used by existing components) ────────────────────────── */
|
|
99
170
|
/** Stack of horizontal skeleton lines */
|
|
100
171
|
export function SkeletonLines({ count = 3, width }: { count?: number; width?: string | number }) {
|
package/app/crons/PageClient.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useEffect, useState, useRef } from 'react';
|
|
4
4
|
import { useResponsive } from '../design-system';
|
|
5
5
|
import { Button, Pill } from '../components/ui';
|
|
6
|
-
import {
|
|
6
|
+
import { CronJobsSkeleton, CronRunsSkeleton } from '../components/Skeleton';
|
|
7
7
|
import { apiFetch } from '@/lib/apiFetch';
|
|
8
8
|
import { useRev4aTimezone } from '@/lib/hooks/useRev4aTimezone';
|
|
9
9
|
import { formatDateTimeInTimezone } from '@/lib/timezone';
|
|
@@ -266,9 +266,28 @@ export default function CronsPage() {
|
|
|
266
266
|
|
|
267
267
|
useEffect(() => {
|
|
268
268
|
const controller = new AbortController();
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
270
|
+
|
|
271
|
+
// Chained setTimeout rather than setInterval, deliberately.
|
|
272
|
+
//
|
|
273
|
+
// GET /api/crons runs `openclaw cron list --json` inside every agent
|
|
274
|
+
// container. That command takes ~5s per agent and the route walks them in
|
|
275
|
+
// sequence, so the response time scales with the number of agents and is
|
|
276
|
+
// already well past 15s on a multi-agent host. setInterval fires on schedule
|
|
277
|
+
// whether or not the previous call has come back, so those requests pile up
|
|
278
|
+
// — and since the route blocks the server's event loop while it runs, the
|
|
279
|
+
// whole dashboard stops answering for as long as this page stays open.
|
|
280
|
+
//
|
|
281
|
+
// Scheduling the next run only once the current one has settled turns the
|
|
282
|
+
// delay into a gap between polls instead of a deadline, so the loop can
|
|
283
|
+
// never outrun the endpoint no matter how slow it gets.
|
|
284
|
+
const poll = async () => {
|
|
285
|
+
await load(controller.signal);
|
|
286
|
+
if (!controller.signal.aborted) timer = setTimeout(poll, 15_000);
|
|
287
|
+
};
|
|
288
|
+
void poll();
|
|
289
|
+
|
|
290
|
+
return () => { controller.abort(); if (timer) clearTimeout(timer); };
|
|
272
291
|
}, []);
|
|
273
292
|
|
|
274
293
|
return (
|
|
@@ -278,7 +297,9 @@ export default function CronsPage() {
|
|
|
278
297
|
<span className="crons-header__meta">{runs.length} runs loaded</span>
|
|
279
298
|
</div>
|
|
280
299
|
|
|
281
|
-
{
|
|
300
|
+
{/* No loader here: the skeleton lives inside each column, so the page keeps
|
|
301
|
+
its real layout while loading instead of showing a stack of bars above
|
|
302
|
+
two empty panels. */}
|
|
282
303
|
|
|
283
304
|
{/* Agent filter bar */}
|
|
284
305
|
{agentFilters.length > 1 && (
|
|
@@ -307,9 +328,12 @@ export default function CronsPage() {
|
|
|
307
328
|
{/* Scheduled jobs */}
|
|
308
329
|
<section style={{ width: isMobile ? '100%' : '40%', borderRight: isMobile ? 'none' : '1px solid var(--border)', display: isMobile && tab !== 'jobs' ? 'none' : 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
309
330
|
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: '#555', flexShrink: 0 }}>
|
|
310
|
-
|
|
331
|
+
{/* Hide the count on the cold load — "(0)" beside a skeleton reads as
|
|
332
|
+
"no jobs", which is precisely what isn't known yet. */}
|
|
333
|
+
SCHEDULED JOBS{loading && filteredJobs.length === 0 ? '' : ` (${filteredJobs.length})`}
|
|
311
334
|
</div>
|
|
312
335
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
|
336
|
+
{loading && filteredJobs.length === 0 && <CronJobsSkeleton />}
|
|
313
337
|
{filteredJobs.length === 0 && !loading && (
|
|
314
338
|
<div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>No scheduled jobs found</div>
|
|
315
339
|
)}
|
|
@@ -471,10 +495,11 @@ export default function CronsPage() {
|
|
|
471
495
|
RUN HISTORY {selectedJob ? `— ${selectedJob.name ?? selectedJob.id?.slice(0, 8) ?? ''}` : ''}
|
|
472
496
|
</div>
|
|
473
497
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
|
498
|
+
{loading && !selectedJob && <CronRunsSkeleton />}
|
|
474
499
|
{!selectedJob && !loading && (
|
|
475
500
|
<div style={{ padding: '20px 12px', fontSize: 11, color: '#555' }}>Select a cron job to view its run history</div>
|
|
476
501
|
)}
|
|
477
|
-
{selectedJob && runsLoading && <
|
|
502
|
+
{selectedJob && runsLoading && <CronRunsSkeleton />}
|
|
478
503
|
{selectedJob && !runsLoading && runsError && (
|
|
479
504
|
<div style={{ padding: '20px 12px', fontSize: 11, color: 'var(--red)' }}>{runsError}</div>
|
|
480
505
|
)}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -548,9 +548,9 @@ List running agent containers (Docker containers with `AGENT_ID` label) with ful
|
|
|
548
548
|
"ports": "",
|
|
549
549
|
"ip": "172.19.0.5",
|
|
550
550
|
"created": "2026-07-01T16:58:42.876829416Z",
|
|
551
|
-
"env": ["AGENT_ID=prometheus", "
|
|
551
|
+
"env": ["AGENT_ID=prometheus", "AGENT_TEMPLATE=prometheus"],
|
|
552
552
|
"authToken": "***",
|
|
553
|
-
"
|
|
553
|
+
"controlPort": "3033"
|
|
554
554
|
}
|
|
555
555
|
]
|
|
556
556
|
|
|
@@ -589,7 +589,7 @@ Create a new agent container from a template.
|
|
|
589
589
|
**Every agent is created with a host port mapping:**
|
|
590
590
|
- Port block: `-p <start>-<end>:3000-<3000+offset>` — maps a range of host ports to the same range starting at container port 3000
|
|
591
591
|
- Single port: `-p <port>:3000` — maps a single host port to container port 3000
|
|
592
|
-
- URL
|
|
592
|
+
- Control UI URL: composed by the browser as `http://<window.location.hostname>:<port>#token=<controlToken>` — the server returns the parts, not the URL
|
|
593
593
|
- Port 3740 is reserved for Rev4a
|
|
594
594
|
|
|
595
595
|
**Response:**
|
|
@@ -600,7 +600,7 @@ Create a new agent container from a template.
|
|
|
600
600
|
"name": "my-agent",
|
|
601
601
|
"image": "openclaw-agent-base:latest",
|
|
602
602
|
"network": "rev4a-network",
|
|
603
|
-
"
|
|
603
|
+
"controlToken": "asdfghjkl",
|
|
604
604
|
"port": 3700,
|
|
605
605
|
"portEnd": 3709,
|
|
606
606
|
"isBlock": true,
|
package/lib/channelManager.ts
CHANGED
|
@@ -97,6 +97,28 @@ function findContainerById(agentId: string): string | null {
|
|
|
97
97
|
|
|
98
98
|
// ── Public API ───────────────────────────────
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Read the Telegram channel out of an already-parsed openclaw.json.
|
|
102
|
+
*
|
|
103
|
+
* Pure — no container access — so callers that have obtained the config some
|
|
104
|
+
* other way (in bulk, asynchronously) can share the same interpretation instead
|
|
105
|
+
* of reimplementing it.
|
|
106
|
+
*/
|
|
107
|
+
export function parseTelegramChannel(config: Record<string, unknown>): TelegramChannel | null {
|
|
108
|
+
const telegram = (config.channels as Record<string, unknown>)?.telegram as Record<string, unknown> | undefined;
|
|
109
|
+
if (!telegram || telegram.enabled === false) return null;
|
|
110
|
+
|
|
111
|
+
const botToken = (telegram.botToken ?? '') as string;
|
|
112
|
+
if (typeof botToken !== 'string' || botToken.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
connected: true,
|
|
116
|
+
botToken: maskToken(botToken),
|
|
117
|
+
dmPolicy: (telegram.dmPolicy ?? 'pairing') as string,
|
|
118
|
+
allowFrom: (Array.isArray(telegram.allowFrom) ? telegram.allowFrom : []) as string[],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
export function getChannels(agentId: string): AgentChannels {
|
|
101
123
|
const container = findContainerById(agentId);
|
|
102
124
|
const result: AgentChannels = { agentId, telegram: null };
|
|
@@ -106,22 +128,7 @@ export function getChannels(agentId: string): AgentChannels {
|
|
|
106
128
|
const config = readConfigJson(container);
|
|
107
129
|
if (!config) return result;
|
|
108
130
|
|
|
109
|
-
|
|
110
|
-
const telegram = (config.channels as Record<string, unknown>)?.telegram as Record<string, unknown> | undefined;
|
|
111
|
-
if (telegram && telegram.enabled !== false) {
|
|
112
|
-
const botToken = (telegram.botToken ?? '') as string;
|
|
113
|
-
const hasToken = typeof botToken === 'string' && botToken.length > 0;
|
|
114
|
-
|
|
115
|
-
if (hasToken) {
|
|
116
|
-
result.telegram = {
|
|
117
|
-
connected: true,
|
|
118
|
-
botToken: maskToken(botToken),
|
|
119
|
-
dmPolicy: (telegram.dmPolicy ?? 'pairing') as string,
|
|
120
|
-
allowFrom: (Array.isArray(telegram.allowFrom) ? telegram.allowFrom : []) as string[],
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
131
|
+
result.telegram = parseTelegramChannel(config);
|
|
125
132
|
return result;
|
|
126
133
|
}
|
|
127
134
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running commands inside agent containers, without blocking the server.
|
|
3
|
+
*
|
|
4
|
+
* Every route that needed something from a container used to call `execSync` or
|
|
5
|
+
* `execFileSync` directly. Those are synchronous: while one runs, Node's single
|
|
6
|
+
* thread does nothing else — no other request is served, no agent's stream
|
|
7
|
+
* advances. Node's own guidance names `execSync` in a server as a
|
|
8
|
+
* denial-of-service vector, and the numbers here bear that out: a bare
|
|
9
|
+
* `docker exec … echo` costs ~260ms, and the OpenClaw CLI inside a container
|
|
10
|
+
* takes 2.5–6s per invocation because it boots a whole Node process.
|
|
11
|
+
*
|
|
12
|
+
* Routes that walk every agent multiplied that: `/api/crons` measured 19.2s on a
|
|
13
|
+
* seven-agent host, all of it with the event loop frozen.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is async. The commands are still as slow as they were — that
|
|
16
|
+
* cost belongs to the OpenClaw CLI — but they no longer stop the rest of the
|
|
17
|
+
* server while they run.
|
|
18
|
+
*/
|
|
19
|
+
import { execFile, spawn } from 'child_process';
|
|
20
|
+
import { promisify } from 'util';
|
|
21
|
+
import { resolveDockerSocket } from '@/lib/docker-socket';
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
/** Commands are expected to answer quickly; the OpenClaw CLI needs the headroom. */
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
27
|
+
|
|
28
|
+
/** Matches what the previous callers passed, and covers `openclaw models list --json`. */
|
|
29
|
+
const DEFAULT_MAX_BUFFER = 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
export interface DockerExecOptions {
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
/** Extra environment for the command, passed as `-e KEY=VALUE`. */
|
|
34
|
+
env?: Record<string, string>;
|
|
35
|
+
maxBuffer?: number;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function baseArgs(container: string, opts?: DockerExecOptions): string[] {
|
|
40
|
+
const args = ['exec'];
|
|
41
|
+
for (const [k, v] of Object.entries(opts?.env ?? {})) args.push('-e', `${k}=${v}`);
|
|
42
|
+
args.push(container);
|
|
43
|
+
return args;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run a command inside a container, passing argv directly.
|
|
48
|
+
*
|
|
49
|
+
* Prefer this over {@link dockerExecShell}: with no shell in between there is
|
|
50
|
+
* nothing to quote and nothing to escape, so a filename with a space or a quote
|
|
51
|
+
* cannot turn into a second command.
|
|
52
|
+
*
|
|
53
|
+
* Rejects when the command fails, the container is missing, or the timeout hits.
|
|
54
|
+
*/
|
|
55
|
+
export async function dockerExec(
|
|
56
|
+
container: string,
|
|
57
|
+
argv: string[],
|
|
58
|
+
opts?: DockerExecOptions,
|
|
59
|
+
): Promise<string> {
|
|
60
|
+
if (!resolveDockerSocket()) throw new Error('Docker is not available');
|
|
61
|
+
|
|
62
|
+
const { stdout } = await execFileAsync(
|
|
63
|
+
'docker',
|
|
64
|
+
[...baseArgs(container, opts), ...argv],
|
|
65
|
+
{
|
|
66
|
+
encoding: 'utf-8',
|
|
67
|
+
timeout: opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
68
|
+
maxBuffer: opts?.maxBuffer ?? DEFAULT_MAX_BUFFER,
|
|
69
|
+
signal: opts?.signal,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
return stdout;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run a shell command inside a container. Only for commands that genuinely need
|
|
77
|
+
* a shell — pipes, redirection, globbing. Anything interpolated into `cmd` is
|
|
78
|
+
* shell syntax, so callers must quote it themselves.
|
|
79
|
+
*/
|
|
80
|
+
export async function dockerExecShell(
|
|
81
|
+
container: string,
|
|
82
|
+
cmd: string,
|
|
83
|
+
opts?: DockerExecOptions,
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
return dockerExec(container, ['sh', '-c', cmd], opts);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* As {@link dockerExec}, but resolves to '' instead of rejecting.
|
|
90
|
+
*
|
|
91
|
+
* This is the shape most of the existing callers want: a container that is
|
|
92
|
+
* stopped, or an OpenClaw subcommand that isn't available on that agent, is a
|
|
93
|
+
* normal condition here — not something to fail a whole page over.
|
|
94
|
+
*/
|
|
95
|
+
export async function dockerExecNoFail(
|
|
96
|
+
container: string,
|
|
97
|
+
argv: string[],
|
|
98
|
+
opts?: DockerExecOptions,
|
|
99
|
+
): Promise<string> {
|
|
100
|
+
try {
|
|
101
|
+
return await dockerExec(container, argv, opts);
|
|
102
|
+
} catch {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Shell variant of {@link dockerExecNoFail}. */
|
|
108
|
+
export async function dockerExecShellNoFail(
|
|
109
|
+
container: string,
|
|
110
|
+
cmd: string,
|
|
111
|
+
opts?: DockerExecOptions,
|
|
112
|
+
): Promise<string> {
|
|
113
|
+
return dockerExecNoFail(container, ['sh', '-c', cmd], opts);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Run a command inside a container and write `input` to its stdin.
|
|
118
|
+
*
|
|
119
|
+
* This uses `spawn` rather than `execFile` on purpose: `promisify(exec)` and
|
|
120
|
+
* `promisify(execFile)` silently ignore an `input` option — the process starts,
|
|
121
|
+
* stdin is never written, and the command hangs or reads nothing, with no error
|
|
122
|
+
* to point at. `openclaw config patch --stdin` is the case that needs this.
|
|
123
|
+
*/
|
|
124
|
+
export function dockerExecWithInput(
|
|
125
|
+
container: string,
|
|
126
|
+
argv: string[],
|
|
127
|
+
input: string,
|
|
128
|
+
opts?: DockerExecOptions,
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
if (!resolveDockerSocket()) return Promise.reject(new Error('Docker is not available'));
|
|
131
|
+
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn('docker', ['exec', '-i', ...baseArgs(container, opts).slice(1), ...argv], {
|
|
134
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
let stdout = '';
|
|
138
|
+
let stderr = '';
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
child.kill('SIGKILL');
|
|
141
|
+
reject(new Error(`docker exec timed out after ${opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`));
|
|
142
|
+
}, opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
143
|
+
|
|
144
|
+
child.stdout.on('data', (c) => { stdout += c; });
|
|
145
|
+
child.stderr.on('data', (c) => { stderr += c; });
|
|
146
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
147
|
+
child.on('close', (code) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
if (code === 0) resolve(stdout);
|
|
150
|
+
else reject(new Error(stderr.trim() || `docker exec exited with code ${code}`));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
child.stdin.on('error', () => { /* the close handler reports the real failure */ });
|
|
154
|
+
child.stdin.end(input);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Map over items with a cap on how many run at once.
|
|
160
|
+
*
|
|
161
|
+
* The point of making these calls async is to run an agent fleet's worth of them
|
|
162
|
+
* concurrently instead of one after another. Doing that with a bare
|
|
163
|
+
* `Promise.all` would spawn one `docker exec` per agent simultaneously, which is
|
|
164
|
+
* fine for seven agents and not fine for seventy — each one is a process, and
|
|
165
|
+
* the Docker daemon serialises past a point anyway. The cap keeps the win
|
|
166
|
+
* without trading a frozen event loop for a swamped daemon.
|
|
167
|
+
*
|
|
168
|
+
* Results keep the order of `items`, regardless of completion order.
|
|
169
|
+
*
|
|
170
|
+
* Fail-fast, like `Promise.all`: if `fn` rejects for one item the whole call
|
|
171
|
+
* rejects. That is deliberate — silently swallowing failures here would hide
|
|
172
|
+
* real errors — but it means callers fanning out over an agent fleet should pass
|
|
173
|
+
* a function that handles its own failures (the `…NoFail` variants above, or an
|
|
174
|
+
* explicit try/catch), so one unreachable container can't blank an entire page.
|
|
175
|
+
*/
|
|
176
|
+
export async function mapWithConcurrency<T, R>(
|
|
177
|
+
items: readonly T[],
|
|
178
|
+
limit: number,
|
|
179
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
180
|
+
): Promise<R[]> {
|
|
181
|
+
if (items.length === 0) return [];
|
|
182
|
+
const results = new Array<R>(items.length);
|
|
183
|
+
let next = 0;
|
|
184
|
+
|
|
185
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
186
|
+
while (true) {
|
|
187
|
+
const i = next++;
|
|
188
|
+
if (i >= items.length) return;
|
|
189
|
+
results[i] = await fn(items[i], i);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await Promise.all(workers);
|
|
194
|
+
return results;
|
|
195
|
+
}
|
package/lib/docker-utils.ts
CHANGED
|
@@ -1,54 +1,28 @@
|
|
|
1
|
-
import { networkInterfaces } from 'os';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
2
|
+
* Docker-related host helpers.
|
|
3
|
+
*
|
|
4
|
+
* This module used to export `detectPublicHost()`, which shelled out to
|
|
5
|
+
* `curl icanhazip.com` via execSync to discover the machine's public IP, then
|
|
6
|
+
* fell back to `hostname -I` and finally the literal string 'localhost'. It was
|
|
7
|
+
* removed rather than optimised, because the caching it seemed to need would
|
|
8
|
+
* only have made a wrong answer stable:
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* - execSync blocks the entire event loop, so every call froze the whole
|
|
11
|
+
* server — not just its own request — for ~270ms, and up to the 5s timeout
|
|
12
|
+
* when the network hung. Node's own guidance names execSync in a server as
|
|
13
|
+
* a denial-of-service vector.
|
|
14
|
+
* - On macOS it never even worked: icanhazip answers with IPv6, which the
|
|
15
|
+
* IPv4-only validation discarded, and `hostname -I` is a GNU-only flag. The
|
|
16
|
+
* value that came back was the machine's `.local` mDNS name, reachable only
|
|
17
|
+
* from the same LAN.
|
|
18
|
+
* - It answered the wrong question. Callers needed "which URL should this
|
|
19
|
+
* user's browser open", not "what is this machine's WAN address". Those
|
|
20
|
+
* differ behind NAT, behind a reverse proxy, and over a tunnel.
|
|
21
|
+
*
|
|
22
|
+
* Agent URLs are now composed in the browser from the port returned by the API
|
|
23
|
+
* plus `window.location.hostname` — a hostname already proven to reach this
|
|
24
|
+
* server, since the dashboard is being served over it.
|
|
11
25
|
*/
|
|
12
|
-
export function detectPublicHost(): string {
|
|
13
|
-
// 1. External detection (works from inside Docker containers)
|
|
14
|
-
try {
|
|
15
|
-
const { execSync } = require('child_process');
|
|
16
|
-
const ip = execSync(
|
|
17
|
-
'curl -fs --max-time 3 icanhazip.com 2>/dev/null || curl -fs --max-time 3 ifconfig.me 2>/dev/null',
|
|
18
|
-
{ encoding: 'utf-8', timeout: 5000 },
|
|
19
|
-
).trim();
|
|
20
|
-
if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !ip.startsWith('172.')) {
|
|
21
|
-
return ip;
|
|
22
|
-
}
|
|
23
|
-
} catch { /* ignore */ }
|
|
24
|
-
|
|
25
|
-
// 2. Hostname (often gives public IP on VPS, but Docker internal 172.x is filtered)
|
|
26
|
-
try {
|
|
27
|
-
const { execSync } = require('child_process');
|
|
28
|
-
const hostname = execSync('hostname -I 2>/dev/null || hostname 2>/dev/null', {
|
|
29
|
-
encoding: 'utf-8', timeout: 2000,
|
|
30
|
-
}).trim().split(/\s+/)[0];
|
|
31
|
-
if (hostname && hostname !== '127.0.0.1' && hostname !== '::1' && !hostname.startsWith('172.')) {
|
|
32
|
-
return hostname;
|
|
33
|
-
}
|
|
34
|
-
} catch { /* ignore */ }
|
|
35
|
-
|
|
36
|
-
// 3. Network interfaces (bare metal)
|
|
37
|
-
try {
|
|
38
|
-
const ifaces = networkInterfaces();
|
|
39
|
-
for (const [, addrs] of Object.entries(ifaces)) {
|
|
40
|
-
if (!addrs) continue;
|
|
41
|
-
for (const addr of addrs) {
|
|
42
|
-
if (addr.family === 'IPv4' && !addr.internal) {
|
|
43
|
-
return addr.address;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
} catch { /* fall through */ }
|
|
48
|
-
|
|
49
|
-
// 4. Fallback
|
|
50
|
-
return 'localhost';
|
|
51
|
-
}
|
|
52
26
|
|
|
53
27
|
/**
|
|
54
28
|
* Build the provider gateway base URL used to sync model config into agent
|