@venturewild/workspace 0.6.5 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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 channel, on/off, and the
1408
- // last update outcome (the "updated to vX" note the UI can surface). Read-only;
1409
- // the toggle/apply levers are the CLI + the operator channel.
1410
- app.get('/api/update/status', (c) => {
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).