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/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
- function runOnce(file, args, environment, timeout = 120000, execImpl = execFile) {
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
- execImpl(file, args, { env: environment, shell: false, windowsHide: true, timeout, maxBuffer: 1024 * 1024, encoding: 'utf8' },
113
- (error, stdout, stderr) => resolve({ error, stdout: stdout || '', stderr: stderr || '' }));
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, timeout,
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 => { size += chunk.length; if (size > 1024 * 1024) req.destroy(); else chunks.push(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
- resolve({ status: response.statusCode, body: parsed });
242
+ finish(null, { status: response.statusCode, body: parsed });
199
243
  });
200
- response.on('error', reject);
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
- req.on('timeout', () => req.destroy(new Error('request timed out')));
203
- req.on('error', reject);
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 answer = await request(new URL('/api/backend/status', url), { headers: { 'X-Office-Token': token } });
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 deadline = Date.now() + timeout;
265
- while (Date.now() < deadline) {
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(`${what} did not become ready in time; it was left running. See ${log}`);
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 ready = await waitFor(() => readyRecord(log), exited, timeout, 'Work Stack native owner', log);
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)) fail(`Work Stack native owner identity was not proven; see ${log}`);
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(() => identityImpl(url, layout.stackbite, backendId), exited, timeout, 'StackBite backend', log);
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 new Promise(resolve => {
457
- execImpl(file, [...prefix, '--workbite-native-init'], {
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
- cwd: home, shell: false, windowsHide: true, timeout, maxBuffer: 64 * 1024, encoding: 'utf8',
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) : {}) }, home);
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 cleanup = () => { for (const [signal, handler] of handlers) signalSource.removeListener(signal, handler); };
594
- child.once('error', () => { cleanup(); reject(new Error('Cannot start the verified Work Stack terminal UI')); });
595
- child.once('exit', (code, signal) => { cleanup(); resolve({ code, signal }); });
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
- await prepareState(layout);
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 safeDirectory(layout.nativeTui);
629
- await protectPrivateDirectories([layout.nativeTui, layout.journal], { platform, environment: base, execImpl });
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 ? await initializeWorkspace(commands, workspace, engineEnvironment) : await readWorkspaceUid(workspace);
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
- const engine = await ensureEngine({ commands, workspace, uid, layout, environment: engineEnvironment, spawnImpl,
645
- timeout: timeouts.engine, execution, boot });
646
- const stackbite = await ensureStackbite({ commands, layout, backendId, environment: base, spawnImpl,
647
- timeout: timeouts.stackbite, execution, boot });
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
- const result = await runForeground(commands[uiCommand], [], environmentForUi, { spawnImpl });
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 };