@venturewild/workspace 0.1.1 → 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/README.md +44 -5
- package/package.json +1 -1
- package/server/bin/wild-workspace.mjs +323 -0
- package/server/src/agent-readiness.mjs +200 -0
- package/server/src/config.mjs +30 -0
- package/server/src/daemon-bin.mjs +6 -2
- package/server/src/doctor.mjs +246 -0
- package/server/src/index.mjs +231 -0
- 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/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-B2EifA0K.js +0 -89
- package/web/dist/assets/index-CsFUQhvj.css +0 -1
package/server/src/config.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import crypto from 'node:crypto';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
|
|
10
10
|
import { loadAccount } from './account.mjs';
|
|
11
|
+
import { loadOperatorToken } from './operator.mjs';
|
|
11
12
|
|
|
12
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
|
|
@@ -74,6 +75,8 @@ export const ROLES = Object.freeze({
|
|
|
74
75
|
PARTNER: 'partner',
|
|
75
76
|
VIEWER: 'viewer',
|
|
76
77
|
CLIENT: 'client',
|
|
78
|
+
// The consented support/operator channel (off by default — see operator.mjs).
|
|
79
|
+
OPERATOR: 'operator',
|
|
77
80
|
});
|
|
78
81
|
|
|
79
82
|
export const ROLE_CAPABILITIES = Object.freeze({
|
|
@@ -88,6 +91,7 @@ export const ROLE_CAPABILITIES = Object.freeze({
|
|
|
88
91
|
sync: true,
|
|
89
92
|
deploy: true,
|
|
90
93
|
requestChanges: false,
|
|
94
|
+
operate: true, // the owner can also drive the operator allowlist locally
|
|
91
95
|
},
|
|
92
96
|
viewer: {
|
|
93
97
|
chat: true,
|
|
@@ -100,6 +104,7 @@ export const ROLE_CAPABILITIES = Object.freeze({
|
|
|
100
104
|
sync: false,
|
|
101
105
|
deploy: false,
|
|
102
106
|
requestChanges: false,
|
|
107
|
+
operate: false,
|
|
103
108
|
},
|
|
104
109
|
client: {
|
|
105
110
|
chat: true,
|
|
@@ -112,6 +117,24 @@ export const ROLE_CAPABILITIES = Object.freeze({
|
|
|
112
117
|
sync: false,
|
|
113
118
|
deploy: false,
|
|
114
119
|
requestChanges: true,
|
|
120
|
+
operate: false,
|
|
121
|
+
},
|
|
122
|
+
// Operator: remote diagnose + a curated remediation allowlist. Read-only on
|
|
123
|
+
// chat (can SEE the conversation to help, cannot drive the agent — chatWrite
|
|
124
|
+
// stays false), plus the `operate` capability the /api/operator/* routes gate
|
|
125
|
+
// on. Reachable only with the dedicated operator token (operator.mjs).
|
|
126
|
+
operator: {
|
|
127
|
+
chat: true,
|
|
128
|
+
chatWrite: false,
|
|
129
|
+
preview: true,
|
|
130
|
+
fileTree: true,
|
|
131
|
+
terminal: false,
|
|
132
|
+
inbox: false,
|
|
133
|
+
share: false,
|
|
134
|
+
sync: false,
|
|
135
|
+
deploy: false,
|
|
136
|
+
requestChanges: false,
|
|
137
|
+
operate: true,
|
|
115
138
|
},
|
|
116
139
|
});
|
|
117
140
|
|
|
@@ -231,6 +254,13 @@ export function buildConfig(overrides = {}) {
|
|
|
231
254
|
overrides.shareSecret ||
|
|
232
255
|
env.WILD_WORKSPACE_SHARE_SECRET ||
|
|
233
256
|
secrets().shareSecret,
|
|
257
|
+
// The operator-channel token — null unless the user explicitly enabled the
|
|
258
|
+
// channel (`wild-workspace operator enable`). Off by default. Server-side
|
|
259
|
+
// only; never broadcast to the browser.
|
|
260
|
+
operatorToken:
|
|
261
|
+
overrides.operatorToken ??
|
|
262
|
+
env.WILD_WORKSPACE_OPERATOR_TOKEN ??
|
|
263
|
+
loadOperatorToken(dataDir),
|
|
234
264
|
workspaceId:
|
|
235
265
|
overrides.workspaceId ||
|
|
236
266
|
env.WILD_WORKSPACE_ID ||
|
|
@@ -36,9 +36,13 @@ export function daemonBinaryName() {
|
|
|
36
36
|
*
|
|
37
37
|
* @param {{ env?: NodeJS.ProcessEnv, vendorRoot?: string }} [opts]
|
|
38
38
|
*/
|
|
39
|
-
export function resolveDaemonBinary({ env = process.env, vendorRoot } = {}) {
|
|
39
|
+
export function resolveDaemonBinary({ env = process.env, vendorRoot, requireResolve } = {}) {
|
|
40
40
|
const binName = daemonBinaryName();
|
|
41
41
|
const tag = platformTag();
|
|
42
|
+
// Injected seam: lets a test simulate "the platform subpackage isn't
|
|
43
|
+
// installed" deterministically, regardless of what's in this machine's
|
|
44
|
+
// node_modules (the win32-x64 subpackage IS present on a Windows dev box).
|
|
45
|
+
const resolvePkg = requireResolve || ((id) => require.resolve(id));
|
|
42
46
|
|
|
43
47
|
// 1. explicit override — if set but missing, that's an error, not a miss.
|
|
44
48
|
const override = env.WILD_WORKSPACE_DAEMON_BIN;
|
|
@@ -49,7 +53,7 @@ export function resolveDaemonBinary({ env = process.env, vendorRoot } = {}) {
|
|
|
49
53
|
// 2. per-platform npm subpackage — resolve via its package.json so the
|
|
50
54
|
// lookup doesn't depend on an `exports` map for the binary file.
|
|
51
55
|
try {
|
|
52
|
-
const pkgJson =
|
|
56
|
+
const pkgJson = resolvePkg(`@venturewild/workspace-daemon-${tag}/package.json`);
|
|
53
57
|
const candidate = path.join(path.dirname(pkgJson), binName);
|
|
54
58
|
if (existsSync(candidate)) return { path: candidate, source: 'subpackage' };
|
|
55
59
|
} catch {
|
|
@@ -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
|
+
}
|
package/server/src/index.mjs
CHANGED
|
@@ -25,11 +25,19 @@ import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs
|
|
|
25
25
|
import { InboxWatcher } from './inbox.mjs';
|
|
26
26
|
import { ActivityBus } from './activity.mjs';
|
|
27
27
|
import { loadIdentity, saveIdentity, markOnboarded, TONES } from './agent-identity.mjs';
|
|
28
|
+
import { probeAgentReadiness } from './agent-readiness.mjs';
|
|
28
29
|
import { ErrorReporter } from './error-reporter.mjs';
|
|
29
30
|
import { DaemonBridge } from './daemon.mjs';
|
|
30
31
|
import { DaemonSupervisor } from './daemon-supervisor.mjs';
|
|
31
32
|
import { SyncControl } from './sync.mjs';
|
|
32
33
|
import { detectPreviewPorts, checkPort } from './preview.mjs';
|
|
34
|
+
import { loadAccount } from './account.mjs';
|
|
35
|
+
import { runDoctor } from './doctor.mjs';
|
|
36
|
+
import { appendLine, tailFile, logFile, TAILABLE, globalDir } from './logpaths.mjs';
|
|
37
|
+
import { SessionReporter } from './session-reporter.mjs';
|
|
38
|
+
import { TranscriptRecorder } from './transcript.mjs';
|
|
39
|
+
import { loadObservabilityConsent, setObservabilityConsent } from './observability.mjs';
|
|
40
|
+
import { spawn } from 'node:child_process';
|
|
33
41
|
import { nanoid } from 'nanoid';
|
|
34
42
|
|
|
35
43
|
const __filename = url.fileURLToPath(import.meta.url);
|
|
@@ -166,6 +174,73 @@ export async function createServer(overrides = {}) {
|
|
|
166
174
|
enabled: process.env.WILD_WORKSPACE_NO_TELEMETRY !== '1',
|
|
167
175
|
});
|
|
168
176
|
|
|
177
|
+
// Proactive, consented session + install observability (session-reporter.mjs).
|
|
178
|
+
// Default-on with a clear disclosure at onboarding; off via the consent toggle
|
|
179
|
+
// or the WILD_WORKSPACE_NO_TELEMETRY kill switch. Inert in tests and without an
|
|
180
|
+
// account token. Carries WHAT happened + install health, never the words —
|
|
181
|
+
// conversation content is the separate transcript channel.
|
|
182
|
+
let observability = loadObservabilityConsent(config.dataDir);
|
|
183
|
+
const sessionEnabled = () =>
|
|
184
|
+
observability.enabled &&
|
|
185
|
+
process.env.WILD_WORKSPACE_NO_TELEMETRY !== '1' &&
|
|
186
|
+
!process.env.VITEST &&
|
|
187
|
+
config.nodeEnv !== 'test';
|
|
188
|
+
const sessionReporter =
|
|
189
|
+
overrides.sessionReporter === false
|
|
190
|
+
? { ingest() {}, flush() {}, start() {}, stop() {}, setEnabled() {} }
|
|
191
|
+
: overrides.sessionReporter ||
|
|
192
|
+
new SessionReporter({
|
|
193
|
+
bmoSyncUrl: config.bmoSyncServerUrl,
|
|
194
|
+
accountToken: config.accountToken,
|
|
195
|
+
slug: config.account?.slug || null,
|
|
196
|
+
workspaceId: config.workspaceId,
|
|
197
|
+
sessionId: overrides.sessionId || nanoid(12),
|
|
198
|
+
enabled: sessionEnabled(),
|
|
199
|
+
});
|
|
200
|
+
// Conversation *content* channel (transcript.mjs) — separate from the feed.
|
|
201
|
+
// Appends markdown to ~/.wild-workspace/transcripts/<workspaceId>/ (OUTSIDE the
|
|
202
|
+
// synced repo); forwarding to us is consent-gated. Noop under the test runner so
|
|
203
|
+
// it never writes into a real home dir.
|
|
204
|
+
const transcriptForward = ({ markdown, date }) => {
|
|
205
|
+
if (!sessionEnabled() || !config.accountToken) return;
|
|
206
|
+
const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/telemetry`;
|
|
207
|
+
const ctrl = new AbortController();
|
|
208
|
+
const t = setTimeout(() => ctrl.abort(), 5000);
|
|
209
|
+
Promise.resolve()
|
|
210
|
+
.then(() =>
|
|
211
|
+
fetch(url, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'content-type': 'application/json' },
|
|
214
|
+
body: JSON.stringify({
|
|
215
|
+
account_token: config.accountToken,
|
|
216
|
+
slug: config.account?.slug || null,
|
|
217
|
+
workspace_id: config.workspaceId,
|
|
218
|
+
kind: 'transcript',
|
|
219
|
+
date,
|
|
220
|
+
markdown,
|
|
221
|
+
sent_at: Math.floor(Date.now() / 1000),
|
|
222
|
+
}),
|
|
223
|
+
signal: ctrl.signal,
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
.catch(() => {})
|
|
227
|
+
.finally(() => clearTimeout(t));
|
|
228
|
+
};
|
|
229
|
+
const transcriptRecorder =
|
|
230
|
+
overrides.transcriptRecorder === false || process.env.VITEST || config.nodeEnv === 'test'
|
|
231
|
+
? { ingest() {}, flush() {}, stop() {} }
|
|
232
|
+
: new TranscriptRecorder({
|
|
233
|
+
dir: path.join(globalDir(), 'transcripts', config.workspaceId),
|
|
234
|
+
agentName: loadIdentity(config.dataDir)?.name || activeAgent?.label || 'Agent',
|
|
235
|
+
forwardImpl: transcriptForward,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
activityBus.on('event', (e) => {
|
|
239
|
+
try { sessionReporter.ingest(e); } catch { /* never let telemetry break the bus */ }
|
|
240
|
+
try { transcriptRecorder.ingest(e); } catch { /* nor the transcript */ }
|
|
241
|
+
});
|
|
242
|
+
sessionReporter.start();
|
|
243
|
+
|
|
169
244
|
// --- chat turn orchestration ----------------------------------------------
|
|
170
245
|
// One conversation per workspace in v1 (single-user, single tab — PRD §5.5).
|
|
171
246
|
// Both user sends and auto-wake turns thread through one turn-runner so they
|
|
@@ -358,6 +433,12 @@ export async function createServer(overrides = {}) {
|
|
|
358
433
|
if (token === config.partnerToken) {
|
|
359
434
|
return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token' };
|
|
360
435
|
}
|
|
436
|
+
// The operator (support) token — header-only, and only when the channel is
|
|
437
|
+
// explicitly enabled (a token exists). Never accepted via `?t=` (below),
|
|
438
|
+
// so it can't leak through URLs/logs/referrer (SECURITY.md S1).
|
|
439
|
+
if (config.operatorToken && token === config.operatorToken) {
|
|
440
|
+
return { role: ROLES.OPERATOR, sub: 'operator', source: 'operator-token' };
|
|
441
|
+
}
|
|
361
442
|
const payload = await verifyShareToken(token, config.shareSecret);
|
|
362
443
|
if (payload && !tokenRegistry.isRevoked(payload.sub)) {
|
|
363
444
|
return {
|
|
@@ -456,6 +537,9 @@ export async function createServer(overrides = {}) {
|
|
|
456
537
|
// with the actual <slug>.venturewild.llc URL. accountToken is NOT
|
|
457
538
|
// exposed — it stays in server-side config only.
|
|
458
539
|
account: config.account,
|
|
540
|
+
// Consent state for the proactive observability feed, so settings/onboarding
|
|
541
|
+
// can show + toggle it. The disclosure copy lives in the UI.
|
|
542
|
+
observability: { enabled: observability.enabled, version: observability.version },
|
|
459
543
|
});
|
|
460
544
|
});
|
|
461
545
|
|
|
@@ -467,6 +551,33 @@ export async function createServer(overrides = {}) {
|
|
|
467
551
|
return c.json({ identity, tones: TONES });
|
|
468
552
|
});
|
|
469
553
|
|
|
554
|
+
// --- agent readiness (the agent-login gate) ---
|
|
555
|
+
// "Is the wrapped agent installed AND signed in?" detectAgents() only proves
|
|
556
|
+
// the binary is on PATH; this proves a turn will actually work. Onboarding
|
|
557
|
+
// calls this before its folder-peek wow beat so a not-signed-in user gets a
|
|
558
|
+
// calm "sign in to Claude" step instead of a broken error bubble (the §3.2
|
|
559
|
+
// open question in docs/user-experience.md). See agent-readiness.mjs.
|
|
560
|
+
//
|
|
561
|
+
// Cached briefly: a 'ready' verdict rarely flips, and the probe spawns a
|
|
562
|
+
// subprocess. `?fresh=1` forces a re-probe (the gate's "I've signed in" button
|
|
563
|
+
// sends it so the user isn't stuck behind a stale 'login' verdict).
|
|
564
|
+
let _readinessCache = null; // { at, verdict }
|
|
565
|
+
const READINESS_TTL_MS = 30_000;
|
|
566
|
+
const agentTag = (a) => (a ? { id: a.id, label: a.label } : null);
|
|
567
|
+
app.get('/api/agent/readiness', async (c) => {
|
|
568
|
+
const forbidden = require(c, 'chat');
|
|
569
|
+
if (forbidden) return forbidden;
|
|
570
|
+
const fresh = c.req.query('fresh') === '1';
|
|
571
|
+
const now = Date.now();
|
|
572
|
+
if (!fresh && _readinessCache && now - _readinessCache.at < READINESS_TTL_MS) {
|
|
573
|
+
return c.json({ agent: agentTag(activeAgent), ...(_readinessCache.verdict) });
|
|
574
|
+
}
|
|
575
|
+
const verdict = await probeAgentReadiness(activeAgent);
|
|
576
|
+
_readinessCache = { at: now, verdict };
|
|
577
|
+
log('[onboarding]', `readiness agent=${activeAgent?.id || '-'} status=${verdict.status}${verdict.email ? ` email=${verdict.email}` : ''}`);
|
|
578
|
+
return c.json({ agent: agentTag(activeAgent), ...verdict });
|
|
579
|
+
});
|
|
580
|
+
|
|
470
581
|
app.post('/api/agent/identity', async (c) => {
|
|
471
582
|
const forbidden = require(c, 'chatWrite');
|
|
472
583
|
if (forbidden) return forbidden;
|
|
@@ -499,6 +610,20 @@ export async function createServer(overrides = {}) {
|
|
|
499
610
|
}
|
|
500
611
|
});
|
|
501
612
|
|
|
613
|
+
// Consent toggle for the proactive observability feed (default-on — see
|
|
614
|
+
// observability.mjs). Owner-only; applied live to the reporter, no restart.
|
|
615
|
+
app.post('/api/observability/consent', async (c) => {
|
|
616
|
+
const forbidden = require(c, 'chatWrite');
|
|
617
|
+
if (forbidden) return forbidden;
|
|
618
|
+
const body = await c.req.json().catch(() => ({}));
|
|
619
|
+
const enabled = body.enabled !== false;
|
|
620
|
+
observability = setObservabilityConsent(config.dataDir, enabled);
|
|
621
|
+
sessionReporter.setEnabled(sessionEnabled());
|
|
622
|
+
activityBus.publish({ type: 'observability-consent', enabled });
|
|
623
|
+
log('[observability]', `consent set enabled=${enabled}`);
|
|
624
|
+
return c.json({ observability: { enabled: observability.enabled, version: observability.version } });
|
|
625
|
+
});
|
|
626
|
+
|
|
502
627
|
// --- onboarding step 2: agent peeks at a folder ---
|
|
503
628
|
// The browser sends a small sample of the chosen folder's contents — file
|
|
504
629
|
// names + a short head of each text file — and we ask the agent to react
|
|
@@ -637,6 +762,109 @@ export async function createServer(overrides = {}) {
|
|
|
637
762
|
return c.json({ ok: true, active: activeAgent.id });
|
|
638
763
|
});
|
|
639
764
|
|
|
765
|
+
// --- operator channel (consented support; OFF unless a token is set) -------
|
|
766
|
+
// The dedicated operator token (operator.mjs) maps to the `operator` role in
|
|
767
|
+
// resolveRole; every route here gates on the `operate` capability. When the
|
|
768
|
+
// channel is disabled (no token) the routes 404 so the surface is invisible.
|
|
769
|
+
// Each call is audit-logged to operator.log AND surfaced in the activity feed
|
|
770
|
+
// (CLAUDE.md principle #5 — both peers see what happened). The actions are a
|
|
771
|
+
// CURATED ALLOWLIST — never arbitrary shell (docs/SECURITY.md).
|
|
772
|
+
const operatorDeps = {
|
|
773
|
+
runDoctor: (o) => runDoctor(o),
|
|
774
|
+
detectAgents,
|
|
775
|
+
loadAccount,
|
|
776
|
+
spawn,
|
|
777
|
+
...(overrides.operatorDeps || {}),
|
|
778
|
+
};
|
|
779
|
+
const operatorEnabled = () => Boolean(config.operatorToken);
|
|
780
|
+
function auditOperator(c, action, detail) {
|
|
781
|
+
const s = c.get('session') || {};
|
|
782
|
+
appendLine('operator', `${action} by=${s.sub || 'operator'} src=${s.source || '-'} ${detail || ''}`.trim());
|
|
783
|
+
activityBus.publish({ type: 'operator-action', action, detail: detail || null, at: Date.now() });
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Curated remediation actions. Each reuses an existing seam; none runs an
|
|
787
|
+
// arbitrary command. (`restart-server` is intentionally absent — exiting the
|
|
788
|
+
// process would sever the very tunnel we reach the user through on a machine
|
|
789
|
+
// without the always-on supervisor; deferred — see SECURITY.md.)
|
|
790
|
+
const OPERATOR_ACTIONS = {
|
|
791
|
+
'run-doctor': async () => operatorDeps.runDoctor({ config }),
|
|
792
|
+
'restart-daemon': async () => {
|
|
793
|
+
if (!daemonSupervisor) return { restarted: false, reason: 'daemon-supervisor-disabled' };
|
|
794
|
+
await daemonSupervisor.stop().catch(() => {});
|
|
795
|
+
return daemonSupervisor.ensureRunning();
|
|
796
|
+
},
|
|
797
|
+
'relink-account': async () => {
|
|
798
|
+
const account = operatorDeps.loadAccount(config.dataDir);
|
|
799
|
+
if (daemonSupervisor) {
|
|
800
|
+
await daemonSupervisor.stop().catch(() => {});
|
|
801
|
+
await daemonSupervisor.ensureRunning().catch(() => {});
|
|
802
|
+
}
|
|
803
|
+
return { relinked: Boolean(account?.slug), slug: account?.slug || null, email: account?.email || null };
|
|
804
|
+
},
|
|
805
|
+
'redetect-agent': async () => {
|
|
806
|
+
const agents = (await operatorDeps.detectAgents()) || [];
|
|
807
|
+
const next = pickDefaultAgent(agents) || null;
|
|
808
|
+
activeAgent = next;
|
|
809
|
+
_readinessCache = null; // force a fresh readiness probe next time
|
|
810
|
+
activityBus.publish({ type: 'agent-changed', agentId: next?.id || null });
|
|
811
|
+
return {
|
|
812
|
+
active: next?.id || null,
|
|
813
|
+
available: Boolean(next?.available),
|
|
814
|
+
agents: agents.map((a) => ({ id: a.id, available: a.available })),
|
|
815
|
+
};
|
|
816
|
+
},
|
|
817
|
+
'reinstall-daemon': async () => {
|
|
818
|
+
const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
819
|
+
const child = operatorDeps.spawn(cmd, ['i', '-g', '@venturewild/workspace'], { windowsHide: true });
|
|
820
|
+
appendLine('operator', `reinstall-daemon spawned pid=${child?.pid}`);
|
|
821
|
+
child?.stdout?.on?.('data', (d) => appendLine('operator', `[npm] ${String(d).trim()}`));
|
|
822
|
+
child?.stderr?.on?.('data', (d) => appendLine('operator', `[npm:err] ${String(d).trim()}`));
|
|
823
|
+
child?.on?.('exit', (code) => appendLine('operator', `reinstall-daemon exited code=${code}`));
|
|
824
|
+
return { started: true, pid: child?.pid || null, command: `${cmd} i -g @venturewild/workspace` };
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
app.get('/api/operator/diag', async (c) => {
|
|
829
|
+
if (!operatorEnabled()) return c.json({ error: 'operator-disabled' }, 404);
|
|
830
|
+
const forbidden = require(c, 'operate');
|
|
831
|
+
if (forbidden) return forbidden;
|
|
832
|
+
const report = await operatorDeps.runDoctor({ config });
|
|
833
|
+
auditOperator(c, 'diag', `fail=${report.summary?.fail} warn=${report.summary?.warn}`);
|
|
834
|
+
return c.json(report);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
app.get('/api/operator/logs', (c) => {
|
|
838
|
+
if (!operatorEnabled()) return c.json({ error: 'operator-disabled' }, 404);
|
|
839
|
+
const forbidden = require(c, 'operate');
|
|
840
|
+
if (forbidden) return forbidden;
|
|
841
|
+
const name = c.req.query('name') || 'cli';
|
|
842
|
+
if (!TAILABLE.includes(name)) return c.json({ error: 'unknown-log', name, allowed: TAILABLE }, 400);
|
|
843
|
+
const tail = Math.min(Number(c.req.query('tail')) || 200, 2000);
|
|
844
|
+
const file = logFile(name);
|
|
845
|
+
auditOperator(c, 'logs', `name=${name} tail=${tail}`);
|
|
846
|
+
return c.json({ name, file, tail, body: tailFile(file, tail) });
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
app.post('/api/operator/action', async (c) => {
|
|
850
|
+
if (!operatorEnabled()) return c.json({ error: 'operator-disabled' }, 404);
|
|
851
|
+
const forbidden = require(c, 'operate');
|
|
852
|
+
if (forbidden) return forbidden;
|
|
853
|
+
const body = await c.req.json().catch(() => ({}));
|
|
854
|
+
const action = String(body.action || '');
|
|
855
|
+
if (!OPERATOR_ACTIONS[action]) {
|
|
856
|
+
return c.json({ error: 'unknown-action', action, allowed: Object.keys(OPERATOR_ACTIONS) }, 400);
|
|
857
|
+
}
|
|
858
|
+
auditOperator(c, 'action', `action=${action}`);
|
|
859
|
+
try {
|
|
860
|
+
const result = await OPERATOR_ACTIONS[action]();
|
|
861
|
+
return c.json({ ok: true, action, result });
|
|
862
|
+
} catch (e) {
|
|
863
|
+
appendLine('operator', `action=${action} FAILED ${e?.stack || e}`);
|
|
864
|
+
return c.json({ ok: false, action, error: String(e?.message || e) }, 500);
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
|
|
640
868
|
// --- workspace files ---
|
|
641
869
|
app.get('/api/workspace/tree', async (c) => {
|
|
642
870
|
if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
|
|
@@ -1053,11 +1281,14 @@ export async function createServer(overrides = {}) {
|
|
|
1053
1281
|
daemonSupervisor,
|
|
1054
1282
|
daemonReady,
|
|
1055
1283
|
syncControl,
|
|
1284
|
+
sessionReporter,
|
|
1056
1285
|
detectedAgents,
|
|
1057
1286
|
getActiveAgent: () => activeAgent,
|
|
1058
1287
|
async stop() {
|
|
1059
1288
|
try { clearTimeout(autoWakeTimer); } catch {}
|
|
1060
1289
|
try { currentTurn?.session.close(); } catch {}
|
|
1290
|
+
try { sessionReporter.stop(); } catch {}
|
|
1291
|
+
try { transcriptRecorder.stop(); } catch {}
|
|
1061
1292
|
try { inboxWatcher.stop(); } catch {}
|
|
1062
1293
|
try { daemonBridge?.stop(); } catch {}
|
|
1063
1294
|
// The daemon is deliberately NOT stopped here — it is detached so sync
|