@venturewild/workspace 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +112 -73
- package/package.json +75 -69
- package/server/bin/wild-workspace.mjs +725 -95
- package/server/src/account.mjs +114 -0
- package/server/src/agent-identity.mjs +65 -0
- package/server/src/agent-readiness.mjs +200 -0
- package/server/src/agent.mjs +356 -335
- package/server/src/config.mjs +302 -236
- package/server/src/daemon-bin.mjs +6 -2
- package/server/src/daemon-supervisor.mjs +216 -0
- package/server/src/daemon.mjs +6 -0
- package/server/src/doctor.mjs +246 -0
- package/server/src/error-reporter.mjs +86 -0
- package/server/src/inbox.mjs +86 -81
- package/server/src/index.mjs +1330 -635
- package/server/src/logpaths.mjs +97 -0
- package/server/src/observability.mjs +45 -0
- package/server/src/operator.mjs +65 -0
- package/server/src/service.mjs +127 -0
- package/server/src/session-reporter.mjs +201 -0
- package/server/src/supervisor.mjs +217 -0
- package/server/src/sync.mjs +248 -176
- package/server/src/transcript.mjs +121 -0
- package/web/dist/assets/index-Bj-mdLGj.css +1 -0
- package/web/dist/assets/index-DLRgyr9j.js +89 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DOwej8U4.js +0 -89
- package/web/dist/assets/index-DZkyDo10.css +0 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Lifecycle owner for the bmo-sync daemon.
|
|
2
|
+
//
|
|
3
|
+
// The daemon is bmo-sync's sync engine running as its own process (a browser
|
|
4
|
+
// app can't embed the Rust library the way wild-terminal's Tauri build did).
|
|
5
|
+
// It does ALL the syncing; wild-workspace only displays its status and tells
|
|
6
|
+
// it what to pair. This module is the one extra responsibility wild-workspace
|
|
7
|
+
// takes on: pressing the daemon's power button.
|
|
8
|
+
//
|
|
9
|
+
// Why wild-workspace owns the lifecycle: on a locked-down (enterprise-managed)
|
|
10
|
+
// machine the daemon cannot register itself as an OS service — logon-triggered
|
|
11
|
+
// scheduled tasks are blocked without admin. So the app the user actually runs
|
|
12
|
+
// starts it instead.
|
|
13
|
+
//
|
|
14
|
+
// The daemon is spawned DETACHED + window-hidden: it keeps running (and so
|
|
15
|
+
// keeps syncing) after wild-workspace — server and browser both — has closed,
|
|
16
|
+
// and it never flashes a console window. It is deliberately NOT stopped when
|
|
17
|
+
// the server stops. The one gap this leaves — sync paused between a reboot and
|
|
18
|
+
// the next `wild-workspace` launch — is covered by bmo-sync's offline-resume.
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import {
|
|
22
|
+
openSync,
|
|
23
|
+
closeSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
unlinkSync,
|
|
28
|
+
} from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import { resolveDaemonBinary } from './daemon-bin.mjs';
|
|
32
|
+
|
|
33
|
+
const DEFAULT_HTTP_BASE = 'http://127.0.0.1:8320';
|
|
34
|
+
|
|
35
|
+
export class DaemonSupervisor {
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} [opts]
|
|
38
|
+
* @param {string} [opts.httpBase] the daemon's local HTTP origin.
|
|
39
|
+
* @param {string} [opts.globalDir] where the pid + log files live. A
|
|
40
|
+
* machine-global dir (`~/.wild-workspace`) — the daemon is one per machine,
|
|
41
|
+
* not one per workspace, so this is deliberately NOT the per-workspace
|
|
42
|
+
* `.wild-workspace/` data dir.
|
|
43
|
+
* @param {Function} [opts.resolveBinary] daemon-binary resolver (test seam).
|
|
44
|
+
* @param {Function} [opts.spawnImpl] child_process.spawn (test seam).
|
|
45
|
+
* @param {Function} [opts.fetchImpl] global fetch (test seam).
|
|
46
|
+
* @param {Function} [opts.killImpl] process.kill (test seam).
|
|
47
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
48
|
+
*/
|
|
49
|
+
constructor({
|
|
50
|
+
httpBase = DEFAULT_HTTP_BASE,
|
|
51
|
+
globalDir = path.join(os.homedir(), '.wild-workspace'),
|
|
52
|
+
resolveBinary = resolveDaemonBinary,
|
|
53
|
+
spawnImpl = spawn,
|
|
54
|
+
fetchImpl = globalThis.fetch,
|
|
55
|
+
killImpl = (pid, sig) => process.kill(pid, sig),
|
|
56
|
+
env = process.env,
|
|
57
|
+
// b-ii: when the install is logged in (account.json present), these are
|
|
58
|
+
// injected into the daemon's spawn env so it opens the proxy link
|
|
59
|
+
// (`BMO_DAEMON_ACCOUNT_TOKEN`) against the right relay
|
|
60
|
+
// (`BMO_DAEMON_SERVER_URL`). Absent → daemon syncs only, no proxy link.
|
|
61
|
+
accountToken = null,
|
|
62
|
+
serverUrl = null,
|
|
63
|
+
} = {}) {
|
|
64
|
+
this.httpBase = httpBase.replace(/\/+$/, '');
|
|
65
|
+
this.globalDir = globalDir;
|
|
66
|
+
this.pidFile = path.join(globalDir, 'daemon.pid');
|
|
67
|
+
this.logFile = path.join(globalDir, 'daemon.log');
|
|
68
|
+
this.resolveBinary = resolveBinary;
|
|
69
|
+
this.spawnImpl = spawnImpl;
|
|
70
|
+
this.fetchImpl = fetchImpl;
|
|
71
|
+
this.killImpl = killImpl;
|
|
72
|
+
this.env = env;
|
|
73
|
+
this.accountToken = accountToken;
|
|
74
|
+
this.serverUrl = serverUrl;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Probe the daemon's /health endpoint. Never throws. */
|
|
78
|
+
async health() {
|
|
79
|
+
try {
|
|
80
|
+
const res = await this.fetchImpl(`${this.httpBase}/health`, {
|
|
81
|
+
signal: AbortSignal.timeout(2000),
|
|
82
|
+
});
|
|
83
|
+
return { running: !!res.ok };
|
|
84
|
+
} catch {
|
|
85
|
+
return { running: false };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start the daemon unless it is already up. Idempotent and best-effort —
|
|
91
|
+
* the result is reported, never thrown.
|
|
92
|
+
* @returns {Promise<{started:boolean, alreadyRunning?:boolean, pid?:number,
|
|
93
|
+
* error?:string}>}
|
|
94
|
+
*/
|
|
95
|
+
async ensureRunning() {
|
|
96
|
+
if ((await this.health()).running) {
|
|
97
|
+
return { started: false, alreadyRunning: true };
|
|
98
|
+
}
|
|
99
|
+
return this.spawnDaemon();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Spawn the daemon detached + window-hidden, logging to `daemon.log`. */
|
|
103
|
+
spawnDaemon() {
|
|
104
|
+
const bin = this.resolveBinary({ env: this.env });
|
|
105
|
+
// `null` = an explicit override path that doesn't exist; `path` = nothing
|
|
106
|
+
// concrete found, only the bare name as a PATH last-resort. In neither case
|
|
107
|
+
// is there a real binary to launch — refuse rather than ENOENT later.
|
|
108
|
+
if (!bin || bin.source === 'path') {
|
|
109
|
+
return { started: false, error: 'daemon-binary-not-found' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
mkdirSync(this.globalDir, { recursive: true });
|
|
114
|
+
} catch {
|
|
115
|
+
/* fall through — openSync below will surface a real problem */
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A real fd (not 'ignore') so the daemon's stdout/stderr land in a log the
|
|
119
|
+
// user can read — and so its eprintln writes hit a valid handle.
|
|
120
|
+
let logFd = 'ignore';
|
|
121
|
+
try {
|
|
122
|
+
logFd = openSync(this.logFile, 'a');
|
|
123
|
+
} catch {
|
|
124
|
+
/* can't open the log — run anyway with output discarded */
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let child;
|
|
128
|
+
try {
|
|
129
|
+
child = this.spawnImpl(bin.path, [], {
|
|
130
|
+
detached: true, // outlive the wild-workspace server
|
|
131
|
+
windowsHide: true, // no console window — the whole point
|
|
132
|
+
stdio: ['ignore', logFd, logFd],
|
|
133
|
+
// b-ii proxy link: inject the account token + relay URL when the
|
|
134
|
+
// install is logged in. Object-spreading a falsy value is a no-op,
|
|
135
|
+
// so an unauthenticated install spawns with a clean inherited env.
|
|
136
|
+
env: {
|
|
137
|
+
...this.env,
|
|
138
|
+
...(this.accountToken && { BMO_DAEMON_ACCOUNT_TOKEN: this.accountToken }),
|
|
139
|
+
...(this.serverUrl && { BMO_DAEMON_SERVER_URL: this.serverUrl }),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (typeof logFd === 'number') {
|
|
144
|
+
try { closeSync(logFd); } catch {}
|
|
145
|
+
}
|
|
146
|
+
return { started: false, error: String(err?.message || err) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The parent must drop its own copy of the log fd + its handle on the
|
|
150
|
+
// child, or the server process can't exit cleanly.
|
|
151
|
+
if (typeof logFd === 'number') {
|
|
152
|
+
try { closeSync(logFd); } catch {}
|
|
153
|
+
}
|
|
154
|
+
child.unref();
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
writeFileSync(this.pidFile, String(child.pid));
|
|
158
|
+
} catch {
|
|
159
|
+
/* pid file is best-effort — `stop` falls back to a health probe */
|
|
160
|
+
}
|
|
161
|
+
return { started: true, pid: child.pid, binary: bin.path, source: bin.source };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The pid recorded by the last spawn, or null. */
|
|
165
|
+
readPid() {
|
|
166
|
+
try {
|
|
167
|
+
const pid = Number(readFileSync(this.pidFile, 'utf8').trim());
|
|
168
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Stop the daemon (best-effort) by signalling the recorded pid. */
|
|
175
|
+
async stop() {
|
|
176
|
+
const pid = this.readPid();
|
|
177
|
+
if (!pid) {
|
|
178
|
+
const running = (await this.health()).running;
|
|
179
|
+
return { stopped: false, reason: running ? 'no-pid-file' : 'not-running' };
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
this.killImpl(pid, 'SIGTERM');
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err?.code === 'ESRCH') {
|
|
185
|
+
// already gone — tidy the stale pid file
|
|
186
|
+
try { unlinkSync(this.pidFile); } catch {}
|
|
187
|
+
return { stopped: false, reason: 'not-running' };
|
|
188
|
+
}
|
|
189
|
+
return { stopped: false, reason: String(err?.message || err) };
|
|
190
|
+
}
|
|
191
|
+
try { unlinkSync(this.pidFile); } catch {}
|
|
192
|
+
return { stopped: true, pid };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Combined status for `wild-workspace daemon status`. */
|
|
196
|
+
async status() {
|
|
197
|
+
const { running } = await this.health();
|
|
198
|
+
return {
|
|
199
|
+
running,
|
|
200
|
+
pid: this.readPid(),
|
|
201
|
+
httpBase: this.httpBase,
|
|
202
|
+
pidFile: this.pidFile,
|
|
203
|
+
logFile: this.logFile,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Poll /health until the daemon answers or the deadline passes. */
|
|
208
|
+
async waitForHealthy(timeoutMs = 4000) {
|
|
209
|
+
const deadline = Date.now() + timeoutMs;
|
|
210
|
+
while (Date.now() < deadline) {
|
|
211
|
+
if ((await this.health()).running) return true;
|
|
212
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
package/server/src/daemon.mjs
CHANGED
|
@@ -147,6 +147,12 @@ export class DaemonBridge {
|
|
|
147
147
|
path: msg.path,
|
|
148
148
|
resolution: msg.resolution,
|
|
149
149
|
conflictingUser: msg.conflictingUser,
|
|
150
|
+
// C12-e enrichment — present for daemon-detected conflicts only.
|
|
151
|
+
mineSha256: msg.mineSha256,
|
|
152
|
+
theirsSha256: msg.theirsSha256,
|
|
153
|
+
baseSha256: msg.baseSha256,
|
|
154
|
+
peerBackOfficePath: msg.peerBackOfficePath,
|
|
155
|
+
detectedAt: msg.detectedAt,
|
|
150
156
|
});
|
|
151
157
|
} else if (msg.kind === 'fatal') {
|
|
152
158
|
this.activityBus.publish({
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// `wild-workspace doctor` — one pre/post-flight diagnostic for a real user's
|
|
2
|
+
// machine. The riskiest moment for a brand-new (non-technical) user is the
|
|
3
|
+
// install itself: no Claude yet, wrong Node, a busy port, a daemon binary that
|
|
4
|
+
// didn't resolve, an unclaimed slug. When something breaks we need to SEE it —
|
|
5
|
+
// ideally fix it — without making them feel stupid (docs/user-experience.md §5).
|
|
6
|
+
//
|
|
7
|
+
// runDoctor() returns a structured report (every check is { id, label, status,
|
|
8
|
+
// detail, hint }); the CLI renders it with ✅/⚠️/❌ and the operator channel
|
|
9
|
+
// serves the same JSON. Every external touch-point (agent detect, auth probe,
|
|
10
|
+
// daemon resolve, port check, account load, service status, registry fetch) is
|
|
11
|
+
// an injected seam so the test suite never spawns a process or hits the network.
|
|
12
|
+
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { buildConfig, APP_VERSION } from './config.mjs';
|
|
18
|
+
import { detectAgents, pickDefaultAgent } from './agent.mjs';
|
|
19
|
+
import { probeAgentReadiness } from './agent-readiness.mjs';
|
|
20
|
+
import { resolveDaemonBinary } from './daemon-bin.mjs';
|
|
21
|
+
import { checkPort } from './preview.mjs';
|
|
22
|
+
import { loadAccount } from './account.mjs';
|
|
23
|
+
import { serviceStatus } from './service.mjs';
|
|
24
|
+
import { probeHealth } from './supervisor.mjs';
|
|
25
|
+
import { listLogs, diagnosticsDir } from './logpaths.mjs';
|
|
26
|
+
|
|
27
|
+
const STATUS_ICON = { ok: '✅', warn: '⚠️', fail: '❌', info: 'ℹ️' };
|
|
28
|
+
|
|
29
|
+
// Native installer is Claude's canonical path today (npm i -g still works as a
|
|
30
|
+
// fallback). Shown verbatim to the user, so keep it copy-pasteable.
|
|
31
|
+
const CLAUDE_INSTALL_HINT =
|
|
32
|
+
'Install Claude Code: curl -fsSL https://claude.ai/install.sh | bash (Windows: irm https://claude.ai/install.ps1 | iex)';
|
|
33
|
+
|
|
34
|
+
function nodeMajor(version = process.version) {
|
|
35
|
+
const m = /^v?(\d+)/.exec(String(version));
|
|
36
|
+
return m ? Number(m[1]) : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Reach the bmo-sync registry: resolve the user's slug if linked, else /health.
|
|
40
|
+
async function probeRegistry(config, fetchImpl) {
|
|
41
|
+
const base = String(config.bmoSyncServerUrl || '').replace(/\/$/, '');
|
|
42
|
+
const slug = config.account?.slug || null;
|
|
43
|
+
const url = slug
|
|
44
|
+
? `${base}/api/slug/resolve/${encodeURIComponent(slug)}`
|
|
45
|
+
: `${base}/api/health`;
|
|
46
|
+
const ctrl = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetchImpl(url, { signal: ctrl.signal });
|
|
50
|
+
return { reachable: true, status: res.status, slug, url };
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { reachable: false, error: String(e?.message || e), slug, url };
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run every diagnostic check. All deps are injectable for testing.
|
|
60
|
+
* @returns {{version,generatedAt,platform,summary,checks,logs}}
|
|
61
|
+
*/
|
|
62
|
+
export async function runDoctor(opts = {}, deps = {}) {
|
|
63
|
+
const config = opts.config || deps.config || buildConfig({});
|
|
64
|
+
const env = deps.env || process.env;
|
|
65
|
+
const d = {
|
|
66
|
+
detectAgents: deps.detectAgents || detectAgents,
|
|
67
|
+
probeReadiness: deps.probeAgentReadiness || probeAgentReadiness,
|
|
68
|
+
resolveDaemon: deps.resolveDaemonBinary || resolveDaemonBinary,
|
|
69
|
+
checkPort: deps.checkPort || checkPort,
|
|
70
|
+
loadAccount: deps.loadAccount || loadAccount,
|
|
71
|
+
serviceStatus: deps.serviceStatus || serviceStatus,
|
|
72
|
+
listLogs: deps.listLogs || listLogs,
|
|
73
|
+
fetchImpl: deps.fetchImpl || ((...a) => globalThis.fetch(...a)),
|
|
74
|
+
};
|
|
75
|
+
const checks = [];
|
|
76
|
+
const add = (c) => checks.push(c);
|
|
77
|
+
// Run a check body, capturing a thrown error as a non-fatal 'warn' so one bad
|
|
78
|
+
// check never aborts the whole report.
|
|
79
|
+
const guarded = async (id, label, body) => {
|
|
80
|
+
try {
|
|
81
|
+
add({ id, label, ...(await body()) });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
add({ id, label, status: 'warn', detail: `check errored: ${String(e?.message || e)}`, hint: null });
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// 1. Node runtime
|
|
88
|
+
await guarded('node', 'Node.js runtime', async () => {
|
|
89
|
+
const major = nodeMajor();
|
|
90
|
+
const detail = `${process.version} on ${os.platform()}-${os.arch()}`;
|
|
91
|
+
return major >= 18
|
|
92
|
+
? { status: 'ok', detail, hint: null }
|
|
93
|
+
: { status: 'fail', detail, hint: 'Claude Code + wild-workspace need Node 18 or newer. Update Node, then retry.' };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 2. Claude installed?
|
|
97
|
+
let claude = null;
|
|
98
|
+
await guarded('agent', 'Claude Code installed', async () => {
|
|
99
|
+
const agents = await d.detectAgents();
|
|
100
|
+
claude = (agents || []).find((a) => a.id === 'claude' && a.available) || null;
|
|
101
|
+
if (!claude) {
|
|
102
|
+
const fallback = pickDefaultAgent(agents || []);
|
|
103
|
+
if (fallback?.available) {
|
|
104
|
+
return { status: 'info', detail: `Claude not found; using ${fallback.label}.`, hint: null };
|
|
105
|
+
}
|
|
106
|
+
return { status: 'fail', detail: 'no `claude` on PATH', hint: CLAUDE_INSTALL_HINT };
|
|
107
|
+
}
|
|
108
|
+
return { status: 'ok', detail: claude.resolvedPath || claude.binary, hint: null };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 3. Claude signed in AND able to run turns?
|
|
112
|
+
if (claude) {
|
|
113
|
+
await guarded('agentAuth', 'Claude ready to think', async () => {
|
|
114
|
+
const v = await d.probeReadiness(claude, undefined, env);
|
|
115
|
+
switch (v.status) {
|
|
116
|
+
case 'ready':
|
|
117
|
+
return { status: 'ok', detail: v.email ? `signed in as ${v.email}` : 'signed in', hint: null };
|
|
118
|
+
case 'subscribe':
|
|
119
|
+
return {
|
|
120
|
+
status: 'warn',
|
|
121
|
+
detail: v.email ? `signed in as ${v.email}, no active plan` : 'signed in, no active plan',
|
|
122
|
+
hint: 'Claude Code needs a Claude Pro plan (or higher). Subscribe at claude.ai, then retry.',
|
|
123
|
+
};
|
|
124
|
+
case 'login':
|
|
125
|
+
return { status: 'fail', detail: 'not signed in', hint: 'Run `claude auth login`, sign in, then retry.' };
|
|
126
|
+
case 'missing':
|
|
127
|
+
return { status: 'fail', detail: 'Claude not installed', hint: CLAUDE_INSTALL_HINT };
|
|
128
|
+
default:
|
|
129
|
+
return { status: 'info', detail: `readiness unknown (${v.status})`, hint: 'Will be confirmed on the first agent turn.' };
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 4. bmo-sync daemon binary resolvable?
|
|
135
|
+
await guarded('daemonBinary', 'Sync daemon binary', async () => {
|
|
136
|
+
const r = d.resolveDaemon({ env });
|
|
137
|
+
if (!r) {
|
|
138
|
+
return { status: 'fail', detail: 'WILD_WORKSPACE_DAEMON_BIN is set but the file is missing', hint: 'Unset it or point it at a real binary.' };
|
|
139
|
+
}
|
|
140
|
+
if (r.source === 'path') {
|
|
141
|
+
return {
|
|
142
|
+
status: 'warn',
|
|
143
|
+
detail: 'no bundled daemon found — relying on PATH; cross-device sync may be off',
|
|
144
|
+
hint: 'Reinstall: npm i -g @venturewild/workspace (pulls the daemon for your platform).',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { status: 'ok', detail: `${r.path} (${r.source})`, hint: null };
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// 5. Workspace port
|
|
151
|
+
await guarded('port', `Workspace port :${config.port}`, async () => {
|
|
152
|
+
const inUse = await d.checkPort(config.port);
|
|
153
|
+
return inUse
|
|
154
|
+
? { status: 'info', detail: 'in use — your workspace is likely already running (or another app holds it)', hint: null }
|
|
155
|
+
: { status: 'ok', detail: 'free', hint: null };
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// 6. Account linked (slug)
|
|
159
|
+
let account = null;
|
|
160
|
+
await guarded('account', 'Workspace account linked', async () => {
|
|
161
|
+
account = d.loadAccount(config.dataDir);
|
|
162
|
+
if (account?.slug) {
|
|
163
|
+
return { status: 'ok', detail: `${account.slug} (${account.email || 'no email'}) → https://${account.slug}.venturewild.llc`, hint: null };
|
|
164
|
+
}
|
|
165
|
+
return { status: 'warn', detail: 'not linked yet', hint: 'Run `wild-workspace login <blob>` with the code from workspace.venturewild.llc.' };
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// 7. Registry reachable + slug status
|
|
169
|
+
await guarded('registry', 'Sync server reachable', async () => {
|
|
170
|
+
const r = await probeRegistry({ ...config, account: account || config.account }, d.fetchImpl);
|
|
171
|
+
if (!r.reachable) {
|
|
172
|
+
return { status: 'fail', detail: `can't reach ${r.url}: ${r.error}`, hint: 'Check the internet connection, then retry.' };
|
|
173
|
+
}
|
|
174
|
+
if (r.slug) {
|
|
175
|
+
if (r.status === 200) return { status: 'ok', detail: `slug "${r.slug}" is claimed`, hint: null };
|
|
176
|
+
if (r.status === 404) return { status: 'warn', detail: `slug "${r.slug}" is not claimed on the server`, hint: 'Re-run the claim, or `wild-workspace login` with a fresh blob.' };
|
|
177
|
+
return { status: 'warn', detail: `slug resolve returned HTTP ${r.status}`, hint: null };
|
|
178
|
+
}
|
|
179
|
+
return r.status < 500
|
|
180
|
+
? { status: 'ok', detail: `reachable (HTTP ${r.status})`, hint: null }
|
|
181
|
+
: { status: 'warn', detail: `server returned HTTP ${r.status}`, hint: null };
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// 8. Always-on / autostart
|
|
185
|
+
await guarded('service', 'Always-on (autostart)', async () => {
|
|
186
|
+
const s = await d.serviceStatus({ port: config.port }, { probeImpl: (p) => probeHealth(p) });
|
|
187
|
+
if (s.supported === false) {
|
|
188
|
+
return { status: 'info', detail: `not yet on ${s.platform} — run \`wild-workspace\` to start it`, hint: null };
|
|
189
|
+
}
|
|
190
|
+
const bits = [`installed=${s.installed ? 'yes' : 'no'}`, `supervisor=${s.supervisorAlive ? 'up' : 'down'}`, `server=${s.serverUp ? 'up' : 'down'}`];
|
|
191
|
+
return { status: s.installed ? 'ok' : 'info', detail: bits.join(' '), hint: s.installed ? null : 'Enable with `wild-workspace service install`.' };
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const logs = d.listLogs(env);
|
|
195
|
+
const summary = checks.reduce(
|
|
196
|
+
(acc, c) => ((acc[c.status] = (acc[c.status] || 0) + 1), acc),
|
|
197
|
+
{ ok: 0, warn: 0, fail: 0, info: 0 },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
version: APP_VERSION,
|
|
202
|
+
generatedAt: new Date().toISOString(),
|
|
203
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
204
|
+
summary,
|
|
205
|
+
checks,
|
|
206
|
+
logs,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Render a report to a human string (used by the CLI). The operator channel
|
|
211
|
+
// sends the JSON instead.
|
|
212
|
+
export function renderDoctor(report) {
|
|
213
|
+
const lines = [];
|
|
214
|
+
lines.push(`wild-workspace doctor — v${report.version} (${report.platform})`);
|
|
215
|
+
lines.push('');
|
|
216
|
+
for (const c of report.checks) {
|
|
217
|
+
lines.push(`${STATUS_ICON[c.status] || '•'} ${c.label}: ${c.detail}`);
|
|
218
|
+
if (c.hint && (c.status === 'fail' || c.status === 'warn')) {
|
|
219
|
+
lines.push(` → ${c.hint}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
lines.push('');
|
|
223
|
+
const { ok, warn, fail } = report.summary;
|
|
224
|
+
lines.push(`Summary: ${ok} ok · ${warn} warning${warn === 1 ? '' : 's'} · ${fail} problem${fail === 1 ? '' : 's'}`);
|
|
225
|
+
lines.push('');
|
|
226
|
+
lines.push('Logs:');
|
|
227
|
+
for (const l of report.logs) {
|
|
228
|
+
lines.push(` ${l.exists ? '·' : ' '} ${l.name.padEnd(10)} ${l.file}${l.exists ? ` (${l.size} bytes)` : ' (none yet)'}`);
|
|
229
|
+
}
|
|
230
|
+
return lines.join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Persist the JSON report under ~/.wild-workspace/diagnostics/. Returns the
|
|
234
|
+
// file path (or null if it couldn't be written). Best-effort.
|
|
235
|
+
export function writeDoctorBundle(report, env = process.env) {
|
|
236
|
+
try {
|
|
237
|
+
const dir = diagnosticsDir(env);
|
|
238
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
239
|
+
const stamp = report.generatedAt.replace(/[:.]/g, '-');
|
|
240
|
+
const file = path.join(dir, `doctor-${stamp}.json`);
|
|
241
|
+
fs.writeFileSync(file, JSON.stringify(report, null, 2));
|
|
242
|
+
return file;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Forwards meaningful errors (agent crashes, daemon exits, etc.) to the
|
|
2
|
+
// central bmo-sync-server so support can see what went wrong on a client's
|
|
3
|
+
// machine. Fire-and-forget — never blocks the user's request, never throws.
|
|
4
|
+
//
|
|
5
|
+
// Off-switch: WILD_WORKSPACE_NO_TELEMETRY=1 disables the reporter entirely.
|
|
6
|
+
// Privacy: only error messages + stack traces are sent — no file contents, no
|
|
7
|
+
// chat content, no file paths beyond their basename.
|
|
8
|
+
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { APP_VERSION } from './config.mjs';
|
|
12
|
+
|
|
13
|
+
// In-process burst guard so a thrashing daemon can't hammer the central
|
|
14
|
+
// server. Per-(workspace+category) bucket, 10 reports per 60s window.
|
|
15
|
+
const RL_WINDOW_MS = 60_000;
|
|
16
|
+
const RL_CAP = 10;
|
|
17
|
+
const buckets = new Map();
|
|
18
|
+
|
|
19
|
+
function bucketKey(workspaceId, category) {
|
|
20
|
+
return `${workspaceId}:${category}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function allow(workspaceId, category) {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const key = bucketKey(workspaceId, category);
|
|
26
|
+
const ts = buckets.get(key) || [];
|
|
27
|
+
const recent = ts.filter((t) => now - t < RL_WINDOW_MS);
|
|
28
|
+
if (recent.length >= RL_CAP) {
|
|
29
|
+
buckets.set(key, recent);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
recent.push(now);
|
|
33
|
+
buckets.set(key, recent);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Redact absolute paths down to a basename so we don't leak the user's home
|
|
38
|
+
// directory or file layout to the central server. Picks up POSIX + Windows
|
|
39
|
+
// paths in free text (messages and stack traces).
|
|
40
|
+
const PATH_RE = /([A-Za-z]:\\[\w.\-\\\/]+|\/[\w.\-\/]+\.\w+)/g;
|
|
41
|
+
function redactPaths(text) {
|
|
42
|
+
if (typeof text !== 'string') return text;
|
|
43
|
+
return text.replace(PATH_RE, (full) => path.basename(full));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ErrorReporter {
|
|
47
|
+
constructor({ bmoSyncUrl, workspaceId, enabled }) {
|
|
48
|
+
this.bmoSyncUrl = bmoSyncUrl?.replace(/\/$/, '') || null;
|
|
49
|
+
this.workspaceId = workspaceId || 'unknown';
|
|
50
|
+
this.enabled = enabled !== false && !this.bmoSyncUrl?.startsWith('http://127');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fire-and-forget. Catches every failure path so calling this never breaks
|
|
54
|
+
// the request that triggered it.
|
|
55
|
+
report({ category, message, stack, agentLabel, source }) {
|
|
56
|
+
if (!this.enabled || !this.bmoSyncUrl) return;
|
|
57
|
+
const cat = (category || 'agent').slice(0, 32);
|
|
58
|
+
if (!message) return;
|
|
59
|
+
if (!allow(this.workspaceId, cat)) return;
|
|
60
|
+
const body = {
|
|
61
|
+
workspace_id: this.workspaceId,
|
|
62
|
+
agent_label: agentLabel || null,
|
|
63
|
+
category: cat,
|
|
64
|
+
message: redactPaths(String(message)).slice(0, 4096),
|
|
65
|
+
stack: stack ? redactPaths(String(stack)).slice(0, 8192) : null,
|
|
66
|
+
app_version: APP_VERSION,
|
|
67
|
+
os: `${os.platform()}-${os.arch()}`,
|
|
68
|
+
source: source || 'wild-workspace',
|
|
69
|
+
ts: Math.floor(Date.now() / 1000),
|
|
70
|
+
};
|
|
71
|
+
const url = `${this.bmoSyncUrl}/api/errors/report`;
|
|
72
|
+
// Detached fetch with a short timeout so a hung server can't pile up.
|
|
73
|
+
const ctrl = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
75
|
+
fetch(url, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json' },
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
signal: ctrl.signal,
|
|
80
|
+
})
|
|
81
|
+
.catch(() => {
|
|
82
|
+
/* swallowed — telemetry never breaks the user's path */
|
|
83
|
+
})
|
|
84
|
+
.finally(() => clearTimeout(timer));
|
|
85
|
+
}
|
|
86
|
+
}
|