@venturewild/workspace 0.3.4 → 0.3.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 +1 -1
- package/server/src/auto-update.mjs +7 -3
- package/server/src/canvas/core.mjs +98 -1
- package/server/src/daemon-supervisor.mjs +55 -1
- package/server/src/index.mjs +16 -0
- package/server/src/supervisor.mjs +6 -3
- package/web/dist/assets/index-B7cOsWLt.js +91 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-DatlFPkm.js +0 -91
package/package.json
CHANGED
|
@@ -255,12 +255,16 @@ export class AutoUpdater {
|
|
|
255
255
|
/**
|
|
256
256
|
* One auto-update cycle, called on the supervisor's slow timer. Self-rate-limits
|
|
257
257
|
* via dueForCheck so the timer cadence and the check interval are independent.
|
|
258
|
-
*
|
|
258
|
+
* `force` bypasses dueForCheck — used by the supervisor's post-boot kick so a
|
|
259
|
+
* RESTART always checks for updates immediately (a reboot is a rare, deliberate
|
|
260
|
+
* signal; without this a reboot within the 6h window never re-checked — why a
|
|
261
|
+
* sleepy/rebooted Mac could sit on a stale version). Returns a short status
|
|
262
|
+
* string (exposed for tests/logging).
|
|
259
263
|
*/
|
|
260
|
-
async tick() {
|
|
264
|
+
async tick({ force = false } = {}) {
|
|
261
265
|
if (this.inProgress) return 'busy';
|
|
262
266
|
if (!this.enabled()) return 'disabled';
|
|
263
|
-
if (!this.dueForCheck()) return 'not-due';
|
|
267
|
+
if (!force && !this.dueForCheck()) return 'not-due';
|
|
264
268
|
this.inProgress = true;
|
|
265
269
|
try {
|
|
266
270
|
touchLastCheck(this.globalDir, this.nowImpl());
|
|
@@ -211,6 +211,12 @@ export function createCanvas({ baseDir } = {}) {
|
|
|
211
211
|
const dir = baseDir || defaultCanvasDir();
|
|
212
212
|
const blocksFile = path.join(dir, 'blocks.json');
|
|
213
213
|
const themeFile = path.join(dir, 'theme.json');
|
|
214
|
+
// Server-side persistence for canvas state (layout, templates, user-theme).
|
|
215
|
+
// Fixes the localStorage-per-origin divergence bug (req-1 gave the user two
|
|
216
|
+
// origins → two divergent canvas states for the same workspace).
|
|
217
|
+
const layoutFile = path.join(dir, 'layout.json');
|
|
218
|
+
const templatesFile = path.join(dir, 'templates.json');
|
|
219
|
+
const userThemeFile = path.join(dir, 'user-theme.json');
|
|
214
220
|
|
|
215
221
|
function ensureDir() {
|
|
216
222
|
try {
|
|
@@ -298,10 +304,101 @@ export function createCanvas({ baseDir } = {}) {
|
|
|
298
304
|
return theme;
|
|
299
305
|
}
|
|
300
306
|
|
|
307
|
+
// --- canvas layout (which blocks exist and where) — one file per workspace ----
|
|
308
|
+
|
|
309
|
+
function getLayout() {
|
|
310
|
+
const v = readJsonSafe(layoutFile, null);
|
|
311
|
+
return v && typeof v === 'object' ? v : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function saveLayout(layout) {
|
|
315
|
+
// Validate: blocks is an array, layouts has an lg array.
|
|
316
|
+
const blocks = Array.isArray(layout?.blocks) ? layout.blocks : [];
|
|
317
|
+
const lg = Array.isArray(layout?.layouts?.lg) ? layout.layouts.lg : [];
|
|
318
|
+
const data = { blocks, layouts: { lg }, ts: Date.now() };
|
|
319
|
+
ensureDir();
|
|
320
|
+
try { writeJsonAtomic(layoutFile, data); } catch { /* best-effort */ }
|
|
321
|
+
return data;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- user templates (saved layouts) — global per install ----
|
|
325
|
+
|
|
326
|
+
function getTemplates() {
|
|
327
|
+
const v = readJsonSafe(templatesFile, null);
|
|
328
|
+
return Array.isArray(v) ? v : [];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function saveTemplates(templates) {
|
|
332
|
+
const data = Array.isArray(templates) ? templates : [];
|
|
333
|
+
ensureDir();
|
|
334
|
+
try { writeJsonAtomic(templatesFile, data); } catch { /* best-effort */ }
|
|
335
|
+
return data;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// --- user theme (mode + accent from the picker) — separate from the agent theme ---
|
|
339
|
+
// The agent theme (theme.json) holds tokens/wallpaper set by set_theme; the user
|
|
340
|
+
// theme holds the user's own mode+accent choice from the ThemePicker. They merge
|
|
341
|
+
// at render time (mergeAgentTheme in theme.js).
|
|
342
|
+
|
|
343
|
+
function getUserTheme() {
|
|
344
|
+
const v = readJsonSafe(userThemeFile, null);
|
|
345
|
+
return v && typeof v === 'object' ? v : null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function saveUserTheme(raw) {
|
|
349
|
+
// Only persist allowlisted fields — mode, accent, hot, accentManual, tokens.
|
|
350
|
+
const accent = hexOr(raw?.accent);
|
|
351
|
+
const theme = {
|
|
352
|
+
mode: raw?.mode === 'dark' ? 'dark' : 'light',
|
|
353
|
+
accent: accent || '#0891b2',
|
|
354
|
+
hot: hexOr(raw?.hot) || (accent ? darkenHex(accent) : '#0e7490'),
|
|
355
|
+
accentManual: !!raw?.accentManual,
|
|
356
|
+
tokens: {},
|
|
357
|
+
};
|
|
358
|
+
if (raw?.tokens && typeof raw.tokens === 'object') {
|
|
359
|
+
for (const key of TOKEN_KEYS) {
|
|
360
|
+
const hex = hexOr(raw.tokens[key]);
|
|
361
|
+
if (hex) theme.tokens[key] = hex;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
ensureDir();
|
|
365
|
+
try { writeJsonAtomic(userThemeFile, theme); } catch { /* best-effort */ }
|
|
366
|
+
return theme;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Darken helper (standalone — can't import from theme.js which is client-only)
|
|
370
|
+
function darkenHex(hex, f = 0.16) {
|
|
371
|
+
const h = hex.replace('#', '');
|
|
372
|
+
const k = 1 - f;
|
|
373
|
+
const c = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
|
|
374
|
+
return `#${[0, 2, 4].map((i) => c(i).toString(16).padStart(2, '0')).join('')}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- batch get/save (used by /api/canvas/state) ---
|
|
378
|
+
|
|
379
|
+
function getState() {
|
|
380
|
+
return {
|
|
381
|
+
layout: getLayout(),
|
|
382
|
+
templates: getTemplates(),
|
|
383
|
+
userTheme: getUserTheme(),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function saveState(updates) {
|
|
388
|
+
const result = {};
|
|
389
|
+
if (updates.layout) result.layout = saveLayout(updates.layout);
|
|
390
|
+
if (updates.templates) result.templates = saveTemplates(updates.templates);
|
|
391
|
+
if (updates.userTheme) result.userTheme = saveUserTheme(updates.userTheme);
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
|
|
301
395
|
return {
|
|
302
|
-
dir, blocksFile, themeFile,
|
|
396
|
+
dir, blocksFile, themeFile, layoutFile, templatesFile, userThemeFile,
|
|
303
397
|
listBlocks, getBlock, addBlock, updateBlock, removeBlock,
|
|
304
398
|
getTheme, setTheme,
|
|
399
|
+
getLayout, saveLayout, getTemplates, saveTemplates,
|
|
400
|
+
getUserTheme, saveUserTheme,
|
|
401
|
+
getState, saveState,
|
|
305
402
|
};
|
|
306
403
|
}
|
|
307
404
|
|
|
@@ -65,6 +65,9 @@ export class DaemonSupervisor {
|
|
|
65
65
|
this.globalDir = globalDir;
|
|
66
66
|
this.pidFile = path.join(globalDir, 'daemon.pid');
|
|
67
67
|
this.logFile = path.join(globalDir, 'daemon.log');
|
|
68
|
+
// Serializes concurrent ensureRunning() spawns (server + supervisor) so only
|
|
69
|
+
// one daemon is started — prevents the duplicate-daemon :8320 bind conflict.
|
|
70
|
+
this.spawnLockFile = path.join(globalDir, 'daemon-spawn.lock');
|
|
68
71
|
this.resolveBinary = resolveBinary;
|
|
69
72
|
this.spawnImpl = spawnImpl;
|
|
70
73
|
this.fetchImpl = fetchImpl;
|
|
@@ -89,6 +92,14 @@ export class DaemonSupervisor {
|
|
|
89
92
|
/**
|
|
90
93
|
* Start the daemon unless it is already up. Idempotent and best-effort —
|
|
91
94
|
* the result is reported, never thrown.
|
|
95
|
+
*
|
|
96
|
+
* Concurrency-safe: the daemon is ensured by BOTH the server (its DaemonBridge)
|
|
97
|
+
* and the always-on supervisor (daemonTick). At boot neither sees the daemon up
|
|
98
|
+
* yet, so without a guard they'd BOTH spawn → two daemons fighting over :8320
|
|
99
|
+
* (`Address already in use`) and both opening proxy links → the relay evicts one
|
|
100
|
+
* → reconnect churn. An atomic cross-process spawn lock serializes the decision:
|
|
101
|
+
* the winner re-checks health then spawns; the loser waits for the winner's
|
|
102
|
+
* daemon instead of spawning its own.
|
|
92
103
|
* @returns {Promise<{started:boolean, alreadyRunning?:boolean, pid?:number,
|
|
93
104
|
* error?:string}>}
|
|
94
105
|
*/
|
|
@@ -96,7 +107,50 @@ export class DaemonSupervisor {
|
|
|
96
107
|
if ((await this.health()).running) {
|
|
97
108
|
return { started: false, alreadyRunning: true };
|
|
98
109
|
}
|
|
99
|
-
|
|
110
|
+
if (!this.acquireSpawnLock()) {
|
|
111
|
+
// Another process (server bridge / always-on supervisor) is spawning —
|
|
112
|
+
// wait for ITS daemon rather than racing in a second one.
|
|
113
|
+
const up = await this.waitForHealthy(4000);
|
|
114
|
+
return up ? { started: false, alreadyRunning: true } : { started: false, error: 'spawn-in-progress' };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
// Re-check under the lock: the other caller may have just brought it up.
|
|
118
|
+
if ((await this.health()).running) {
|
|
119
|
+
return { started: false, alreadyRunning: true };
|
|
120
|
+
}
|
|
121
|
+
return this.spawnDaemon();
|
|
122
|
+
} finally {
|
|
123
|
+
this.releaseSpawnLock();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Is a pid alive? EPERM ("exists, not ours") still counts as alive. */
|
|
128
|
+
pidAlive(pid) {
|
|
129
|
+
try { this.killImpl(pid, 0); return true; } catch (e) { return !!(e && e.code === 'EPERM'); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Atomic, cross-process spawn lock (shared globalDir, so it serializes the
|
|
134
|
+
* server and the supervisor). Returns true iff we hold it. A stale lock whose
|
|
135
|
+
* recorded pid is dead is taken over — a crash mid-spawn can't wedge it.
|
|
136
|
+
*/
|
|
137
|
+
acquireSpawnLock() {
|
|
138
|
+
try { mkdirSync(this.globalDir, { recursive: true }); } catch { /* surfaced below */ }
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(this.spawnLockFile, String(process.pid), { flag: 'wx' }); // atomic exclusive create
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
let holder = null;
|
|
144
|
+
try { holder = Number(readFileSync(this.spawnLockFile, 'utf8').trim()); } catch { /* unreadable */ }
|
|
145
|
+
if (holder && this.pidAlive(holder)) return false; // a live caller is spawning
|
|
146
|
+
try { writeFileSync(this.spawnLockFile, String(process.pid)); return true; } catch { return false; }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
releaseSpawnLock() {
|
|
151
|
+
try {
|
|
152
|
+
if (Number(readFileSync(this.spawnLockFile, 'utf8').trim()) === process.pid) unlinkSync(this.spawnLockFile);
|
|
153
|
+
} catch { /* already gone */ }
|
|
100
154
|
}
|
|
101
155
|
|
|
102
156
|
/** Spawn the daemon detached + window-hidden, logging to `daemon.log`. */
|
package/server/src/index.mjs
CHANGED
|
@@ -1801,6 +1801,22 @@ export async function createServer(overrides = {}) {
|
|
|
1801
1801
|
return c.json({ theme: canvas.getTheme() });
|
|
1802
1802
|
});
|
|
1803
1803
|
|
|
1804
|
+
// Canvas state — the source of truth for layout, templates, and user-theme.
|
|
1805
|
+
// Fixes the localStorage-per-origin divergence (req-1 gave two origins for the
|
|
1806
|
+
// same workspace → divergent canvas). Server stores the canonical state; client
|
|
1807
|
+
// uses localStorage as a read-through cache.
|
|
1808
|
+
app.get('/api/canvas/state', (c) => {
|
|
1809
|
+
const forbidden = require(c, 'chat');
|
|
1810
|
+
if (forbidden) return forbidden;
|
|
1811
|
+
return c.json(canvas.getState());
|
|
1812
|
+
});
|
|
1813
|
+
app.post('/api/canvas/state', async (c) => {
|
|
1814
|
+
const forbidden = require(c, 'chatWrite');
|
|
1815
|
+
if (forbidden) return forbidden;
|
|
1816
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1817
|
+
return c.json({ ok: true, ...canvas.saveState(body) });
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1804
1820
|
// The built site, served SAME-ORIGIN through this (already authed) server — no
|
|
1805
1821
|
// squatted dev port, no mixed-content under the public proxy. The build dir
|
|
1806
1822
|
// comes from preview.json (written by the agent's launch_preview / record_use).
|
|
@@ -503,9 +503,9 @@ export class WorkspaceSupervisor {
|
|
|
503
503
|
});
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
runUpdateTick() {
|
|
506
|
+
runUpdateTick(opts = {}) {
|
|
507
507
|
if (!this.autoUpdater) return;
|
|
508
|
-
this.autoUpdater.tick()
|
|
508
|
+
this.autoUpdater.tick(opts)
|
|
509
509
|
.then((r) => { if (r && !['not-due', 'disabled', 'up-to-date', 'busy'].includes(r)) this.log(`auto-update tick: ${r}`); })
|
|
510
510
|
.catch((e) => this.log(`auto-update error: ${e?.message || e}`));
|
|
511
511
|
}
|
|
@@ -617,7 +617,10 @@ export class WorkspaceSupervisor {
|
|
|
617
617
|
this.autoUpdater = u;
|
|
618
618
|
this.updateTimer = setInterval(() => this.runUpdateTick(), this.updatePollMs);
|
|
619
619
|
if (this.updateTimer.unref) this.updateTimer.unref();
|
|
620
|
-
|
|
620
|
+
// FORCE the first post-boot check (bypass the 6h dueForCheck): a restart
|
|
621
|
+
// should always pull the latest, so a rebooted/woken machine can't sit on
|
|
622
|
+
// a stale version just because it last checked <6h ago.
|
|
623
|
+
const kick = setTimeout(() => this.runUpdateTick({ force: true }), 60_000);
|
|
621
624
|
if (kick.unref) kick.unref();
|
|
622
625
|
}).catch((e) => this.log(`auto-update init error: ${e?.message || e}`));
|
|
623
626
|
}
|