@venturewild/workspace 0.6.4 → 0.6.6
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
package/server/src/index.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import { ActivityBus } from './activity.mjs';
|
|
|
37
37
|
import { createWorkspacePresence } from './workspace-presence.mjs';
|
|
38
38
|
import { loadIdentity, saveIdentity, markOnboarded, TONES } from './agent-identity.mjs';
|
|
39
39
|
import { probeAgentReadiness } from './agent-readiness.mjs';
|
|
40
|
-
import { AutoUpdater, npmInstall, recordUpdate, loadUpdateSettings, PACKAGE_NAME } from './auto-update.mjs';
|
|
40
|
+
import { AutoUpdater, npmInstall, recordUpdate, loadUpdateSettings, PACKAGE_NAME, fetchLatestVersion, isNewer } from './auto-update.mjs';
|
|
41
41
|
import {
|
|
42
42
|
consentStatus,
|
|
43
43
|
revokeConsent,
|
|
@@ -1404,22 +1404,77 @@ export async function createServer(overrides = {}) {
|
|
|
1404
1404
|
return c.json({ agent: agentTag(activeAgent), ...verdict });
|
|
1405
1405
|
});
|
|
1406
1406
|
|
|
1407
|
-
// Auto-update status (Phase 2) — what's running, the
|
|
1408
|
-
//
|
|
1409
|
-
// the
|
|
1410
|
-
|
|
1407
|
+
// Auto-update status (Phase 2 + the in-app update UX) — what's running, the
|
|
1408
|
+
// channel, on/off, the last update outcome, AND whether a newer version is
|
|
1409
|
+
// published (powers the "update ready" nudge + the ⋯More control + the
|
|
1410
|
+
// check-on-open). The latest-version lookup is cached (60s) so an auto-check on
|
|
1411
|
+
// open/focus is cheap; ?fresh=1 bypasses it (the manual "check again"). Read
|
|
1412
|
+
// (chat)-gated. The latest fetch is degrade-never (null on any failure).
|
|
1413
|
+
let _latestCache = { at: 0, channel: null, latest: null };
|
|
1414
|
+
const LATEST_TTL_MS = 60_000;
|
|
1415
|
+
// `fallback` serves the last-good value for this channel when a fetch fails —
|
|
1416
|
+
// good for the STATUS display (don't flap to null). `apply` passes fallback:false
|
|
1417
|
+
// so it only proceeds on a freshly-confirmed version (never installs off stale).
|
|
1418
|
+
async function latestVersionCached(channel, { fresh = false, fallback = true } = {}) {
|
|
1419
|
+
const t = Date.now();
|
|
1420
|
+
if (!fresh && _latestCache.latest != null && _latestCache.channel === channel
|
|
1421
|
+
&& t - _latestCache.at < LATEST_TTL_MS) return _latestCache.latest;
|
|
1422
|
+
const impl = overrides.latestVersionImpl || fetchLatestVersion;
|
|
1423
|
+
let latest = null;
|
|
1424
|
+
try { latest = await impl(channel, { packageName: PACKAGE_NAME }); } catch { latest = null; }
|
|
1425
|
+
if (latest) { _latestCache = { at: t, channel, latest }; return latest; }
|
|
1426
|
+
return fallback && _latestCache.channel === channel ? _latestCache.latest : null;
|
|
1427
|
+
}
|
|
1428
|
+
const updateRequestFile = () => path.join(globalDir(), 'update-request.json');
|
|
1429
|
+
app.get('/api/update/status', async (c) => {
|
|
1411
1430
|
const forbidden = require(c, 'chat');
|
|
1412
1431
|
if (forbidden) return forbidden;
|
|
1413
1432
|
const s = loadUpdateSettings(globalDir());
|
|
1433
|
+
const fresh = c.req.query('fresh') === '1';
|
|
1434
|
+
const latest = await latestVersionCached(s.channel, { fresh });
|
|
1414
1435
|
return c.json({
|
|
1415
1436
|
current: APP_VERSION,
|
|
1437
|
+
latest: latest || null,
|
|
1438
|
+
available: isNewer(latest, APP_VERSION),
|
|
1416
1439
|
enabled: s.enabled,
|
|
1417
1440
|
channel: s.channel,
|
|
1418
1441
|
lastCheckAt: s.lastCheckAt || null,
|
|
1419
1442
|
lastUpdate: s.lastUpdate || null,
|
|
1443
|
+
applying: existsSync(updateRequestFile()),
|
|
1420
1444
|
});
|
|
1421
1445
|
});
|
|
1422
1446
|
|
|
1447
|
+
// Apply an update from the UI (the one-tap nudge / ⋯More control). chatWrite-
|
|
1448
|
+
// gated (owner action). We do NOT install in-process — the always-on supervisor
|
|
1449
|
+
// owns install + restart + health-gated rollback. We drop an `update-request`
|
|
1450
|
+
// signal the supervisor consumes on its next tick (the SAME out-of-band channel
|
|
1451
|
+
// as the support `restart-server` action). If no supervisor owns this server (a
|
|
1452
|
+
// bare foreground run), the file just waits until always-on next runs — the
|
|
1453
|
+
// client polls /api/health and times out honestly. Returns immediately; the
|
|
1454
|
+
// client then watches /api/health for the version to change and reloads.
|
|
1455
|
+
app.post('/api/update/apply', async (c) => {
|
|
1456
|
+
const forbidden = require(c, 'chatWrite');
|
|
1457
|
+
if (forbidden) return forbidden;
|
|
1458
|
+
const s = loadUpdateSettings(globalDir());
|
|
1459
|
+
const latest = await latestVersionCached(s.channel, { fresh: true, fallback: false });
|
|
1460
|
+
if (!latest) return c.json({ ok: false, error: 'registry_unreachable', current: APP_VERSION });
|
|
1461
|
+
if (!isNewer(latest, APP_VERSION)) {
|
|
1462
|
+
return c.json({ ok: true, updated: false, current: APP_VERSION, latest });
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
mkdirSync(globalDir(), { recursive: true });
|
|
1466
|
+
writeFileSync(
|
|
1467
|
+
updateRequestFile(),
|
|
1468
|
+
JSON.stringify({ requestedAt: Date.now(), from: APP_VERSION, to: latest }, null, 2),
|
|
1469
|
+
{ mode: 0o600 },
|
|
1470
|
+
);
|
|
1471
|
+
} catch (e) {
|
|
1472
|
+
return c.json({ ok: false, error: 'signal_failed', message: String(e?.message || e) }, 500);
|
|
1473
|
+
}
|
|
1474
|
+
log('[update]', `apply requested ${APP_VERSION} → ${latest} (${s.channel})`);
|
|
1475
|
+
return c.json({ ok: true, requested: true, current: APP_VERSION, latest });
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1423
1478
|
// Support consent + audit (Phase 3, Pillar E) — the user's view of the
|
|
1424
1479
|
// out-of-band support channel: is support allowed (tier/expiry), was it acting
|
|
1425
1480
|
// recently, and a feed of everything it did (the daemon writes the audit; this
|
|
@@ -191,6 +191,10 @@ export class WorkspaceSupervisor {
|
|
|
191
191
|
// out-of-band even when :5173 is wedged. We kill the child; the next tick
|
|
192
192
|
// respawns it from disk (new code loads). Safe: absent file = no-op.
|
|
193
193
|
this.restartRequestFile = path.join(globalDir, 'restart-request.json');
|
|
194
|
+
// The in-app "Update now" (and the support update-now) drops this file; we
|
|
195
|
+
// consume it on the next tick and run a FORCED update check (install +
|
|
196
|
+
// restart + health-gated rollback via the AutoUpdater). Absent file = no-op.
|
|
197
|
+
this.updateRequestFile = path.join(globalDir, 'update-request.json');
|
|
194
198
|
this.child = null;
|
|
195
199
|
this.backoff = backoffStartMs;
|
|
196
200
|
this.lastSpawn = 0;
|
|
@@ -268,6 +272,21 @@ export class WorkspaceSupervisor {
|
|
|
268
272
|
return true;
|
|
269
273
|
}
|
|
270
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Consume a pending "update now" request (the in-app nudge / ⋯More control, or
|
|
277
|
+
* the support update-now). Read-then-delete makes "present" mean "unhandled" —
|
|
278
|
+
* idempotent across ticks. Mirrors consumeRestartRequest().
|
|
279
|
+
*/
|
|
280
|
+
consumeUpdateRequest() {
|
|
281
|
+
try {
|
|
282
|
+
fs.readFileSync(this.updateRequestFile); // throws if absent
|
|
283
|
+
} catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
try { fs.unlinkSync(this.updateRequestFile); } catch { /* best-effort */ }
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
271
290
|
/** One supervision step. Returns its decision (exposed for tests). */
|
|
272
291
|
async tick() {
|
|
273
292
|
// Phase 3.2: a consented support restart request takes priority — kill the
|
|
@@ -277,6 +296,16 @@ export class WorkspaceSupervisor {
|
|
|
277
296
|
this.restartChild();
|
|
278
297
|
return 'restart-requested';
|
|
279
298
|
}
|
|
299
|
+
// An in-app / support "update now" request: run a FORCED update check + apply
|
|
300
|
+
// (install + restart + health-gated rollback, via the AutoUpdater). Only when
|
|
301
|
+
// the updater is already built — otherwise leave the file for a later tick so
|
|
302
|
+
// an early-boot request isn't silently dropped. The `&&` short-circuit means
|
|
303
|
+
// consumeUpdateRequest() (which deletes the file) only runs once we can act.
|
|
304
|
+
if (this.autoUpdater && this.consumeUpdateRequest()) {
|
|
305
|
+
this.log('update-now requested — running forced update check');
|
|
306
|
+
this.runUpdateTick({ force: true });
|
|
307
|
+
return 'update-requested';
|
|
308
|
+
}
|
|
280
309
|
// Part-8 backstop: if disk moved ahead of our own code (any update path),
|
|
281
310
|
// schedule a supervisor self-restart. Side-effect only — never changes the
|
|
282
311
|
// tick decision below (server/daemon healing proceeds as usual meanwhile).
|