@venturewild/workspace 0.3.0 → 0.3.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/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// lookup chain so it works both in development (a locally built binary in
|
|
6
6
|
// vendor/ or on PATH) and in a published install (the platform subpackage).
|
|
7
7
|
|
|
8
|
-
import { existsSync } from 'node:fs';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import url from 'node:url';
|
|
11
11
|
import { createRequire } from 'node:module';
|
|
@@ -68,3 +68,21 @@ export function resolveDaemonBinary({ env = process.env, vendorRoot, requireReso
|
|
|
68
68
|
// 4. last resort — spawn by name and let PATH resolve it.
|
|
69
69
|
return { path: binName, source: 'path' };
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The version of the INSTALLED per-platform daemon subpackage (e.g. `"0.1.3"`),
|
|
74
|
+
* or null when it can't be determined (resolved via PATH/vendor, or not found).
|
|
75
|
+
* Used by the supervisor to detect a stale running daemon after an auto-update
|
|
76
|
+
* (the daemon's own /health doesn't report a version), so it can recycle it.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveDaemonVersion({ env = process.env, requireResolve } = {}) {
|
|
79
|
+
const tag = platformTag();
|
|
80
|
+
const resolvePkg = requireResolve || ((id) => require.resolve(id));
|
|
81
|
+
try {
|
|
82
|
+
const pkgJson = resolvePkg(`@venturewild/workspace-daemon-${tag}/package.json`);
|
|
83
|
+
const parsed = JSON.parse(readFileSync(pkgJson, 'utf8'));
|
|
84
|
+
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -192,6 +192,21 @@ export class DaemonSupervisor {
|
|
|
192
192
|
return { stopped: true, pid };
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Recycle the daemon so it loads a freshly-resolved binary (after an
|
|
197
|
+
* auto-update). Stop the running process, wait for it to release its API port,
|
|
198
|
+
* then spawn again. Returns the `spawnDaemon` result.
|
|
199
|
+
*/
|
|
200
|
+
async recycle() {
|
|
201
|
+
await this.stop();
|
|
202
|
+
// Wait for the old process to exit + free :PORT, else the new one can't bind.
|
|
203
|
+
const deadline = Date.now() + 5000;
|
|
204
|
+
while (Date.now() < deadline && (await this.health()).running) {
|
|
205
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
206
|
+
}
|
|
207
|
+
return this.spawnDaemon();
|
|
208
|
+
}
|
|
209
|
+
|
|
195
210
|
/** Combined status for `wild-workspace daemon status`. */
|
|
196
211
|
async status() {
|
|
197
212
|
const { running } = await this.health();
|
package/server/src/index.mjs
CHANGED
|
@@ -36,7 +36,15 @@ import { ActivityBus } from './activity.mjs';
|
|
|
36
36
|
import { loadIdentity, saveIdentity, markOnboarded, TONES } from './agent-identity.mjs';
|
|
37
37
|
import { probeAgentReadiness } from './agent-readiness.mjs';
|
|
38
38
|
import { AutoUpdater, npmInstall, recordUpdate, loadUpdateSettings, PACKAGE_NAME } from './auto-update.mjs';
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
consentStatus,
|
|
41
|
+
revokeConsent,
|
|
42
|
+
readAudit,
|
|
43
|
+
grantConsent,
|
|
44
|
+
OPERATE_TIER,
|
|
45
|
+
MAX_TIER,
|
|
46
|
+
MAX_GRANT_MINUTES,
|
|
47
|
+
} from './support-consent.mjs';
|
|
40
48
|
import { ClaudeLoginSession } from './agent-login.mjs';
|
|
41
49
|
import { ErrorReporter } from './error-reporter.mjs';
|
|
42
50
|
import { DaemonBridge } from './daemon.mjs';
|
|
@@ -1081,6 +1089,10 @@ export async function createServer(overrides = {}) {
|
|
|
1081
1089
|
remainingMs: s.remainingMs,
|
|
1082
1090
|
active,
|
|
1083
1091
|
operate,
|
|
1092
|
+
// Whether THIS viewer may grant/revoke (owner only) — drives the banner's
|
|
1093
|
+
// one-tap "Allow VentureWild support" control so consent is never a CLI paste.
|
|
1094
|
+
canGrant: Boolean(ROLE_CAPABILITIES[c.get('role')]?.chatWrite),
|
|
1095
|
+
maxTier: MAX_TIER,
|
|
1084
1096
|
lastAction: last ? { action: last.action, ts: last.ts, ok: last.ok } : null,
|
|
1085
1097
|
});
|
|
1086
1098
|
});
|
|
@@ -1092,6 +1104,31 @@ export async function createServer(overrides = {}) {
|
|
|
1092
1104
|
return c.json({ audit: readAudit(globalDir(), { limit }) });
|
|
1093
1105
|
});
|
|
1094
1106
|
|
|
1107
|
+
// Phase 4 (Pillar E): the owner grants time-boxed support consent with a UI tap —
|
|
1108
|
+
// so "yes, VentureWild may help" is a browser click, never a terminal paste (the
|
|
1109
|
+
// CLI `support allow` still works too). Owner-only. Operate tiers (3/4) require an
|
|
1110
|
+
// explicit confirmOperate so a stray tap can't hand over agent/shell control.
|
|
1111
|
+
app.post('/api/support/grant', async (c) => {
|
|
1112
|
+
const forbidden = require(c, 'chatWrite');
|
|
1113
|
+
if (forbidden) return forbidden;
|
|
1114
|
+
let body;
|
|
1115
|
+
try {
|
|
1116
|
+
body = await c.req.json();
|
|
1117
|
+
} catch {
|
|
1118
|
+
body = {};
|
|
1119
|
+
}
|
|
1120
|
+
const tier = Math.min(MAX_TIER, Math.max(1, Math.floor(Number(body.tier) || 1)));
|
|
1121
|
+
const minutes = Math.min(MAX_GRANT_MINUTES, Math.max(1, Math.floor(Number(body.minutes) || 30)));
|
|
1122
|
+
if (tier >= OPERATE_TIER && body.confirmOperate !== true) {
|
|
1123
|
+
return c.json({ error: 'operate-confirm-required', tier }, 400);
|
|
1124
|
+
}
|
|
1125
|
+
const rec = grantConsent(globalDir(), { tier, minutes });
|
|
1126
|
+
if (!rec) return c.json({ error: 'write-failed' }, 500);
|
|
1127
|
+
appendLine('operator', `support consent granted via UI tier=${rec.tier} minutes=${minutes}`);
|
|
1128
|
+
activityBus.publish({ type: 'support-granted', tier: rec.tier, at: Date.now() });
|
|
1129
|
+
return c.json({ granted: true, tier: rec.tier, expiresAt: rec.expiresAt });
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1095
1132
|
app.post('/api/support/revoke', (c) => {
|
|
1096
1133
|
const forbidden = require(c, 'chatWrite');
|
|
1097
1134
|
if (forbidden) return forbidden;
|
|
@@ -25,6 +25,7 @@ import fs from 'node:fs';
|
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { resolveDaemonVersion } from './daemon-bin.mjs';
|
|
28
29
|
|
|
29
30
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
31
|
const DEFAULT_SERVER_ENTRY = path.join(__dirname, 'index.mjs');
|
|
@@ -125,6 +126,14 @@ export class WorkspaceSupervisor {
|
|
|
125
126
|
superviseDaemon = env.WILD_WORKSPACE_NO_DAEMON_SUPERVISION !== '1',
|
|
126
127
|
daemonPollMs = 10000, // probe the daemon every 10s
|
|
127
128
|
daemonSupervisorFactory = null, // test seam: (supervisor) => DaemonSupervisor-like
|
|
129
|
+
// Daemon version-drift restart (the daemon analog of RC1b): after an
|
|
130
|
+
// auto-update installs a new daemon binary, the long-lived daemon process
|
|
131
|
+
// keeps running the OLD code until something restarts it — so the support
|
|
132
|
+
// channel silently won't activate. We recycle the daemon when the installed
|
|
133
|
+
// subpackage version differs from the version the running daemon was spawned
|
|
134
|
+
// under (tracked in `daemon-runtime.json`, since the daemon's /health reports
|
|
135
|
+
// no version). Test seam: inject a version function.
|
|
136
|
+
daemonVersionImpl = () => resolveDaemonVersion({ env }),
|
|
128
137
|
} = {}) {
|
|
129
138
|
Object.assign(this, {
|
|
130
139
|
serverEntry, workspaceDir, port, globalDir, node, pollMs,
|
|
@@ -132,13 +141,14 @@ export class WorkspaceSupervisor {
|
|
|
132
141
|
crashLoopThreshold, diagnosticsImpl,
|
|
133
142
|
autoRestartOnVersionDrift, versionImpl, installedVersionImpl,
|
|
134
143
|
autoUpdate, updatePollMs, autoUpdaterFactory,
|
|
135
|
-
superviseDaemon, daemonPollMs, daemonSupervisorFactory,
|
|
144
|
+
superviseDaemon, daemonPollMs, daemonSupervisorFactory, daemonVersionImpl,
|
|
136
145
|
});
|
|
137
146
|
this.autoUpdater = null;
|
|
138
147
|
this.updateTimer = null;
|
|
139
148
|
this.daemonSupervisor = null;
|
|
140
149
|
this.daemonTimer = null;
|
|
141
150
|
this._daemonTicking = false;
|
|
151
|
+
this.daemonRuntimeFile = path.join(globalDir, 'daemon-runtime.json');
|
|
142
152
|
this.logFile = path.join(globalDir, 'supervisor.log');
|
|
143
153
|
this.serverLogFile = path.join(globalDir, 'server.out.log');
|
|
144
154
|
this.lockFile = path.join(globalDir, 'supervisor.lock');
|
|
@@ -390,14 +400,58 @@ export class WorkspaceSupervisor {
|
|
|
390
400
|
* channel) must stay up even when the server is crashed/mid-upgrade. Re-entrancy
|
|
391
401
|
* guarded so a slow spawn can't overlap the next tick. Never throws.
|
|
392
402
|
*/
|
|
403
|
+
/** The daemon version the currently-running daemon was spawned under, or null. */
|
|
404
|
+
readDaemonMarker() {
|
|
405
|
+
try {
|
|
406
|
+
const v = JSON.parse(fs.readFileSync(this.daemonRuntimeFile, 'utf8'))?.daemonVersion;
|
|
407
|
+
return typeof v === 'string' ? v : null;
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
writeDaemonMarker(version) {
|
|
414
|
+
if (!version) return; // unknown installed version (PATH/vendor) — don't pin
|
|
415
|
+
try {
|
|
416
|
+
fs.mkdirSync(this.globalDir, { recursive: true });
|
|
417
|
+
fs.writeFileSync(this.daemonRuntimeFile, JSON.stringify({ daemonVersion: version }));
|
|
418
|
+
} catch {
|
|
419
|
+
/* best-effort */
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
393
423
|
async daemonTick() {
|
|
394
424
|
if (!this.daemonSupervisor || this._daemonTicking) return 'skip';
|
|
395
425
|
this._daemonTicking = true;
|
|
396
426
|
try {
|
|
427
|
+
const installed = this.daemonVersionImpl();
|
|
397
428
|
const h = await this.daemonSupervisor.health();
|
|
398
|
-
if (h && h.running)
|
|
429
|
+
if (h && h.running) {
|
|
430
|
+
// Running — but is it the CURRENT binary? After an auto-update the daemon
|
|
431
|
+
// keeps the old code until recycled (RC1b analog). Recycle when the
|
|
432
|
+
// installed version differs from what we recorded at spawn (a null marker
|
|
433
|
+
// = spawned by a pre-drift-aware supervisor → treat as drift, recycle once).
|
|
434
|
+
if (installed && this.readDaemonMarker() !== installed && this.daemonSupervisor.recycle) {
|
|
435
|
+
this.log(
|
|
436
|
+
`daemon version drift (marker=${this.readDaemonMarker() || 'none'} installed=${installed}) — recycling`,
|
|
437
|
+
);
|
|
438
|
+
const r = await this.daemonSupervisor.recycle();
|
|
439
|
+
if (r && r.started) {
|
|
440
|
+
this.writeDaemonMarker(installed);
|
|
441
|
+
this.log(`daemon recycled to ${installed} (pid=${r.pid})`);
|
|
442
|
+
return 'recycled';
|
|
443
|
+
}
|
|
444
|
+
this.log(`daemon recycle failed: ${r?.error || 'unknown'}`);
|
|
445
|
+
return 'recycle-failed';
|
|
446
|
+
}
|
|
447
|
+
return 'healthy';
|
|
448
|
+
}
|
|
399
449
|
const r = await this.daemonSupervisor.ensureRunning();
|
|
400
|
-
if (r && r.started) {
|
|
450
|
+
if (r && r.started) {
|
|
451
|
+
this.writeDaemonMarker(installed);
|
|
452
|
+
this.log(`daemon respawned (pid=${r.pid})`);
|
|
453
|
+
return 'respawned';
|
|
454
|
+
}
|
|
401
455
|
if (r && r.alreadyRunning) return 'healthy';
|
|
402
456
|
this.log(`daemon down, respawn not started: ${r?.error || 'unknown'}`);
|
|
403
457
|
return 'failed';
|