workbite 0.1.12 → 0.1.14
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 -8
- package/lib/agent.js +47 -17
- package/lib/archive.js +110 -9
- package/lib/cli.js +79 -7
- package/lib/diagnostics.js +234 -0
- package/lib/install.js +44 -4
- package/lib/mcp.js +133 -22
- package/lib/runtime.js +188 -91
- package/lib/startup-lock.js +71 -0
- package/lib/transfer.js +113 -0
- package/package.json +1 -1
- package/release.json +8 -15
- package/skills/workbite-native/SKILL.md +18 -3
- package/skills/workbite-native/references/commands.md +26 -12
- package/skills/workbite-native/references/task-fields.md +208 -0
- package/skills/workbite-native/references/transfer.md +204 -0
package/lib/runtime.js
CHANGED
|
@@ -9,10 +9,12 @@ const fsSync = require('node:fs');
|
|
|
9
9
|
const http = require('node:http');
|
|
10
10
|
const net = require('node:net');
|
|
11
11
|
const path = require('node:path');
|
|
12
|
+
const { performance } = require('node:perf_hooks');
|
|
12
13
|
const { spawn, execFile } = require('node:child_process');
|
|
13
14
|
const { randomBytes, randomUUID } = require('node:crypto');
|
|
14
15
|
const { fail, exists, safeDirectory, writeAtomic, readJson, readSmall, sha256, UUID, BACKEND_ID } = require('./common');
|
|
15
16
|
const { bootIdentity, validBoot, previousBoot } = require('./boot-provenance');
|
|
17
|
+
const { withLock } = require('./startup-lock');
|
|
16
18
|
const { BUNDLE_V2 } = require('./install');
|
|
17
19
|
const { protectPrivateDirectories } = require('./private-state');
|
|
18
20
|
|
|
@@ -107,24 +109,54 @@ function boundedText(text) {
|
|
|
107
109
|
const line = String(text).split(/\r?\n/).map(value => value.trim()).filter(Boolean).pop() || '';
|
|
108
110
|
return line.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 300);
|
|
109
111
|
}
|
|
110
|
-
|
|
112
|
+
const finiteTimeout = (value, fallback) => Number.isFinite(value) && value > 0 ? value : fallback;
|
|
113
|
+
const remaining = deadline => Math.max(0, deadline - performance.now());
|
|
114
|
+
// Bounds observation, not kernel filesystem I/O or the lifetime of an owned backend.
|
|
115
|
+
async function beforeDeadline(action, deadline, message) {
|
|
116
|
+
const left = remaining(deadline);
|
|
117
|
+
if (!left) throw new Error(message);
|
|
118
|
+
let timer;
|
|
119
|
+
try {
|
|
120
|
+
const result = await Promise.race([Promise.resolve().then(action), new Promise((_, reject) => {
|
|
121
|
+
timer = setTimeout(() => reject(new Error(message)), left);
|
|
122
|
+
})]);
|
|
123
|
+
if (!remaining(deadline)) throw new Error(message);
|
|
124
|
+
return result;
|
|
125
|
+
} finally { clearTimeout(timer); }
|
|
126
|
+
}
|
|
127
|
+
function runOnce(file, args, environment, timeout = 120000, execImpl = execFile, options = {}) {
|
|
128
|
+
timeout = finiteTimeout(timeout, 120000);
|
|
111
129
|
return new Promise(resolve => {
|
|
112
|
-
|
|
113
|
-
|
|
130
|
+
let child, settled = false;
|
|
131
|
+
const finish = (error, stdout, stderr) => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
settled = true;
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
resolve({ error, stdout: stdout || '', stderr: stderr || '' });
|
|
136
|
+
};
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
// A kill request is not proof of exit. Refuse with an unknown outcome; never retry.
|
|
139
|
+
finish(Object.assign(new Error('startup command timed out; outcome unknown'), { killed: true, code: 'ETIMEDOUT' }));
|
|
140
|
+
try { child?.kill('SIGKILL'); } catch { /* outcome remains unknown */ }
|
|
141
|
+
}, timeout);
|
|
142
|
+
try {
|
|
143
|
+
child = execImpl(file, args, { ...options, env: environment, shell: false, windowsHide: true, timeout,
|
|
144
|
+
maxBuffer: options.maxBuffer || 1024 * 1024, encoding: 'utf8' }, finish);
|
|
145
|
+
} catch (error) { finish(error); }
|
|
114
146
|
});
|
|
115
147
|
}
|
|
116
|
-
async function initializeWorkspace(commands, workspace, environment) {
|
|
148
|
+
async function initializeWorkspace(commands, workspace, environment, { timeout, execImpl } = {}) {
|
|
117
149
|
if (await exists(path.join(workspace, 'workspace.json'))) fail('This folder is already a Work Stack workspace; open it without --init.');
|
|
118
150
|
if (!await exists(workspace)) {
|
|
119
151
|
try { await fs.mkdir(workspace, { mode: 0o700 }); } catch { fail('Cannot create the workspace folder; its parent folder must exist.'); }
|
|
120
152
|
}
|
|
121
153
|
const uid = randomUUID();
|
|
122
154
|
const [file, ...prefix] = commands.workstack_engine;
|
|
123
|
-
const result = await runOnce(file, [...prefix, 'init', '--workspace', workspace, '--workspace-uid', uid], environment);
|
|
155
|
+
const result = await runOnce(file, [...prefix, 'init', '--workspace', workspace, '--workspace-uid', uid], environment, timeout, execImpl);
|
|
124
156
|
let record;
|
|
125
157
|
try { record = JSON.parse(result.stdout.trim()); } catch { record = null; }
|
|
126
158
|
if (result.error || record?.state !== 'initialized' || record.workspace_uid !== uid) {
|
|
127
|
-
fail(`Work Stack init refused this folder: ${boundedText(result.stderr) || 'no ready record'}`);
|
|
159
|
+
fail(`Work Stack init refused this folder: ${result.error?.killed ? 'timed out; outcome unknown; inspect the workspace before opening again' : boundedText(result.stderr) || 'no ready record'}`);
|
|
128
160
|
}
|
|
129
161
|
return uid;
|
|
130
162
|
}
|
|
@@ -185,31 +217,47 @@ function request(url, { method = 'GET', headers = {}, body, timeout = 5000 } = {
|
|
|
185
217
|
const match = LOOPBACK.exec(url.origin);
|
|
186
218
|
if (!match) return Promise.reject(new Error('not a loopback origin'));
|
|
187
219
|
return new Promise((resolve, reject) => {
|
|
220
|
+
timeout = finiteTimeout(timeout, 5000);
|
|
221
|
+
let timer, responseStream, settled = false;
|
|
222
|
+
const finish = (error, value) => {
|
|
223
|
+
if (settled) return;
|
|
224
|
+
settled = true;
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
if (error) reject(error); else resolve(value);
|
|
227
|
+
};
|
|
188
228
|
const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
|
|
189
|
-
const req = http.request(url, { method, agent: false,
|
|
229
|
+
const req = http.request(url, { method, agent: false,
|
|
190
230
|
headers: { ...headers, ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {}) } },
|
|
191
231
|
response => {
|
|
232
|
+
responseStream = response;
|
|
192
233
|
const chunks = [];
|
|
193
234
|
let size = 0;
|
|
194
|
-
response.on('data', chunk => {
|
|
235
|
+
response.on('data', chunk => {
|
|
236
|
+
size += chunk.length;
|
|
237
|
+
if (size > 1024 * 1024) abort(new Error('response too large')); else chunks.push(chunk);
|
|
238
|
+
});
|
|
195
239
|
response.on('end', () => {
|
|
196
240
|
let parsed = null;
|
|
197
241
|
try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { parsed = null; }
|
|
198
|
-
|
|
242
|
+
finish(null, { status: response.statusCode, body: parsed });
|
|
199
243
|
});
|
|
200
|
-
response.on('error',
|
|
244
|
+
response.on('error', error => finish(error));
|
|
245
|
+
response.on('aborted', () => finish(new Error('response aborted')));
|
|
246
|
+
response.on('close', () => { if (!response.complete) finish(new Error('response closed before completion')); });
|
|
201
247
|
});
|
|
202
|
-
|
|
203
|
-
|
|
248
|
+
const abort = error => { finish(error); responseStream?.destroy(); req.destroy(); };
|
|
249
|
+
// A socket inactivity timeout can be extended forever by trickled bytes.
|
|
250
|
+
timer = setTimeout(() => abort(new Error('request timed out')), timeout);
|
|
251
|
+
req.on('error', error => finish(error));
|
|
204
252
|
req.end(payload);
|
|
205
253
|
});
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
// Owner identity is proven only by the private lifecycle token this WorkBite generated.
|
|
209
|
-
async function engineIdentity(endpoint, token, workspaceUid) {
|
|
257
|
+
async function engineIdentity(endpoint, token, workspaceUid, { timeout = 5000 } = {}) {
|
|
210
258
|
if (typeof endpoint !== 'string' || !LOOPBACK.test(endpoint) || typeof token !== 'string' || !token) return false;
|
|
211
259
|
try {
|
|
212
|
-
const answer = await request(new URL('/api/v1/native-runtime/identity', endpoint), { headers: { 'X-WorkStack-Native-Token': token } });
|
|
260
|
+
const answer = await request(new URL('/api/v1/native-runtime/identity', endpoint), { timeout, headers: { 'X-WorkStack-Native-Token': token } });
|
|
213
261
|
return answer.status === 200 && answer.body?.data?.workspace_uid === workspaceUid && answer.body.data.protocol_version === 1;
|
|
214
262
|
} catch { return false; }
|
|
215
263
|
}
|
|
@@ -222,12 +270,14 @@ async function stackbiteToken(stateDir) {
|
|
|
222
270
|
}
|
|
223
271
|
return null;
|
|
224
272
|
}
|
|
225
|
-
async function stackbiteIdentity(url, stateDir, backendId) {
|
|
273
|
+
async function stackbiteIdentity(url, stateDir, backendId, { timeout = 5000 } = {}) {
|
|
274
|
+
const deadline = performance.now() + finiteTimeout(timeout, 5000);
|
|
226
275
|
if (typeof url !== 'string' || !LOOPBACK.test(url)) return false;
|
|
227
|
-
const token = await stackbiteToken(stateDir);
|
|
228
|
-
if (!token) return false;
|
|
229
276
|
try {
|
|
230
|
-
const
|
|
277
|
+
const token = await beforeDeadline(() => stackbiteToken(stateDir), deadline, 'token read timed out');
|
|
278
|
+
if (!token) return false;
|
|
279
|
+
if (!remaining(deadline)) return false;
|
|
280
|
+
const answer = await request(new URL('/api/backend/status', url), { timeout: remaining(deadline), headers: { 'X-Office-Token': token } });
|
|
231
281
|
return answer.status === 200 && answer.body?.status === 'ok' && answer.body.backend_id === backendId;
|
|
232
282
|
} catch { return false; }
|
|
233
283
|
}
|
|
@@ -258,17 +308,19 @@ function spawnDetached(command, args, { env, cwd, log, spawnImpl = spawn }) {
|
|
|
258
308
|
return { child, exited };
|
|
259
309
|
} finally { fsSync.closeSync(output); }
|
|
260
310
|
}
|
|
261
|
-
async function waitFor(probe, exited, timeout, what, log) {
|
|
311
|
+
async function waitFor(probe, exited, timeout, what, log, deadline = performance.now() + timeout) {
|
|
262
312
|
let ended = false;
|
|
263
313
|
exited.then(() => { ended = true; });
|
|
264
|
-
const
|
|
265
|
-
while (
|
|
266
|
-
const value = await probe()
|
|
314
|
+
const unknown = `${what} did not become ready in time; it was left running; startup outcome unknown, intent retained. See ${log}`;
|
|
315
|
+
while (remaining(deadline)) {
|
|
316
|
+
const value = await beforeDeadline(() => Promise.race([probe(remaining(deadline)), exited.then(() => {
|
|
317
|
+
fail(`${what} exited during startup; see ${log}`);
|
|
318
|
+
})]), deadline, unknown);
|
|
267
319
|
if (value) return value;
|
|
268
320
|
if (ended) fail(`${what} exited during startup; see ${log}`);
|
|
269
|
-
await new Promise(resolve => setTimeout(resolve, 150));
|
|
321
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(150, remaining(deadline))));
|
|
270
322
|
}
|
|
271
|
-
fail(
|
|
323
|
+
fail(unknown);
|
|
272
324
|
}
|
|
273
325
|
async function readyRecord(log) {
|
|
274
326
|
let text;
|
|
@@ -324,10 +376,14 @@ async function archivePreviousRecord(file, saved, original, boot) {
|
|
|
324
376
|
}
|
|
325
377
|
return true;
|
|
326
378
|
}
|
|
327
|
-
function checkBoot(saved, recordPath) {
|
|
379
|
+
function checkBoot(saved, recordPath, boot) {
|
|
328
380
|
if (Object.hasOwn(saved, 'boot') && !validBoot(saved.boot)) {
|
|
329
381
|
fail(`The startup boot provenance in ${recordPath} is invalid; WorkBite does not replace it.`);
|
|
330
382
|
}
|
|
383
|
+
if (validBoot(saved.boot) && validBoot(boot) && saved.boot.host !== boot.host) {
|
|
384
|
+
fail(`The startup record ${recordPath} belongs to a foreign host; its process state is unknown and could not be authenticated locally. `
|
|
385
|
+
+ 'No local endpoint or PID was probed; the record was retained. ' + RECOVERY);
|
|
386
|
+
}
|
|
331
387
|
}
|
|
332
388
|
// A v2 (execution UI) bundle reuses only processes its own startup intent marked: the owner needs
|
|
333
389
|
// this bundle's execution commands, the StackBite backend its native launch opt-in (read once at
|
|
@@ -352,6 +408,7 @@ const executionFields = (execution, extra = {}) => (execution ? { execution_bund
|
|
|
352
408
|
const NATIVE_LAUNCH = { native_launch: true };
|
|
353
409
|
|
|
354
410
|
async function ensureEngine({ commands, workspace, uid, layout, environment, spawnImpl, timeout = 60000, execution = null, boot = null, identityImpl = engineIdentity }) {
|
|
411
|
+
timeout = finiteTimeout(timeout, 60000);
|
|
355
412
|
const key = workspaceKey(workspace);
|
|
356
413
|
const recordPath = path.join(layout.owners, `${key}.json`);
|
|
357
414
|
const logFor = launch => path.join(layout.logs, `workstack-engine-${key}-${launch}.log`);
|
|
@@ -365,14 +422,15 @@ async function ensureEngine({ commands, workspace, uid, layout, environment, spa
|
|
|
365
422
|
|| (saved.state === 'ready' && (typeof saved.endpoint !== 'string' || !LOOPBACK.test(saved.endpoint)))) {
|
|
366
423
|
fail(`The Work Stack owner record ${recordPath} is invalid for this workspace; WorkBite does not replace it. ${RECOVERY}`);
|
|
367
424
|
}
|
|
368
|
-
checkBoot(saved, recordPath);
|
|
425
|
+
checkBoot(saved, recordPath, boot);
|
|
369
426
|
requireExecutionMarker(saved, execution, recordPath, 'Work Stack owner');
|
|
370
427
|
let endpoint = saved.state === 'ready' ? saved.endpoint : null;
|
|
371
428
|
if (saved.state === 'starting') {
|
|
372
429
|
const ready = await readyRecord(logFor(saved.launch));
|
|
373
430
|
if (ready?.workspace_uid === uid && ready.protocol_version === 1) endpoint = ready.endpoint;
|
|
374
431
|
}
|
|
375
|
-
if (!await identityImpl(endpoint, saved.token, uid))
|
|
432
|
+
if (!await beforeDeadline(() => identityImpl(endpoint, saved.token, uid, { timeout: Math.min(5000, timeout) }),
|
|
433
|
+
performance.now() + timeout, `Work Stack owner authentication timed out; outcome unknown; record retained: ${recordPath}; logs: ${layout.logs}`)) {
|
|
376
434
|
if (!await archivePreviousRecord(recordPath, saved, original, boot))
|
|
377
435
|
fail(`The Work Stack owner recorded in ${recordPath} could not be authenticated (still starting, or stopped). ${RECOVERY}`);
|
|
378
436
|
} else {
|
|
@@ -390,14 +448,18 @@ async function ensureEngine({ commands, workspace, uid, layout, environment, spa
|
|
|
390
448
|
['serve', '--workspace', workspace, '--workspace-uid', uid, '--host', '127.0.0.1', '--port', '0', '--native-runtime-control'],
|
|
391
449
|
{ env: { ...environment, WORKSTACK_NATIVE_RUNTIME_TOKEN: intent.token }, cwd: layout.root, log, spawnImpl });
|
|
392
450
|
await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid }));
|
|
393
|
-
const
|
|
451
|
+
const deadline = performance.now() + timeout;
|
|
452
|
+
const ready = await waitFor(() => readyRecord(log), exited, timeout, 'Work Stack native owner', log, deadline);
|
|
394
453
|
if (ready.workspace_uid !== uid || ready.protocol_version !== 1 || !LOOPBACK.test(ready.endpoint)
|
|
395
|
-
|| !await identityImpl(ready.endpoint, intent.token, uid
|
|
454
|
+
|| !await beforeDeadline(() => identityImpl(ready.endpoint, intent.token, uid, { timeout: Math.min(5000, remaining(deadline)) }),
|
|
455
|
+
deadline, `Work Stack native owner identity timed out; outcome unknown; intent retained: ${recordPath}; see ${log}`))
|
|
456
|
+
fail(`Work Stack native owner identity was not proven; intent retained: ${recordPath}; see ${log}`);
|
|
396
457
|
await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid, state: 'ready', endpoint: ready.endpoint }));
|
|
397
458
|
return { endpoint: ready.endpoint, reused: false, pid: child.pid };
|
|
398
459
|
}
|
|
399
460
|
|
|
400
461
|
async function ensureStackbite({ commands, layout, backendId, environment, spawnImpl, timeout = 120000, execution = null, boot = null, identityImpl = stackbiteIdentity }) {
|
|
462
|
+
timeout = finiteTimeout(timeout, 120000);
|
|
401
463
|
const recordPath = layout.stackbiteRecord;
|
|
402
464
|
const logFor = launch => path.join(layout.logs, `stackbite-backend-${launch}.log`);
|
|
403
465
|
const snapshot = await savedRecord(recordPath, 'StackBite backend');
|
|
@@ -408,9 +470,10 @@ async function ensureStackbite({ commands, layout, backendId, environment, spawn
|
|
|
408
470
|
|| typeof saved.url !== 'string' || LOOPBACK.exec(saved.url)?.[1] !== String(saved.port)) {
|
|
409
471
|
fail(`The StackBite backend record ${recordPath} is invalid for this state; WorkBite does not replace it. ${RECOVERY}`);
|
|
410
472
|
}
|
|
411
|
-
checkBoot(saved, recordPath);
|
|
473
|
+
checkBoot(saved, recordPath, boot);
|
|
412
474
|
requireExecutionMarker(saved, execution, recordPath, 'StackBite backend', NATIVE_LAUNCH);
|
|
413
|
-
if (!await identityImpl(saved.url, layout.stackbite, backendId))
|
|
475
|
+
if (!await beforeDeadline(() => identityImpl(saved.url, layout.stackbite, backendId, { timeout: Math.min(5000, timeout) }),
|
|
476
|
+
performance.now() + timeout, `StackBite backend authentication timed out; outcome unknown; record retained: ${recordPath}; logs: ${layout.logs}`)) {
|
|
414
477
|
if (!await archivePreviousRecord(recordPath, saved, original, boot))
|
|
415
478
|
fail(`The StackBite backend recorded in ${recordPath} could not be authenticated (still starting, or stopped). ${RECOVERY}`);
|
|
416
479
|
} else {
|
|
@@ -439,7 +502,8 @@ async function ensureStackbite({ commands, layout, backendId, environment, spawn
|
|
|
439
502
|
...(execution ? { STACKBITE_WORKBITE_NATIVE_LAUNCH: '1' } : {}) }, home),
|
|
440
503
|
cwd: home, log, spawnImpl });
|
|
441
504
|
await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid }));
|
|
442
|
-
await waitFor(
|
|
505
|
+
await waitFor(left => identityImpl(url, layout.stackbite, backendId, { timeout: Math.min(5000, left) }),
|
|
506
|
+
exited, timeout, 'StackBite backend', log);
|
|
443
507
|
await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid, state: 'ready' }));
|
|
444
508
|
return { url, reused: false, pid: child.pid };
|
|
445
509
|
}
|
|
@@ -453,14 +517,11 @@ const NATIVE_INIT_SCHEMA = 'stackbite.workbite-native-init.v1';
|
|
|
453
517
|
async function initializeNativeRuntime({ commands, layout, url, environment, execImpl = execFile, timeout = 120000 }) {
|
|
454
518
|
const home = path.dirname(commands.stackbite[commands.stackbite.length - 1]);
|
|
455
519
|
const [file, ...prefix] = commands.stackbite;
|
|
456
|
-
const result = await
|
|
457
|
-
|
|
458
|
-
env: withPath({ ...environment, STACKBITE_HOME: home, STACKBITE_STATE_DIR: layout.stackbite,
|
|
520
|
+
const result = await runOnce(file, [...prefix, '--workbite-native-init'],
|
|
521
|
+
withPath({ ...environment, STACKBITE_HOME: home, STACKBITE_STATE_DIR: layout.stackbite,
|
|
459
522
|
STACKBITE_BRIDGE_URL: url, STACKBITE_ROOM_BRIDGE_URL: url, STACKBITE_WORKBITE_NATIVE_LAUNCH: '1',
|
|
460
523
|
PYINSTALLER_RESET_ENVIRONMENT: '1' }, home),
|
|
461
|
-
|
|
462
|
-
}, (error, stdout, stderr) => resolve({ error, stdout: stdout || '', stderr: stderr || '' }));
|
|
463
|
-
});
|
|
524
|
+
timeout, execImpl, { cwd: home, maxBuffer: 64 * 1024 });
|
|
464
525
|
const text = result.stdout.trim();
|
|
465
526
|
let record = null;
|
|
466
527
|
if (text && !/[\r\n]/.test(text) && text.length <= 4096) { try { record = JSON.parse(text); } catch { record = null; } }
|
|
@@ -481,32 +542,6 @@ async function writeDockConfig(layout, url) {
|
|
|
481
542
|
if (!current || !current.equals(bytes)) await writeAtomic(layout.dock, bytes);
|
|
482
543
|
}
|
|
483
544
|
|
|
484
|
-
// One opener at a time per state; a lock older than the startup bound is set aside, never deleted.
|
|
485
|
-
async function withLock(file, action, { wait = 180000, stale = 300000 } = {}) {
|
|
486
|
-
const deadline = Date.now() + wait;
|
|
487
|
-
let handle;
|
|
488
|
-
for (;;) {
|
|
489
|
-
try { handle = await fs.open(file, 'wx', 0o600); break; } catch (error) {
|
|
490
|
-
if (error.code !== 'EEXIST') throw error;
|
|
491
|
-
try {
|
|
492
|
-
if (Date.now() - (await fs.stat(file)).mtimeMs > stale) {
|
|
493
|
-
await fs.rename(file, `${file}.stale-${randomBytes(6).toString('hex')}`);
|
|
494
|
-
continue;
|
|
495
|
-
}
|
|
496
|
-
} catch (inner) { if (inner.code !== 'ENOENT') throw inner; continue; }
|
|
497
|
-
if (Date.now() > deadline) fail('Another WorkBite open is still starting this state; try again shortly.');
|
|
498
|
-
await new Promise(resolve => setTimeout(resolve, 200));
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
try {
|
|
502
|
-
await handle.writeFile(`${process.pid}\n`);
|
|
503
|
-
return await action();
|
|
504
|
-
} finally {
|
|
505
|
-
await handle.close();
|
|
506
|
-
await fs.unlink(file).catch(() => {});
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
545
|
// WB-EXEC-UI-V1 §4.4 device opt-in for 9 Execution, in WorkBite's own private settings file.
|
|
511
546
|
// Written only when no execution_ui choice exists; a present choice (enabled or disabled, any
|
|
512
547
|
// prefix) is kept byte for byte, and anything else there is refused, never repaired.
|
|
@@ -570,36 +605,50 @@ function typescriptExecutionEnvironment(layout, bridge) {
|
|
|
570
605
|
}
|
|
571
606
|
const verifiedPath = value => typeof value === 'string' && path.isAbsolute(value) && !/[\x00-\x1f]/.test(value);
|
|
572
607
|
|
|
573
|
-
function uiEnvironment(base, { executables, layout, endpoint, uid, backendId, execution = null, bridge = null }) {
|
|
608
|
+
function uiEnvironment(base, { executables, layout, endpoint, uid, backendId, execution = null, bridge = null, mobile = null }) {
|
|
574
609
|
const home = path.dirname(executables.stackbite);
|
|
575
610
|
return withPath({ ...base, WORKSTACK_TRANSPORT: 'native', WORKSTACK_NATIVE_CLIENT: executables.workstack_client,
|
|
576
611
|
WORKSTACK_ENDPOINT: endpoint, WORKSTACK_WORKSPACE_UID: uid, WORKSTACK_STATE_DIR: layout.tuiState,
|
|
577
612
|
WORKSTACK_EXECUTION_DOCK_CONFIG: layout.dock, WORKSTACK_EXECUTION_DOCK_BACKEND_ID: backendId,
|
|
578
613
|
WORKSTACK_DEPLOY_JOURNAL_DIR: layout.journal, WORKSTACK_STACKBITE_EXECUTABLE: executables.stackbite,
|
|
579
614
|
...(execution ? { WORKSTACK_TUI_STATE: layout.tuiIntent, WORKSTACK_TUI_SETTINGS: layout.tuiSettings } : {}),
|
|
580
|
-
...(bridge ? typescriptExecutionEnvironment(layout, bridge) : {})
|
|
615
|
+
...(bridge ? typescriptExecutionEnvironment(layout, bridge) : {}),
|
|
616
|
+
...(mobile ? { WORKBITE_MOBILE_EXECUTABLE: executables.workbite_mobile,
|
|
617
|
+
WORKBITE_MOBILE_HOST: JSON.stringify(mobile) } : {}) }, home);
|
|
581
618
|
}
|
|
582
619
|
|
|
583
|
-
function runForeground(command, args, environment, { spawnImpl = spawn, signalSource = process
|
|
620
|
+
function runForeground(command, args, environment, { spawnImpl = spawn, signalSource = process,
|
|
621
|
+
timeout = 30000, onStarted = () => {} } = {}) {
|
|
584
622
|
return new Promise((resolve, reject) => {
|
|
585
623
|
const [file, ...prefix] = command;
|
|
586
624
|
const child = spawnImpl(file, [...prefix, ...args], { env: environment, stdio: 'inherit', shell: false });
|
|
625
|
+
let settled = false;
|
|
587
626
|
const handlers = new Map();
|
|
588
627
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
589
628
|
const handler = () => child.kill(signal);
|
|
590
629
|
handlers.set(signal, handler);
|
|
591
630
|
signalSource.on(signal, handler);
|
|
592
631
|
}
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
632
|
+
const timer = setTimeout(() => {
|
|
633
|
+
settled = true;
|
|
634
|
+
cleanup();
|
|
635
|
+
reject(new Error('Work Stack terminal UI launch timed out; outcome unknown. No retry or fallback was attempted.'));
|
|
636
|
+
}, finiteTimeout(timeout, 30000));
|
|
637
|
+
const cleanup = () => {
|
|
638
|
+
clearTimeout(timer);
|
|
639
|
+
for (const [signal, handler] of handlers) signalSource.removeListener(signal, handler);
|
|
640
|
+
};
|
|
641
|
+
// Only launch acknowledgement is bounded. A successfully spawned UI runs until it exits.
|
|
642
|
+
child.once('spawn', () => { if (!settled) { clearTimeout(timer); onStarted(); } });
|
|
643
|
+
child.once('error', () => { settled = true; cleanup(); reject(new Error('Cannot start the verified Work Stack terminal UI')); });
|
|
644
|
+
child.once('exit', (code, signal) => { settled = true; cleanup(); resolve({ code, signal }); });
|
|
596
645
|
});
|
|
597
646
|
}
|
|
598
647
|
|
|
599
648
|
// commands maps each bundle executable to its argv prefix; tests substitute fixtures.
|
|
600
649
|
async function openSuite({ installed, commands, workspace, init = false, stateRoot, environment = process.env,
|
|
601
650
|
ui = true, frontend = 'typescript', spawnImpl = spawn, execImpl = execFile, platform = process.platform,
|
|
602
|
-
stdout = process.stdout, timeouts = {}, bootImpl = bootIdentity }) {
|
|
651
|
+
stdout = process.stdout, stderr = process.stderr, timeouts = {}, bootImpl = bootIdentity, mobileBootstrap = undefined }) {
|
|
603
652
|
if (!FRONTENDS.includes(frontend)) fail(`--frontend must be one of: ${FRONTENDS.join(', ')}`);
|
|
604
653
|
if (workspace !== undefined) workspace = checkedWorkspace(workspace);
|
|
605
654
|
else if (init) fail('--init needs --workspace ABS naming the new or empty folder');
|
|
@@ -621,39 +670,65 @@ async function openSuite({ installed, commands, workspace, init = false, stateRo
|
|
|
621
670
|
fail('This WorkBite execution bundle has no verified execution bridge executable; nothing was started.');
|
|
622
671
|
}
|
|
623
672
|
const layout = stateLayout(path.resolve(stateRoot));
|
|
624
|
-
|
|
673
|
+
const stage = async (name, action) => {
|
|
674
|
+
stderr.write(`[workbite] ${name}: starting\n`);
|
|
675
|
+
try {
|
|
676
|
+
const value = await action();
|
|
677
|
+
stderr.write(`[workbite] ${name}: ready\n`);
|
|
678
|
+
return value;
|
|
679
|
+
} catch (error) {
|
|
680
|
+
if (error.workbiteStage) throw error;
|
|
681
|
+
stderr.write(`[workbite] ${name}: failed\n`);
|
|
682
|
+
const wrapped = new Error(`WorkBite startup stage "${name}" failed: ${error.message}; state: ${layout.root}; logs: ${layout.logs}`,
|
|
683
|
+
{ cause: error });
|
|
684
|
+
wrapped.workbiteStage = name;
|
|
685
|
+
throw wrapped;
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
// Fixed stage names only: never print child environment, tokens or native stdout.
|
|
689
|
+
await stage('state preparation', () => prepareState(layout));
|
|
625
690
|
const base = cleanEnvironment(environment);
|
|
691
|
+
const startupExec = (file, args, options, callback) => {
|
|
692
|
+
runOnce(file, args, options.env, options.timeout, execImpl, options)
|
|
693
|
+
.then(result => callback(result.error, result.stdout,
|
|
694
|
+
result.stderr || (result.error?.killed ? 'startup command timed out; outcome unknown' : '')));
|
|
695
|
+
};
|
|
626
696
|
if (execution) {
|
|
627
697
|
// The execution UI's intent parent and deploy journal, made private (Windows DACL) before any file in them is written.
|
|
628
|
-
await
|
|
629
|
-
|
|
698
|
+
await stage('private execution state', async () => {
|
|
699
|
+
await safeDirectory(layout.nativeTui);
|
|
700
|
+
await protectPrivateDirectories([layout.nativeTui, layout.journal], { platform, environment: base,
|
|
701
|
+
execImpl: startupExec, timeout: finiteTimeout(timeouts.privateState, 60000) });
|
|
702
|
+
});
|
|
630
703
|
}
|
|
631
704
|
const engineEnvironment = { ...base, WORK_STACK_RUNTIME: layout.workstackRuntime };
|
|
632
|
-
const session = await withLock(layout.lock, async () => {
|
|
705
|
+
const session = await stage('startup lock', () => withLock(layout.lock, async () => {
|
|
633
706
|
let remembered = null;
|
|
634
707
|
if (workspace === undefined) {
|
|
635
|
-
remembered = await rememberedWorkspace(layout);
|
|
708
|
+
remembered = await stage('workspace selection', () => rememberedWorkspace(layout));
|
|
636
709
|
workspace = checkedWorkspace(remembered.workspace);
|
|
637
710
|
}
|
|
638
711
|
// Settings are settled before any workspace init or backend start, so a refused file changes nothing else.
|
|
639
|
-
const executionUi = execution ? await ensureExecutionSettings(layout.tuiSettings) : null;
|
|
640
|
-
const uid = init ?
|
|
712
|
+
const executionUi = execution ? await stage('execution settings', () => ensureExecutionSettings(layout.tuiSettings)) : null;
|
|
713
|
+
const uid = await stage(init ? 'workspace initialization' : 'workspace identity', () => init
|
|
714
|
+
? initializeWorkspace(commands, workspace, engineEnvironment, { timeout: timeouts.workspaceInit }) : readWorkspaceUid(workspace));
|
|
641
715
|
if (remembered && uid !== remembered.uid) fail(`The workspace ${workspace} changed while opening. ${EXPLICIT}`);
|
|
642
|
-
const backendId = await ensureBackendId(layout);
|
|
643
|
-
const boot = await bootImpl({ platform, environment: base })
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
716
|
+
const backendId = await stage('backend identity', () => ensureBackendId(layout));
|
|
717
|
+
const boot = await stage('host boot provenance', () => beforeDeadline(() => bootImpl({ platform, environment: base }),
|
|
718
|
+
performance.now() + finiteTimeout(timeouts.boot, 10000), 'OS boot provenance timed out; no backend was started'));
|
|
719
|
+
const engine = await stage('Work Stack owner', () => ensureEngine({ commands, workspace, uid, layout, environment: engineEnvironment, spawnImpl,
|
|
720
|
+
timeout: timeouts.engine, execution, boot }));
|
|
721
|
+
const stackbite = await stage('StackBite backend', () => ensureStackbite({ commands, layout, backendId, environment: base, spawnImpl,
|
|
722
|
+
timeout: timeouts.stackbite, execution, boot }));
|
|
648
723
|
if (execution) {
|
|
649
|
-
await initializeNativeRuntime({ commands, layout, url: stackbite.url, environment: base, execImpl,
|
|
650
|
-
timeout: timeouts.nativeInit });
|
|
724
|
+
await stage('native runtime selection', () => initializeNativeRuntime({ commands, layout, url: stackbite.url, environment: base, execImpl,
|
|
725
|
+
timeout: timeouts.nativeInit }));
|
|
651
726
|
}
|
|
652
|
-
await writeDockConfig(layout, stackbite.url);
|
|
727
|
+
await stage('execution connection', () => writeDockConfig(layout, stackbite.url));
|
|
653
728
|
// Only after the owner, StackBite and dock config are proven ready, and before the UI.
|
|
654
|
-
await rememberWorkspace(layout, workspace, uid);
|
|
729
|
+
await stage('workspace selection save', () => rememberWorkspace(layout, workspace, uid));
|
|
655
730
|
return { uid, backendId, engine, stackbite, executionUi };
|
|
656
|
-
});
|
|
731
|
+
}));
|
|
657
732
|
const summary = { schema: 'workbite.open.v1', workspace, workspace_uid: session.uid, endpoint: session.engine.endpoint,
|
|
658
733
|
owner_reused: session.engine.reused, stackbite_url: session.stackbite.url, stackbite_reused: session.stackbite.reused,
|
|
659
734
|
backend_id: session.backendId, state: layout.root, frontend,
|
|
@@ -665,14 +740,36 @@ async function openSuite({ installed, commands, workspace, init = false, stateRo
|
|
|
665
740
|
const environmentForUi = uiEnvironment(base, { executables: installed.executables, layout, endpoint: session.engine.endpoint,
|
|
666
741
|
uid: session.uid, backendId: session.backendId, execution: frontend === 'rust' ? execution : null,
|
|
667
742
|
bridge: bridge && ['enabled', 'initialized'].includes(session.executionUi) ? bridge : null });
|
|
743
|
+
if (frontend === 'typescript' && platform === 'win32' && verifiedPath(installed.executables?.workbite_mobile)) {
|
|
744
|
+
const mobileRoot = path.join(layout.root, 'mobile');
|
|
745
|
+
await stage('private phone state', async () => {
|
|
746
|
+
await safeDirectory(mobileRoot);
|
|
747
|
+
await protectPrivateDirectories([mobileRoot], { platform, environment: base, execImpl: startupExec,
|
|
748
|
+
timeout: finiteTimeout(timeouts.privateState, 60000) });
|
|
749
|
+
});
|
|
750
|
+
environmentForUi.WORKBITE_MOBILE_EXECUTABLE = installed.executables.workbite_mobile;
|
|
751
|
+
environmentForUi.WORKBITE_MOBILE_HOST = JSON.stringify({
|
|
752
|
+
directory: path.join(mobileRoot, workspaceKey(workspace)), executable: installed.executables.workbite_mobile,
|
|
753
|
+
scope: { host_id: session.backendId, workspace_uid: session.uid },
|
|
754
|
+
native: { executable: installed.executables.workstack_client, cwd: workspace, endpoint: session.engine.endpoint,
|
|
755
|
+
workspaceUid: session.uid, deployment: { connection: layout.dock, backendId: session.backendId, journalDir: layout.journal } },
|
|
756
|
+
// Embedding hosts may supply the private operator invitation at first setup; never a token.
|
|
757
|
+
...(mobileBootstrap ? { initialInput: mobileBootstrap } : {}),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
668
760
|
// Exactly one foreground child in this terminal (inherited stdio, never detached or a new console).
|
|
669
761
|
// The TypeScript UI gets the v1 environment (keeping its own familiar settings file) plus, when the
|
|
670
762
|
// v2 opt-in is enabled, only the WORKBITE_EXECUTION_* names above.
|
|
671
|
-
|
|
763
|
+
stderr.write('[workbite] terminal UI launch: starting\n');
|
|
764
|
+
const result = await runForeground(commands[uiCommand], [], environmentForUi, { spawnImpl, timeout: timeouts.uiLaunch,
|
|
765
|
+
onStarted: () => stderr.write('[workbite] terminal UI launch: ready\n') }).catch(error => {
|
|
766
|
+
stderr.write('[workbite] terminal UI launch: failed\n');
|
|
767
|
+
throw new Error(`WorkBite startup stage "terminal UI launch" failed: ${error.message}; state: ${layout.root}; logs: ${layout.logs}`, { cause: error });
|
|
768
|
+
});
|
|
672
769
|
return { ...result, summary };
|
|
673
770
|
}
|
|
674
771
|
|
|
675
772
|
module.exports = { cleanEnvironment, stateLayout, prepareState, ensureBackendId, workspaceKey, readWorkspaceUid,
|
|
676
773
|
initializeWorkspace, engineIdentity, stackbiteIdentity, freePort, ensureEngine, ensureStackbite, writeDockConfig,
|
|
677
774
|
withLock, uiEnvironment, openSuite, rememberedWorkspace, ensureExecutionSettings, initializeNativeRuntime, RESERVED_PORTS,
|
|
678
|
-
SELECTION_SCHEMA, EXECUTION_UI_DEFAULT, NATIVE_INIT_SCHEMA, FRONTENDS };
|
|
775
|
+
SELECTION_SCHEMA, EXECUTION_UI_DEFAULT, NATIVE_INIT_SCHEMA, FRONTENDS, runForeground };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const fsSync = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const { randomBytes } = require('node:crypto');
|
|
6
|
+
const { performance } = require('node:perf_hooks');
|
|
7
|
+
|
|
8
|
+
function sameFile(a, b) {
|
|
9
|
+
// An unavailable/zero inode is not sufficient evidence to remove anything.
|
|
10
|
+
return a.isFile() && b.isFile() && a.ino !== 0n && a.ino === b.ino
|
|
11
|
+
&& a.dev === b.dev && a.birthtimeNs === b.birthtimeNs && b.nlink === 1n;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function releaseOwned(file, handle, identity, bytes) {
|
|
15
|
+
try {
|
|
16
|
+
// Keep the descriptor open so its inode cannot be recycled. Read through
|
|
17
|
+
// that descriptor, never a pathname that could now point at a symlink.
|
|
18
|
+
// Synchronous checks avoid yielding to other JS before the final unlink.
|
|
19
|
+
// Node has no atomic compare-and-unlink: external actors must not mutate
|
|
20
|
+
// the lock directory during release. Cooperating callers never reclaim it.
|
|
21
|
+
if (!sameFile(identity, fsSync.fstatSync(handle.fd, { bigint: true }))) return;
|
|
22
|
+
const actual = Buffer.alloc(bytes.length + 1);
|
|
23
|
+
const count = fsSync.readSync(handle.fd, actual, 0, actual.length, 0);
|
|
24
|
+
if (count !== bytes.length || !actual.subarray(0, count).equals(bytes)) return;
|
|
25
|
+
if (!sameFile(identity, fsSync.lstatSync(file, { bigint: true }))) return;
|
|
26
|
+
fsSync.unlinkSync(file);
|
|
27
|
+
} catch {
|
|
28
|
+
// Missing, unreadable or unprovable ownership stays closed; cleanup must
|
|
29
|
+
// not hide the action's result/error or remove another caller's lock.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// `stale` in older callers' options is intentionally ignored. Neither age nor
|
|
34
|
+
// a locally absent PID proves a lock owner on shared storage has stopped.
|
|
35
|
+
async function withLock(file, action, { wait = 180000 } = {}) {
|
|
36
|
+
if (!Number.isFinite(wait) || wait < 0) throw new RangeError('Lock wait must be a finite nonnegative number.');
|
|
37
|
+
const bytes = Buffer.from(JSON.stringify({ pid: process.pid, hostname: os.hostname().slice(0, 255),
|
|
38
|
+
boot: null, nonce: randomBytes(16).toString('hex') }) + '\n');
|
|
39
|
+
const deadline = performance.now() + wait;
|
|
40
|
+
let handle;
|
|
41
|
+
for (;;) {
|
|
42
|
+
try {
|
|
43
|
+
handle = await fs.open(file, 'wx+', 0o600);
|
|
44
|
+
break;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code !== 'EEXIST') throw error;
|
|
47
|
+
const remaining = deadline - performance.now();
|
|
48
|
+
if (remaining <= 0) {
|
|
49
|
+
const timeout = new Error(`Another WorkBite open may still be starting this state (lock: ${file}). `
|
|
50
|
+
+ 'Run workbite status --json to inspect it, and retry after the owner finishes. '
|
|
51
|
+
+ 'An old lock or a PID absent on this host does not prove that a shared-state owner has stopped; '
|
|
52
|
+
+ 'WorkBite leaves the lock in place.');
|
|
53
|
+
timeout.code = 'WORKBITE_STARTUP_LOCK_TIMEOUT';
|
|
54
|
+
throw timeout;
|
|
55
|
+
}
|
|
56
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(200, remaining)));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let identity;
|
|
61
|
+
try {
|
|
62
|
+
identity = await handle.stat({ bigint: true });
|
|
63
|
+
await handle.writeFile(bytes);
|
|
64
|
+
return await action();
|
|
65
|
+
} finally {
|
|
66
|
+
if (identity) releaseOwned(file, handle, identity, bytes);
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { withLock };
|