@swarmclawai/swarmclaw 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/bin/doctor-cmd.js +7 -1
- package/bin/server-cmd.js +204 -40
- package/bin/swarmclaw.js +9 -6
- package/bin/worker-cmd.js +5 -4
- package/package.json +8 -8
- package/scripts/postinstall.mjs +17 -13
- package/src/app/usage/page.tsx +22 -12
- package/src/cli/server-cmd.test.js +130 -0
- package/src/lib/server/data-dir.test.ts +6 -6
package/README.md
CHANGED
|
@@ -136,6 +136,17 @@ Running `swarmclaw` with no arguments starts the server on `http://localhost:345
|
|
|
136
136
|
Global install runs `postinstall`, which rebuilds `better-sqlite3` and prepares the sandbox browser image when Docker is available.
|
|
137
137
|
If Docker is not installed yet, SwarmClaw keeps running and falls back to host execution for shell, browser, and `sandbox_exec`.
|
|
138
138
|
No Deno install is required for the local `sandbox_exec` path.
|
|
139
|
+
Runtime state defaults to `~/.swarmclaw` unless you set `SWARMCLAW_HOME`.
|
|
140
|
+
If the standalone server bundle is missing, the first launch builds it under `<swarmclaw-home>/builds/package-<version>` before starting.
|
|
141
|
+
|
|
142
|
+
### Local project install
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm i @swarmclawai/swarmclaw
|
|
146
|
+
npx swarmclaw
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Local installs keep runtime state in `<project>/.swarmclaw` by default. The same project-local install works with `pnpm`, `yarn`, or `bun`; use that package manager's exec command to launch the local binary, or run `./node_modules/.bin/swarmclaw` directly.
|
|
139
150
|
|
|
140
151
|
### One-off run
|
|
141
152
|
|
|
@@ -146,6 +157,8 @@ yarn dlx @swarmclawai/swarmclaw
|
|
|
146
157
|
bunx @swarmclawai/swarmclaw
|
|
147
158
|
```
|
|
148
159
|
|
|
160
|
+
One-off runs use the published package without keeping a project-local install. Runtime state defaults to `~/.swarmclaw` unless you set `SWARMCLAW_HOME`.
|
|
161
|
+
|
|
149
162
|
### Install script
|
|
150
163
|
|
|
151
164
|
```bash
|
|
@@ -155,7 +168,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
|
|
|
155
168
|
The installer resolves the latest stable release tag and installs that version by default.
|
|
156
169
|
It also builds the production bundle so `npm run start` is ready immediately after install.
|
|
157
170
|
No Deno install is required; local sandbox execution is Docker-first with automatic host Node fallback.
|
|
158
|
-
To pin a version: `SWARMCLAW_VERSION=
|
|
171
|
+
To pin a version: `SWARMCLAW_VERSION=v1.0.3 curl ... | bash`
|
|
159
172
|
|
|
160
173
|
Or run locally from the repo (friendly for non-technical users):
|
|
161
174
|
|
|
@@ -603,7 +616,7 @@ A fuller step-by-step walkthrough lives at https://swarmclaw.ai/docs/plugin-tuto
|
|
|
603
616
|
|
|
604
617
|
### Browser, Watch, and Delegation Upgrades
|
|
605
618
|
|
|
606
|
-
- **Persistent Browser Profiles**: The built-in `browser` plugin now keeps a reusable profile per chat/session, and subagents inherit the parent profile by default. Profiles live under `~/.swarmclaw/browser-profiles` unless you override `BROWSER_PROFILES_DIR`, so cookies, storage, and authenticated state survive longer-running work without polluting the project tree. Browser state is exposed at `GET /api/chats/[id]/browser`.
|
|
619
|
+
- **Persistent Browser Profiles**: The built-in `browser` plugin now keeps a reusable profile per chat/session, and subagents inherit the parent profile by default. Profiles live under `<swarmclaw-home>/browser-profiles` by default (`~/.swarmclaw/browser-profiles` for global installs, `<project>/.swarmclaw/browser-profiles` for local installs) unless you override `BROWSER_PROFILES_DIR`, so cookies, storage, and authenticated state survive longer-running work without polluting the project tree. Browser state is exposed at `GET /api/chats/[id]/browser`.
|
|
607
620
|
- **Higher-Level Browser Actions**: In addition to raw Playwright-style actions, `browser` supports workflow-oriented actions such as `read_page`, `extract_links`, `extract_form_fields`, `extract_table`, `fill_form`, `submit_form`, `scroll_until`, `download_file`, `complete_web_task`, `verify_text`, `verify_element`, `verify_list`, `verify_value`, `profile`, and `reset_profile`.
|
|
608
621
|
- **Structured Browser State**: Browser sessions persist recent observations, tabs, artifacts (screenshots / PDFs / downloads), current URL, and last errors in `browser_sessions`, which makes autonomous browser tasks easier to resume, inspect, and hand off across turns.
|
|
609
622
|
- **Durable Watches**: `schedule_wake` now uses persisted watch jobs instead of an in-memory timer, and `monitor_tool` supports `create_watch`, `list_watches`, `get_watch`, and `cancel_watch` across `time`, `http`, `file`, `task`, `webhook`, and `page` conditions. The same watch system also powers the new `mailbox`, session-mailbox, and approval waits used by human-loop flows. Watches support common checks like status/status sets, regex or text matches, content changes, existence checks, inbound mailbox correlation IDs, and webhook event filters.
|
package/bin/doctor-cmd.js
CHANGED
|
@@ -11,6 +11,8 @@ const {
|
|
|
11
11
|
SWARMCLAW_HOME,
|
|
12
12
|
findStandaloneServer,
|
|
13
13
|
isGitCheckout,
|
|
14
|
+
resolvePackageBuildRoot,
|
|
15
|
+
resolveInstalledNext,
|
|
14
16
|
} = require('./server-cmd.js')
|
|
15
17
|
|
|
16
18
|
function readPid(pidFile) {
|
|
@@ -38,8 +40,10 @@ function buildDoctorReport(opts = {}) {
|
|
|
38
40
|
const dataDir = path.join(homeDir, 'data')
|
|
39
41
|
const workspaceDir = path.join(homeDir, 'workspace')
|
|
40
42
|
const browserProfilesDir = path.join(homeDir, 'browser-profiles')
|
|
41
|
-
const
|
|
43
|
+
const nextInstall = resolveInstalledNext(pkgRoot)
|
|
44
|
+
const nextCliPath = nextInstall?.nextCli || path.join(pkgRoot, 'node_modules', 'next', 'dist', 'bin', 'next')
|
|
42
45
|
const standaloneServer = findStandaloneServer({ pkgRoot })
|
|
46
|
+
const buildRoot = resolvePackageBuildRoot(pkgRoot)
|
|
43
47
|
const pid = readPid(pidFile)
|
|
44
48
|
const running = pid ? isProcessRunning(pid) : false
|
|
45
49
|
|
|
@@ -64,6 +68,7 @@ function buildDoctorReport(opts = {}) {
|
|
|
64
68
|
return {
|
|
65
69
|
packageVersion: readPackageVersion(pkgRoot) || 'unknown',
|
|
66
70
|
packageRoot: pkgRoot,
|
|
71
|
+
buildRoot,
|
|
67
72
|
installKind: isGitCheckout(pkgRoot) ? 'git' : 'package',
|
|
68
73
|
homeDir,
|
|
69
74
|
dataDir,
|
|
@@ -96,6 +101,7 @@ function printHumanReport(report) {
|
|
|
96
101
|
`Package version: ${report.packageVersion}`,
|
|
97
102
|
`Install kind: ${report.installKind}`,
|
|
98
103
|
`Package root: ${report.packageRoot}`,
|
|
104
|
+
`Build root: ${report.buildRoot}`,
|
|
99
105
|
`Home: ${report.homeDir}`,
|
|
100
106
|
`Data: ${report.dataDir}`,
|
|
101
107
|
`Workspace: ${report.workspaceDir}`,
|
package/bin/server-cmd.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
4
4
|
|
|
5
5
|
const fs = require('node:fs')
|
|
6
|
+
const http = require('node:http')
|
|
6
7
|
const path = require('node:path')
|
|
7
8
|
const { spawn, execFileSync } = require('node:child_process')
|
|
8
9
|
const {
|
|
@@ -36,6 +37,7 @@ const LOG_FILE = path.join(SWARMCLAW_HOME, 'server.log')
|
|
|
36
37
|
const DATA_DIR = path.join(SWARMCLAW_HOME, 'data')
|
|
37
38
|
const WORKSPACE_DIR = path.join(SWARMCLAW_HOME, 'workspace')
|
|
38
39
|
const BROWSER_PROFILES_DIR = path.join(SWARMCLAW_HOME, 'browser-profiles')
|
|
40
|
+
const BUILD_WORKSPACES_DIR = path.join(SWARMCLAW_HOME, 'builds')
|
|
39
41
|
|
|
40
42
|
// ---------------------------------------------------------------------------
|
|
41
43
|
// Helpers
|
|
@@ -71,6 +73,62 @@ function isProcessRunning(pid) {
|
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
function resolveReadyCheckHost(host) {
|
|
77
|
+
if (host === '0.0.0.0') return '127.0.0.1'
|
|
78
|
+
if (host === '::') return '::1'
|
|
79
|
+
return host
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function probeHttpReady(host, port, timeoutMs = 1_000) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const req = http.request(
|
|
85
|
+
{
|
|
86
|
+
host,
|
|
87
|
+
port: Number(port),
|
|
88
|
+
path: '/api/auth',
|
|
89
|
+
method: 'GET',
|
|
90
|
+
timeout: timeoutMs,
|
|
91
|
+
},
|
|
92
|
+
(res) => {
|
|
93
|
+
res.resume()
|
|
94
|
+
resolve(res.statusCode >= 200 && res.statusCode < 500)
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
req.once('timeout', () => {
|
|
99
|
+
req.destroy()
|
|
100
|
+
resolve(false)
|
|
101
|
+
})
|
|
102
|
+
req.once('error', () => resolve(false))
|
|
103
|
+
req.end()
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function waitForPortReady({
|
|
108
|
+
host,
|
|
109
|
+
port,
|
|
110
|
+
timeoutMs = 30_000,
|
|
111
|
+
intervalMs = 250,
|
|
112
|
+
pid = null,
|
|
113
|
+
isProcessRunningFn = isProcessRunning,
|
|
114
|
+
probeFn = probeHttpReady,
|
|
115
|
+
} = {}) {
|
|
116
|
+
const readyHost = resolveReadyCheckHost(host)
|
|
117
|
+
const deadline = Date.now() + timeoutMs
|
|
118
|
+
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
if (pid && !isProcessRunningFn(pid)) {
|
|
121
|
+
throw new Error(`Detached server process ${pid} exited before becoming ready.`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (await probeFn(readyHost, port)) return
|
|
125
|
+
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw new Error(`Timed out waiting for ${readyHost}:${port} to become ready.`)
|
|
130
|
+
}
|
|
131
|
+
|
|
74
132
|
function resolveStandaloneBase(pkgRoot = PKG_ROOT) {
|
|
75
133
|
return path.join(pkgRoot, '.next', 'standalone')
|
|
76
134
|
}
|
|
@@ -83,15 +141,112 @@ function getVersion() {
|
|
|
83
141
|
return readPackageVersion(PKG_ROOT) || 'unknown'
|
|
84
142
|
}
|
|
85
143
|
|
|
144
|
+
function resolveInstalledNext(pkgRoot = PKG_ROOT) {
|
|
145
|
+
try {
|
|
146
|
+
const nextPackageJson = require.resolve('next/package.json', { paths: [pkgRoot] })
|
|
147
|
+
const nextPackageDir = path.dirname(nextPackageJson)
|
|
148
|
+
return {
|
|
149
|
+
nextCli: path.join(nextPackageDir, 'dist', 'bin', 'next'),
|
|
150
|
+
nodeModulesDir: path.dirname(nextPackageDir),
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
86
157
|
function ensurePackageDependencies(pkgRoot = PKG_ROOT) {
|
|
87
|
-
const
|
|
88
|
-
if (fs.existsSync(nextCli)) return
|
|
158
|
+
const resolved = resolveInstalledNext(pkgRoot)
|
|
159
|
+
if (resolved && fs.existsSync(resolved.nextCli)) return resolved
|
|
89
160
|
|
|
90
161
|
const packageManager = detectPackageManager(pkgRoot, process.env)
|
|
91
162
|
const install = getInstallCommand(packageManager)
|
|
92
163
|
log(`Installing dependencies with ${packageManager}...`)
|
|
93
164
|
execFileSync(install.command, install.args, { cwd: pkgRoot, stdio: 'inherit' })
|
|
94
|
-
|
|
165
|
+
|
|
166
|
+
const installed = resolveInstalledNext(pkgRoot)
|
|
167
|
+
if (installed && fs.existsSync(installed.nextCli)) return installed
|
|
168
|
+
|
|
169
|
+
throw new Error('Next.js CLI was not found after installing dependencies.')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolvePackageBuildRoot(pkgRoot = PKG_ROOT) {
|
|
173
|
+
if (isGitCheckout(pkgRoot)) return pkgRoot
|
|
174
|
+
const version = readPackageVersion(pkgRoot) || 'unknown'
|
|
175
|
+
return path.join(BUILD_WORKSPACES_DIR, `package-${version}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function copyBuildWorkspaceContents(sourceRoot, targetRoot) {
|
|
179
|
+
const excluded = new Set([
|
|
180
|
+
'.git',
|
|
181
|
+
'.next',
|
|
182
|
+
'data',
|
|
183
|
+
'node_modules',
|
|
184
|
+
])
|
|
185
|
+
|
|
186
|
+
ensureDir(targetRoot)
|
|
187
|
+
|
|
188
|
+
for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
|
|
189
|
+
if (excluded.has(entry.name)) continue
|
|
190
|
+
|
|
191
|
+
const sourcePath = path.join(sourceRoot, entry.name)
|
|
192
|
+
const targetPath = path.join(targetRoot, entry.name)
|
|
193
|
+
fs.rmSync(targetPath, { recursive: true, force: true })
|
|
194
|
+
fs.cpSync(sourcePath, targetPath, {
|
|
195
|
+
recursive: true,
|
|
196
|
+
force: true,
|
|
197
|
+
dereference: true,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function symlinkDir(targetPath, linkPath) {
|
|
203
|
+
fs.rmSync(linkPath, { recursive: true, force: true })
|
|
204
|
+
fs.symlinkSync(targetPath, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function prepareBuildWorkspace({ pkgRoot = PKG_ROOT, buildRoot = resolvePackageBuildRoot(pkgRoot), nodeModulesDir } = {}) {
|
|
208
|
+
copyBuildWorkspaceContents(pkgRoot, buildRoot)
|
|
209
|
+
symlinkDir(nodeModulesDir, path.join(buildRoot, 'node_modules'))
|
|
210
|
+
return buildRoot
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function resolveStandaloneCandidateRoots(pkgRoot = PKG_ROOT) {
|
|
214
|
+
const roots = [pkgRoot]
|
|
215
|
+
const buildRoot = resolvePackageBuildRoot(pkgRoot)
|
|
216
|
+
if (buildRoot !== pkgRoot) roots.push(buildRoot)
|
|
217
|
+
return roots
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function locateStandaloneServer({ pkgRoot = PKG_ROOT } = {}) {
|
|
221
|
+
for (const root of resolveStandaloneCandidateRoots(pkgRoot)) {
|
|
222
|
+
const standaloneBase = resolveStandaloneBase(root)
|
|
223
|
+
if (!fs.existsSync(standaloneBase)) continue
|
|
224
|
+
|
|
225
|
+
const direct = path.join(standaloneBase, 'server.js')
|
|
226
|
+
if (fs.existsSync(direct)) {
|
|
227
|
+
return { root, serverJs: direct }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function search(dir) {
|
|
231
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
const full = path.join(dir, entry.name)
|
|
234
|
+
if (entry.isFile() && entry.name === 'server.js') return full
|
|
235
|
+
if (entry.isDirectory() && entry.name !== 'node_modules') {
|
|
236
|
+
const found = search(full)
|
|
237
|
+
if (found) return found
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const nested = search(standaloneBase)
|
|
244
|
+
if (nested) {
|
|
245
|
+
return { root, serverJs: nested }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return null
|
|
95
250
|
}
|
|
96
251
|
|
|
97
252
|
// ---------------------------------------------------------------------------
|
|
@@ -108,11 +263,17 @@ function runBuild({ pkgRoot = PKG_ROOT } = {}) {
|
|
|
108
263
|
ensureDir(SWARMCLAW_HOME)
|
|
109
264
|
ensureDir(DATA_DIR)
|
|
110
265
|
|
|
111
|
-
const nextCli = ensurePackageDependencies(pkgRoot)
|
|
266
|
+
const { nextCli, nodeModulesDir } = ensurePackageDependencies(pkgRoot)
|
|
267
|
+
const buildRoot = resolvePackageBuildRoot(pkgRoot)
|
|
268
|
+
|
|
269
|
+
if (buildRoot !== pkgRoot) {
|
|
270
|
+
prepareBuildWorkspace({ pkgRoot, buildRoot, nodeModulesDir })
|
|
271
|
+
log(`Using build workspace: ${buildRoot}`)
|
|
272
|
+
}
|
|
112
273
|
|
|
113
274
|
log('Building Next.js application (this may take a minute)...')
|
|
114
275
|
execFileSync(process.execPath, [nextCli, 'build', '--webpack'], {
|
|
115
|
-
cwd:
|
|
276
|
+
cwd: buildRoot,
|
|
116
277
|
stdio: 'inherit',
|
|
117
278
|
env: {
|
|
118
279
|
...process.env,
|
|
@@ -130,41 +291,20 @@ function runBuild({ pkgRoot = PKG_ROOT } = {}) {
|
|
|
130
291
|
// ---------------------------------------------------------------------------
|
|
131
292
|
|
|
132
293
|
function findStandaloneServer({ pkgRoot = PKG_ROOT } = {}) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!fs.existsSync(standaloneBase)) {
|
|
136
|
-
return null
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const direct = path.join(standaloneBase, 'server.js')
|
|
140
|
-
if (fs.existsSync(direct)) return direct
|
|
141
|
-
|
|
142
|
-
function search(dir) {
|
|
143
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
144
|
-
for (const entry of entries) {
|
|
145
|
-
const full = path.join(dir, entry.name)
|
|
146
|
-
if (entry.isFile() && entry.name === 'server.js') return full
|
|
147
|
-
if (entry.isDirectory() && entry.name !== 'node_modules') {
|
|
148
|
-
const found = search(full)
|
|
149
|
-
if (found) return found
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return null
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return search(standaloneBase)
|
|
294
|
+
return locateStandaloneServer({ pkgRoot })?.serverJs || null
|
|
156
295
|
}
|
|
157
296
|
|
|
158
297
|
// ---------------------------------------------------------------------------
|
|
159
298
|
// Start server
|
|
160
299
|
// ---------------------------------------------------------------------------
|
|
161
300
|
|
|
162
|
-
function startServer(opts, { pkgRoot = PKG_ROOT } = {}) {
|
|
163
|
-
const
|
|
164
|
-
if (!
|
|
301
|
+
async function startServer(opts, { pkgRoot = PKG_ROOT } = {}) {
|
|
302
|
+
const standalone = locateStandaloneServer({ pkgRoot })
|
|
303
|
+
if (!standalone) {
|
|
165
304
|
logError('Standalone server.js not found in the installed package. Try running: swarmclaw server --build')
|
|
166
305
|
process.exit(1)
|
|
167
306
|
}
|
|
307
|
+
const { root: runtimeRoot, serverJs } = standalone
|
|
168
308
|
|
|
169
309
|
ensureDir(SWARMCLAW_HOME)
|
|
170
310
|
ensureDir(DATA_DIR)
|
|
@@ -186,26 +326,38 @@ function startServer(opts, { pkgRoot = PKG_ROOT } = {}) {
|
|
|
186
326
|
|
|
187
327
|
log(`Starting server on ${host}:${port} (WebSocket: ${wsPort})...`)
|
|
188
328
|
log(`Package root: ${pkgRoot}`)
|
|
329
|
+
log(`Runtime root: ${runtimeRoot}`)
|
|
189
330
|
log(`Home: ${SWARMCLAW_HOME}`)
|
|
190
331
|
log(`Data directory: ${DATA_DIR}`)
|
|
191
332
|
|
|
192
333
|
if (opts.detach) {
|
|
193
334
|
const logStream = fs.openSync(LOG_FILE, 'a')
|
|
194
335
|
const child = spawn(process.execPath, [serverJs], {
|
|
195
|
-
cwd:
|
|
336
|
+
cwd: runtimeRoot,
|
|
196
337
|
detached: true,
|
|
197
338
|
env,
|
|
198
339
|
stdio: ['ignore', logStream, logStream],
|
|
199
340
|
})
|
|
200
341
|
|
|
201
|
-
child.unref()
|
|
202
342
|
fs.writeFileSync(PID_FILE, String(child.pid))
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
343
|
+
try {
|
|
344
|
+
await waitForPortReady({ host, port, pid: child.pid })
|
|
345
|
+
child.unref()
|
|
346
|
+
log(`Server started in background (PID: ${child.pid})`)
|
|
347
|
+
log(`Logs: ${LOG_FILE}`)
|
|
348
|
+
process.exit(0)
|
|
349
|
+
} catch (err) {
|
|
350
|
+
try {
|
|
351
|
+
if (isProcessRunning(child.pid)) process.kill(child.pid, 'SIGTERM')
|
|
352
|
+
} catch {}
|
|
353
|
+
try { fs.unlinkSync(PID_FILE) } catch {}
|
|
354
|
+
logError(`Detached start failed: ${err.message}`)
|
|
355
|
+
logError(`Check logs: ${LOG_FILE}`)
|
|
356
|
+
process.exit(1)
|
|
357
|
+
}
|
|
206
358
|
} else {
|
|
207
359
|
const child = spawn(process.execPath, [serverJs], {
|
|
208
|
-
cwd:
|
|
360
|
+
cwd: runtimeRoot,
|
|
209
361
|
env,
|
|
210
362
|
stdio: 'inherit',
|
|
211
363
|
})
|
|
@@ -263,6 +415,7 @@ function showStatus() {
|
|
|
263
415
|
}
|
|
264
416
|
|
|
265
417
|
log(`Package: ${PKG_ROOT}`)
|
|
418
|
+
log(`Build workspace: ${resolvePackageBuildRoot()}`)
|
|
266
419
|
log(`Home: ${SWARMCLAW_HOME}`)
|
|
267
420
|
log(`Data: ${DATA_DIR}`)
|
|
268
421
|
log(`Workspace: ${WORKSPACE_DIR}`)
|
|
@@ -301,7 +454,7 @@ Options:
|
|
|
301
454
|
console.log(help)
|
|
302
455
|
}
|
|
303
456
|
|
|
304
|
-
function main(args = process.argv.slice(3)) {
|
|
457
|
+
async function main(args = process.argv.slice(3)) {
|
|
305
458
|
let command = 'start'
|
|
306
459
|
let forceBuild = false
|
|
307
460
|
let detach = false
|
|
@@ -361,15 +514,19 @@ function main(args = process.argv.slice(3)) {
|
|
|
361
514
|
}
|
|
362
515
|
}
|
|
363
516
|
|
|
364
|
-
startServer({ port, wsPort, host, detach })
|
|
517
|
+
await startServer({ port, wsPort, host, detach })
|
|
365
518
|
}
|
|
366
519
|
|
|
367
520
|
if (require.main === module) {
|
|
368
|
-
main()
|
|
521
|
+
void main().catch((err) => {
|
|
522
|
+
logError(err?.message || String(err))
|
|
523
|
+
process.exit(1)
|
|
524
|
+
})
|
|
369
525
|
}
|
|
370
526
|
|
|
371
527
|
module.exports = {
|
|
372
528
|
DATA_DIR,
|
|
529
|
+
BUILD_WORKSPACES_DIR,
|
|
373
530
|
BROWSER_PROFILES_DIR,
|
|
374
531
|
PKG_ROOT,
|
|
375
532
|
SWARMCLAW_HOME,
|
|
@@ -377,8 +534,15 @@ module.exports = {
|
|
|
377
534
|
findStandaloneServer,
|
|
378
535
|
getVersion,
|
|
379
536
|
isGitCheckout,
|
|
537
|
+
locateStandaloneServer,
|
|
380
538
|
main,
|
|
381
539
|
needsBuild,
|
|
540
|
+
prepareBuildWorkspace,
|
|
541
|
+
resolveInstalledNext,
|
|
542
|
+
resolvePackageBuildRoot,
|
|
543
|
+
resolveReadyCheckHost,
|
|
544
|
+
resolveStandaloneCandidateRoots,
|
|
382
545
|
resolveStandaloneBase,
|
|
383
546
|
runBuild,
|
|
547
|
+
waitForPortReady,
|
|
384
548
|
}
|
package/bin/swarmclaw.js
CHANGED
|
@@ -128,7 +128,7 @@ async function runHelp(argv) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
if (target === 'run' || target === 'start' || target === 'stop' || target === 'status' || target === 'server') {
|
|
131
|
-
require('./server-cmd.js').main(['--help'])
|
|
131
|
+
await require('./server-cmd.js').main(['--help'])
|
|
132
132
|
return
|
|
133
133
|
}
|
|
134
134
|
if (target === 'worker') {
|
|
@@ -164,7 +164,7 @@ async function main() {
|
|
|
164
164
|
|
|
165
165
|
// Default to 'server' when invoked with no arguments.
|
|
166
166
|
if (!top) {
|
|
167
|
-
require('./server-cmd.js').main([])
|
|
167
|
+
await require('./server-cmd.js').main([])
|
|
168
168
|
return
|
|
169
169
|
}
|
|
170
170
|
|
|
@@ -185,15 +185,15 @@ async function main() {
|
|
|
185
185
|
|
|
186
186
|
// Route local lifecycle/maintenance commands to CJS scripts (no TS dependency).
|
|
187
187
|
if (top === 'server') {
|
|
188
|
-
require('./server-cmd.js').main(argv.slice(1))
|
|
188
|
+
await require('./server-cmd.js').main(argv.slice(1))
|
|
189
189
|
return
|
|
190
190
|
}
|
|
191
191
|
if (top === 'run' || top === 'start') {
|
|
192
|
-
require('./server-cmd.js').main(argv.slice(1))
|
|
192
|
+
await require('./server-cmd.js').main(argv.slice(1))
|
|
193
193
|
return
|
|
194
194
|
}
|
|
195
195
|
if (top === 'status' || top === 'stop') {
|
|
196
|
-
require('./server-cmd.js').main([top, ...argv.slice(1)])
|
|
196
|
+
await require('./server-cmd.js').main([top, ...argv.slice(1)])
|
|
197
197
|
return
|
|
198
198
|
}
|
|
199
199
|
if (top === 'worker') {
|
|
@@ -217,7 +217,10 @@ async function main() {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
if (require.main === module) {
|
|
220
|
-
void main()
|
|
220
|
+
void main().catch((err) => {
|
|
221
|
+
process.stderr.write(`${err?.message || String(err)}\n`)
|
|
222
|
+
process.exit(1)
|
|
223
|
+
})
|
|
221
224
|
}
|
|
222
225
|
|
|
223
226
|
module.exports = {
|
package/bin/worker-cmd.js
CHANGED
|
@@ -9,7 +9,7 @@ const {
|
|
|
9
9
|
PKG_ROOT,
|
|
10
10
|
SWARMCLAW_HOME,
|
|
11
11
|
WORKSPACE_DIR,
|
|
12
|
-
|
|
12
|
+
locateStandaloneServer,
|
|
13
13
|
} = require('./server-cmd.js')
|
|
14
14
|
|
|
15
15
|
function printHelp() {
|
|
@@ -51,14 +51,15 @@ function main(args = process.argv.slice(3)) {
|
|
|
51
51
|
console.log(`[swarmclaw] Workspace directory: ${WORKSPACE_DIR}`)
|
|
52
52
|
console.log(`[swarmclaw] Browser profiles: ${BROWSER_PROFILES_DIR}`)
|
|
53
53
|
|
|
54
|
-
const
|
|
55
|
-
if (!
|
|
54
|
+
const standalone = locateStandaloneServer()
|
|
55
|
+
if (!standalone) {
|
|
56
56
|
console.error('[swarmclaw] Standalone server.js not found in the installed package. Try running: swarmclaw server --build')
|
|
57
57
|
process.exit(1)
|
|
58
58
|
}
|
|
59
|
+
const { root: runtimeRoot, serverJs } = standalone
|
|
59
60
|
|
|
60
61
|
const child = spawn(process.execPath, [serverJs], {
|
|
61
|
-
cwd:
|
|
62
|
+
cwd: runtimeRoot,
|
|
62
63
|
env: process.env,
|
|
63
64
|
stdio: 'inherit',
|
|
64
65
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard with OpenClaw integration, multi-provider support, LangGraph workflows, and chat platform connectors.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"lint:baseline": "node ./scripts/lint-baseline.mjs check",
|
|
66
66
|
"lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
|
|
67
67
|
"cli": "node ./bin/swarmclaw.js",
|
|
68
|
-
"test:cli": "node --test src/cli/*.test.js bin/*.test.js",
|
|
68
|
+
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs",
|
|
69
69
|
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts",
|
|
70
70
|
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
|
|
71
71
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
@@ -122,12 +122,11 @@
|
|
|
122
122
|
"sonner": "^2.0.7",
|
|
123
123
|
"tailwindcss": "^4",
|
|
124
124
|
"tailwind-merge": "^3.4.1",
|
|
125
|
+
"typescript": "^5",
|
|
125
126
|
"tw-animate-css": "^1.4.0",
|
|
126
127
|
"ws": "^8.19.0",
|
|
127
128
|
"zod": "^4.3.6",
|
|
128
|
-
"zustand": "^5.0.11"
|
|
129
|
-
},
|
|
130
|
-
"devDependencies": {
|
|
129
|
+
"zustand": "^5.0.11",
|
|
131
130
|
"@types/better-sqlite3": "^7.6.13",
|
|
132
131
|
"@types/mailparser": "^3.4.6",
|
|
133
132
|
"@types/node": "^20",
|
|
@@ -135,11 +134,12 @@
|
|
|
135
134
|
"@types/qrcode": "^1.5.6",
|
|
136
135
|
"@types/react": "^19",
|
|
137
136
|
"@types/react-dom": "^19",
|
|
138
|
-
"@types/ws": "^8.18.1"
|
|
137
|
+
"@types/ws": "^8.18.1"
|
|
138
|
+
},
|
|
139
|
+
"devDependencies": {
|
|
139
140
|
"eslint": "^9",
|
|
140
141
|
"eslint-config-next": "16.1.6",
|
|
141
|
-
"tsx": "^4.20.6"
|
|
142
|
-
"typescript": "^5"
|
|
142
|
+
"tsx": "^4.20.6"
|
|
143
143
|
},
|
|
144
144
|
"optionalDependencies": {
|
|
145
145
|
"botbuilder": "^4.23.3",
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import fs from 'node:fs'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import { fileURLToPath } from 'node:url'
|
|
5
|
-
import { writeFileSync } from 'node:fs'
|
|
6
6
|
import { spawnSync } from 'node:child_process'
|
|
7
7
|
const INSTALL_METADATA_FILE = '.swarmclaw-install.json'
|
|
8
8
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -48,7 +48,7 @@ function formatFailure(result) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
try {
|
|
51
|
-
writeFileSync(
|
|
51
|
+
fs.writeFileSync(
|
|
52
52
|
new URL(`../${INSTALL_METADATA_FILE}`, import.meta.url),
|
|
53
53
|
JSON.stringify({
|
|
54
54
|
packageManager: installedWith,
|
|
@@ -72,18 +72,22 @@ if (result.error || (result.status ?? 0) !== 0) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
if (!process.env.CI) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
if (!fs.existsSync(ensureSandboxBrowserScript)) {
|
|
76
|
+
logNote('Sandbox browser image helper is not present in this install context. Skipping setup.')
|
|
77
|
+
} else {
|
|
78
|
+
const sandboxImage = spawnSync(process.execPath, [ensureSandboxBrowserScript, '--quiet'], {
|
|
79
|
+
cwd: packageRoot,
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
stdio: 'pipe',
|
|
82
|
+
})
|
|
83
|
+
if (sandboxImage.error || (sandboxImage.status ?? 0) !== 0) {
|
|
84
|
+
logWarn(`sandbox browser image setup failed: ${formatFailure(sandboxImage)}`)
|
|
85
|
+
logWarn('Retry manually with: node ./scripts/ensure-sandbox-browser-image.mjs')
|
|
86
|
+
}
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
if (!commandExists('docker')) {
|
|
89
|
+
logNote('Docker was not found. Container sandboxes will fall back to host execution until Docker is installed.')
|
|
90
|
+
}
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
|
package/src/app/usage/page.tsx
CHANGED
|
@@ -61,6 +61,16 @@ const CHART_COLORS = [
|
|
|
61
61
|
'#60A5FA', '#4ADE80',
|
|
62
62
|
]
|
|
63
63
|
|
|
64
|
+
function numericChartValue(value: unknown): number {
|
|
65
|
+
if (Array.isArray(value)) return numericChartValue(value[0])
|
|
66
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
|
|
67
|
+
if (typeof value === 'string') {
|
|
68
|
+
const parsed = Number(value)
|
|
69
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
70
|
+
}
|
|
71
|
+
return 0
|
|
72
|
+
}
|
|
73
|
+
|
|
64
74
|
|
|
65
75
|
function formatBucketLabel(bucket: string, range: Range): string {
|
|
66
76
|
if (range === '24h') {
|
|
@@ -306,7 +316,7 @@ export default function UsagePage() {
|
|
|
306
316
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
|
307
317
|
<XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
308
318
|
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={formatTokens} />
|
|
309
|
-
<Tooltip {...tooltipStyle} formatter={(value
|
|
319
|
+
<Tooltip {...tooltipStyle} formatter={(value) => [formatTokens(numericChartValue(value)), 'Tokens']} />
|
|
310
320
|
<Line type="monotone" dataKey="tokens" stroke="#818CF8" strokeWidth={2} dot={false} activeDot={{ r: 4, fill: '#818CF8' }} />
|
|
311
321
|
</LineChart>
|
|
312
322
|
</ResponsiveContainer>
|
|
@@ -324,8 +334,8 @@ export default function UsagePage() {
|
|
|
324
334
|
<BarChart data={providerData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
|
|
325
335
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
|
326
336
|
<XAxis dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
327
|
-
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(
|
|
328
|
-
<Tooltip {...tooltipStyle} formatter={(value
|
|
337
|
+
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(value) => `$${numericChartValue(value)}`} />
|
|
338
|
+
<Tooltip {...tooltipStyle} formatter={(value) => [formatCost(numericChartValue(value)), 'Cost']} />
|
|
329
339
|
<Bar dataKey="cost" radius={[4, 4, 0, 0]}>
|
|
330
340
|
{providerData.map((_entry, i) => (
|
|
331
341
|
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
|
@@ -343,9 +353,9 @@ export default function UsagePage() {
|
|
|
343
353
|
<ResponsiveContainer width="100%" height={280}>
|
|
344
354
|
<BarChart data={agentData} layout="vertical" margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
|
|
345
355
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" horizontal={false} />
|
|
346
|
-
<XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(
|
|
356
|
+
<XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(value) => `$${numericChartValue(value)}`} />
|
|
347
357
|
<YAxis type="category" dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={100} />
|
|
348
|
-
<Tooltip {...tooltipStyle} formatter={(value
|
|
358
|
+
<Tooltip {...tooltipStyle} formatter={(value) => [formatCost(numericChartValue(value)), 'Cost']} />
|
|
349
359
|
<Bar dataKey="cost" radius={[0, 4, 4, 0]}>
|
|
350
360
|
{agentData.map((_entry, i) => (
|
|
351
361
|
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
|
@@ -370,9 +380,9 @@ export default function UsagePage() {
|
|
|
370
380
|
<YAxis type="category" dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={120} />
|
|
371
381
|
<Tooltip
|
|
372
382
|
{...tooltipStyle}
|
|
373
|
-
formatter={(value
|
|
374
|
-
formatTokens(value
|
|
375
|
-
name === 'definitionTokens' ? 'Context (definitions)' : 'Invocations',
|
|
383
|
+
formatter={(value, name) => [
|
|
384
|
+
formatTokens(numericChartValue(value)),
|
|
385
|
+
String(name) === 'definitionTokens' ? 'Context (definitions)' : 'Invocations',
|
|
376
386
|
]}
|
|
377
387
|
/>
|
|
378
388
|
<Bar dataKey="definitionTokens" fill="#818CF8" radius={[0, 0, 0, 0]} stackId="a" name="definitionTokens" />
|
|
@@ -381,9 +391,9 @@ export default function UsagePage() {
|
|
|
381
391
|
verticalAlign="bottom"
|
|
382
392
|
iconType="circle"
|
|
383
393
|
iconSize={8}
|
|
384
|
-
formatter={(value
|
|
394
|
+
formatter={(value) => (
|
|
385
395
|
<span style={{ color: '#a0a0b0', fontSize: 11 }}>
|
|
386
|
-
{value === 'definitionTokens' ? 'Context (definitions)' : 'Invocations'}
|
|
396
|
+
{String(value) === 'definitionTokens' ? 'Context (definitions)' : 'Invocations'}
|
|
387
397
|
</span>
|
|
388
398
|
)}
|
|
389
399
|
/>
|
|
@@ -431,7 +441,7 @@ export default function UsagePage() {
|
|
|
431
441
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
|
432
442
|
<XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
|
|
433
443
|
<YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
|
|
434
|
-
<Tooltip {...tooltipStyle} formatter={(value
|
|
444
|
+
<Tooltip {...tooltipStyle} formatter={(value) => [numericChartValue(value), 'Completed']} />
|
|
435
445
|
<Bar dataKey="count" fill="#34D399" radius={[4, 4, 0, 0]} />
|
|
436
446
|
</BarChart>
|
|
437
447
|
</ResponsiveContainer>
|
|
@@ -458,7 +468,7 @@ export default function UsagePage() {
|
|
|
458
468
|
<Bar dataKey="completed" fill="#34D399" radius={[4, 4, 0, 0]} stackId="a" name="Completed" />
|
|
459
469
|
<Bar dataKey="failed" fill="#F87171" radius={[4, 4, 0, 0]} stackId="a" name="Failed" />
|
|
460
470
|
<Legend verticalAlign="bottom" iconType="circle" iconSize={8}
|
|
461
|
-
formatter={(value
|
|
471
|
+
formatter={(value) => <span style={{ color: '#a0a0b0', fontSize: 11 }}>{String(value)}</span>} />
|
|
462
472
|
</BarChart>
|
|
463
473
|
</ResponsiveContainer>
|
|
464
474
|
) : (
|
|
@@ -58,3 +58,133 @@ test('findStandaloneServer recursively resolves nested standalone server paths',
|
|
|
58
58
|
fs.rmSync(homeDir, { recursive: true, force: true })
|
|
59
59
|
fs.rmSync(pkgRoot, { recursive: true, force: true })
|
|
60
60
|
})
|
|
61
|
+
|
|
62
|
+
test('resolvePackageBuildRoot uses a versioned workspace for registry installs', () => {
|
|
63
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
|
|
64
|
+
const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
|
|
65
|
+
const serverCmd = loadServerCmdForHome(homeDir)
|
|
66
|
+
|
|
67
|
+
fs.writeFileSync(
|
|
68
|
+
path.join(pkgRoot, 'package.json'),
|
|
69
|
+
JSON.stringify({ name: '@swarmclawai/swarmclaw', version: '1.0.2' }),
|
|
70
|
+
'utf8',
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert.equal(
|
|
74
|
+
serverCmd.resolvePackageBuildRoot(pkgRoot),
|
|
75
|
+
path.join(homeDir, 'builds', 'package-1.0.2'),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
fs.rmSync(homeDir, { recursive: true, force: true })
|
|
79
|
+
fs.rmSync(pkgRoot, { recursive: true, force: true })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('findStandaloneServer falls back to the external build workspace for registry installs', () => {
|
|
83
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
|
|
84
|
+
const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
|
|
85
|
+
const serverCmd = loadServerCmdForHome(homeDir)
|
|
86
|
+
|
|
87
|
+
fs.writeFileSync(
|
|
88
|
+
path.join(pkgRoot, 'package.json'),
|
|
89
|
+
JSON.stringify({ name: '@swarmclawai/swarmclaw', version: '1.0.2' }),
|
|
90
|
+
'utf8',
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const nestedServer = path.join(
|
|
94
|
+
serverCmd.resolvePackageBuildRoot(pkgRoot),
|
|
95
|
+
'.next',
|
|
96
|
+
'standalone',
|
|
97
|
+
'Users',
|
|
98
|
+
'wayde',
|
|
99
|
+
'Dev',
|
|
100
|
+
'swarmclaw',
|
|
101
|
+
'server.js',
|
|
102
|
+
)
|
|
103
|
+
fs.mkdirSync(path.dirname(nestedServer), { recursive: true })
|
|
104
|
+
fs.writeFileSync(nestedServer, 'console.log("ok")\n', 'utf8')
|
|
105
|
+
|
|
106
|
+
assert.equal(serverCmd.findStandaloneServer({ pkgRoot }), nestedServer)
|
|
107
|
+
|
|
108
|
+
fs.rmSync(homeDir, { recursive: true, force: true })
|
|
109
|
+
fs.rmSync(pkgRoot, { recursive: true, force: true })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('prepareBuildWorkspace copies the package tree and links node_modules outside node_modules paths', () => {
|
|
113
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
|
|
114
|
+
const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
|
|
115
|
+
const externalNodeModules = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-node-modules-'))
|
|
116
|
+
const serverCmd = loadServerCmdForHome(homeDir)
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
path.join(pkgRoot, 'package.json'),
|
|
120
|
+
JSON.stringify({ name: '@swarmclawai/swarmclaw', version: '1.0.2' }),
|
|
121
|
+
'utf8',
|
|
122
|
+
)
|
|
123
|
+
fs.mkdirSync(path.join(pkgRoot, 'src', 'app'), { recursive: true })
|
|
124
|
+
fs.writeFileSync(path.join(pkgRoot, 'src', 'app', 'page.tsx'), 'export default function Page() { return null }\n', 'utf8')
|
|
125
|
+
|
|
126
|
+
const buildRoot = serverCmd.resolvePackageBuildRoot(pkgRoot)
|
|
127
|
+
serverCmd.prepareBuildWorkspace({ pkgRoot, buildRoot, nodeModulesDir: externalNodeModules })
|
|
128
|
+
|
|
129
|
+
assert.equal(fs.readFileSync(path.join(buildRoot, 'package.json'), 'utf8'), fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'))
|
|
130
|
+
assert.equal(fs.readFileSync(path.join(buildRoot, 'src', 'app', 'page.tsx'), 'utf8'), 'export default function Page() { return null }\n')
|
|
131
|
+
assert.equal(fs.lstatSync(path.join(buildRoot, 'node_modules')).isSymbolicLink(), true)
|
|
132
|
+
assert.equal(fs.realpathSync(path.join(buildRoot, 'node_modules')), fs.realpathSync(externalNodeModules))
|
|
133
|
+
|
|
134
|
+
fs.rmSync(homeDir, { recursive: true, force: true })
|
|
135
|
+
fs.rmSync(pkgRoot, { recursive: true, force: true })
|
|
136
|
+
fs.rmSync(externalNodeModules, { recursive: true, force: true })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('resolveReadyCheckHost maps wildcard bind hosts to loopback', () => {
|
|
140
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
|
|
141
|
+
const serverCmd = loadServerCmdForHome(homeDir)
|
|
142
|
+
|
|
143
|
+
assert.equal(serverCmd.resolveReadyCheckHost('0.0.0.0'), '127.0.0.1')
|
|
144
|
+
assert.equal(serverCmd.resolveReadyCheckHost('::'), '::1')
|
|
145
|
+
assert.equal(serverCmd.resolveReadyCheckHost('127.0.0.1'), '127.0.0.1')
|
|
146
|
+
|
|
147
|
+
fs.rmSync(homeDir, { recursive: true, force: true })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('waitForPortReady resolves once the readiness probe succeeds', async () => {
|
|
151
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
|
|
152
|
+
const serverCmd = loadServerCmdForHome(homeDir)
|
|
153
|
+
const calls = []
|
|
154
|
+
let attempts = 0
|
|
155
|
+
|
|
156
|
+
await serverCmd.waitForPortReady({
|
|
157
|
+
host: '0.0.0.0',
|
|
158
|
+
port: 3456,
|
|
159
|
+
timeoutMs: 1_000,
|
|
160
|
+
intervalMs: 10,
|
|
161
|
+
probeFn: async (host, port) => {
|
|
162
|
+
calls.push({ host, port })
|
|
163
|
+
attempts += 1
|
|
164
|
+
return attempts >= 3
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
assert.deepEqual(calls[0], { host: '127.0.0.1', port: 3456 })
|
|
169
|
+
assert.equal(calls.length, 3)
|
|
170
|
+
fs.rmSync(homeDir, { recursive: true, force: true })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('waitForPortReady fails fast when the detached process exits before readiness', async () => {
|
|
174
|
+
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
|
|
175
|
+
const serverCmd = loadServerCmdForHome(homeDir)
|
|
176
|
+
|
|
177
|
+
await assert.rejects(
|
|
178
|
+
serverCmd.waitForPortReady({
|
|
179
|
+
host: '127.0.0.1',
|
|
180
|
+
port: 6553,
|
|
181
|
+
pid: 4242,
|
|
182
|
+
timeoutMs: 500,
|
|
183
|
+
intervalMs: 25,
|
|
184
|
+
isProcessRunningFn: () => false,
|
|
185
|
+
}),
|
|
186
|
+
/exited before becoming ready/,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
fs.rmSync(homeDir, { recursive: true, force: true })
|
|
190
|
+
})
|
|
@@ -60,9 +60,9 @@ describe('data-dir resolution', () => {
|
|
|
60
60
|
|
|
61
61
|
try {
|
|
62
62
|
const env = { ...process.env, HOME: fakeHome, npm_lifecycle_event: 'build:ci' }
|
|
63
|
-
delete
|
|
64
|
-
delete
|
|
65
|
-
delete
|
|
63
|
+
delete env.DATA_DIR
|
|
64
|
+
delete env.WORKSPACE_DIR
|
|
65
|
+
delete env.BROWSER_PROFILES_DIR
|
|
66
66
|
|
|
67
67
|
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
|
|
68
68
|
const modNs = await import('./src/lib/server/data-dir')
|
|
@@ -98,9 +98,9 @@ describe('data-dir resolution', () => {
|
|
|
98
98
|
|
|
99
99
|
try {
|
|
100
100
|
const env = { ...process.env, HOME: fakeHome, SWARMCLAW_HOME: swarmclawHome }
|
|
101
|
-
delete
|
|
102
|
-
delete
|
|
103
|
-
delete
|
|
101
|
+
delete env.DATA_DIR
|
|
102
|
+
delete env.WORKSPACE_DIR
|
|
103
|
+
delete env.BROWSER_PROFILES_DIR
|
|
104
104
|
|
|
105
105
|
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
|
|
106
106
|
const modNs = await import('./src/lib/server/data-dir')
|