@vellumai/assistant 0.11.7-dev.202608281503.cbafd6c → 0.11.7-dev.202608282003.8ce5421

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.
Files changed (35) hide show
  1. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  4. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  6. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  7. package/openapi.yaml +5 -5
  8. package/package.json +1 -1
  9. package/src/__tests__/credential-health-service.test.ts +45 -0
  10. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
  11. package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
  12. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
  13. package/src/__tests__/guardian-gateway-sim.test.ts +47 -0
  14. package/src/__tests__/guardian-gateway-sim.ts +5 -1
  15. package/src/__tests__/non-member-access-request.test.ts +12 -0
  16. package/src/channels/gateway-guardian-requests.ts +4 -3
  17. package/src/cli/commands/__tests__/browser.test.ts +9 -7
  18. package/src/credential-health/credential-health-service.ts +37 -6
  19. package/src/daemon/lifecycle.ts +10 -13
  20. package/src/oauth/scope-utils.ts +48 -0
  21. package/src/persistence/db-connection.ts +3 -21
  22. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
  23. package/src/persistence/embeddings/embedding-local.ts +7 -112
  24. package/src/runtime/access-request-helper.ts +15 -7
  25. package/src/runtime/routes/integrations/slack/channel.ts +39 -18
  26. package/src/runtime/tool-grant-request-helper.ts +4 -0
  27. package/src/security/oauth2.ts +2 -11
  28. package/src/tools/browser/__tests__/browser-status.test.ts +9 -7
  29. package/src/tools/browser/browser-execution.ts +4 -40
  30. package/src/tools/browser/browser-status-constants.ts +0 -6
  31. package/src/util/__tests__/worker-process-command.test.ts +246 -1
  32. package/src/util/platform.ts +13 -81
  33. package/src/util/process-table.ts +14 -5
  34. package/src/util/worker-ownership.ts +126 -0
  35. package/src/util/worker-process.ts +369 -20
@@ -19,8 +19,21 @@ import {
19
19
  import { dirname, join } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
 
22
- import { getCurrentLogFilePath } from "./logger.js";
22
+ import { getCurrentLogFilePath, getLogger } from "./logger.js";
23
+ import { isProcessAlive } from "./process-liveness.js";
24
+ import {
25
+ listProcessTable,
26
+ type ProcessTableRow,
27
+ readRawProcessCommand,
28
+ } from "./process-table.js";
23
29
  import { workerMemoryEnv } from "./worker-memory.js";
30
+ import {
31
+ classifyWorkerOwnership,
32
+ isDaemonCommand,
33
+ pid1OwnsWorkers,
34
+ } from "./worker-ownership.js";
35
+
36
+ const log = getLogger("worker-process");
24
37
 
25
38
  export interface WorkerProcessStatus {
26
39
  status: "running" | "not_running";
@@ -135,21 +148,40 @@ interface WorkerCommandRuntime {
135
148
  executableExists: (path: string) => boolean;
136
149
  }
137
150
 
138
- export function resolveWorkerCommand(
139
- entry: URL,
140
- packagedEntry: PackagedWorkerEntry | undefined,
141
- runtime: WorkerCommandRuntime = {
151
+ function defaultWorkerCommandRuntime(): WorkerCommandRuntime {
152
+ return {
142
153
  platform: process.platform,
143
154
  execPath: process.execPath,
144
155
  executableExists: existsSync,
145
- },
146
- ): string[] {
156
+ };
157
+ }
158
+
159
+ /**
160
+ * How a packaged Windows runtime spawns this worker, or null when the worker
161
+ * runs from source. Only packaged Windows runtimes ship the executable.
162
+ */
163
+ function packagedWorkerSpawn(
164
+ packagedEntry: PackagedWorkerEntry | undefined,
165
+ runtime: WorkerCommandRuntime,
166
+ ): { executable: string; entry: PackagedWorkerEntry } | null {
147
167
  if (runtime.platform === "win32" && packagedEntry) {
148
168
  const executable = join(dirname(runtime.execPath), "vellum-worker.exe");
149
169
  if (runtime.executableExists(executable)) {
150
- return [executable, packagedEntry];
170
+ return { executable, entry: packagedEntry };
151
171
  }
152
172
  }
173
+ return null;
174
+ }
175
+
176
+ export function resolveWorkerCommand(
177
+ entry: URL,
178
+ packagedEntry: PackagedWorkerEntry | undefined,
179
+ runtime: WorkerCommandRuntime = defaultWorkerCommandRuntime(),
180
+ ): string[] {
181
+ const packaged = packagedWorkerSpawn(packagedEntry, runtime);
182
+ if (packaged) {
183
+ return [packaged.executable, packaged.entry];
184
+ }
153
185
  return ["bun", "--smol", "run", fileURLToPath(entry)];
154
186
  }
155
187
 
@@ -194,10 +226,310 @@ async function waitForWorkerPidFile(
194
226
  return existsSync(pidPath) ? "ready" : "timeout";
195
227
  }
196
228
 
229
+ /** Path separators differ by platform; compare command lines on one form. */
230
+ function normalizeSeparators(value: string): string {
231
+ return value.replaceAll("\\", "/");
232
+ }
233
+
234
+ /** Whether a command line carries every fragment that marks this worker. */
235
+ function matchesSignature(
236
+ command: string,
237
+ signature: readonly string[],
238
+ ): boolean {
239
+ const normalized = normalizeSeparators(command);
240
+ return signature.every((part) =>
241
+ normalized.includes(normalizeSeparators(part)),
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Fragments of a command line that together mark a process as this worker,
247
+ * whichever install it came from. All must be present.
248
+ *
249
+ * This is a safety guard, not the ownership test. Ownership is decided by
250
+ * parentage; this only keeps us from signalling a process that happens to hold
251
+ * a recycled PID. Three trailing path segments are specific enough
252
+ * (`src/schedule/worker.ts`, `defaults/memory/worker.ts`) that an unrelated
253
+ * program running some other `worker.ts` does not match. Packaged workers all
254
+ * run one executable and are told apart by the subcommand, so the signature
255
+ * carries both: matching on the executable alone would let one packaged
256
+ * worker's slot reclaim another's process.
257
+ */
258
+ export function workerKindSignature(
259
+ entry: URL,
260
+ packagedEntry: PackagedWorkerEntry | undefined,
261
+ runtime: WorkerCommandRuntime = defaultWorkerCommandRuntime(),
262
+ ): readonly string[] {
263
+ const packaged = packagedWorkerSpawn(packagedEntry, runtime);
264
+ if (packaged) {
265
+ // Matched as separate fragments rather than one adjacent string: a Windows
266
+ // command line may quote the executable path, so the two are not reliably
267
+ // neighbours.
268
+ return ["vellum-worker", packaged.entry];
269
+ }
270
+ return [
271
+ normalizeSeparators(fileURLToPath(entry))
272
+ .split("/")
273
+ .filter(Boolean)
274
+ .slice(-3)
275
+ .join("/"),
276
+ ];
277
+ }
278
+
279
+ /**
280
+ * What became of the orphan after an awaited gap, from liveness plus a fresh
281
+ * command-line read.
282
+ *
283
+ * - `gone`: the PID is dead, or alive under a command line that is not this
284
+ * worker (the orphan exited and the OS recycled its PID). The slot may be
285
+ * released.
286
+ * - `orphan`: still alive and still reads as this worker. Escalation may
287
+ * proceed.
288
+ * - `identity-unreadable`: alive, but the command line could not be read, so
289
+ * "still the orphan" and "recycled PID" cannot be told apart. Uncertainty
290
+ * must resolve to reuse: no signal, and the slot must NOT be released, or
291
+ * a second worker would be spawned next to a live one.
292
+ */
293
+ export type OrphanFate = "gone" | "orphan" | "identity-unreadable";
294
+
295
+ export function classifyOrphanAfterWait(
296
+ alive: boolean,
297
+ command: string | null,
298
+ signature: readonly string[],
299
+ ): OrphanFate {
300
+ if (!alive) {
301
+ return "gone";
302
+ }
303
+ if (command == null) {
304
+ return "identity-unreadable";
305
+ }
306
+ return matchesSignature(command, signature) ? "orphan" : "gone";
307
+ }
308
+
309
+ /** Re-read the orphan's fate for `pid` from the live process table. */
310
+ function orphanFate(pid: number, signature: readonly string[]): OrphanFate {
311
+ return classifyOrphanAfterWait(
312
+ isProcessAlive(pid),
313
+ readRawProcessCommand(pid),
314
+ signature,
315
+ );
316
+ }
317
+
318
+ /**
319
+ * How long an orphaned worker gets to exit after SIGTERM.
320
+ *
321
+ * Derived from the longest shutdown any worker can legitimately take, not
322
+ * picked. The memory worker reaps the ONNX embedding subprocess it owns before
323
+ * exiting and bounds that at `EMBEDDING_SHUTDOWN_BUDGET_MS + 1s`, so a shorter
324
+ * ceiling here would SIGKILL it mid-reap and strand the very subprocess that
325
+ * reap exists to collect. A test asserts this stays above that bound.
326
+ */
327
+ export const ORPHAN_STOP_TIMEOUT_MS = 15_000;
328
+
329
+ /** Poll interval while waiting for a stopped worker to exit. */
330
+ const ORPHAN_STOP_POLL_INTERVAL_MS = 100;
331
+
332
+ /**
333
+ * How long to wait for a SIGKILLed worker to actually disappear. Signal
334
+ * delivery and reaping are asynchronous, so a liveness probe taken straight
335
+ * after the kill can still see a process that is on its way out.
336
+ */
337
+ const ORPHAN_KILL_CONFIRM_MS = 1_000;
338
+
339
+ /** Poll until `pid` is gone or `timeoutMs` elapses. */
340
+ async function waitForExit(pid: number, timeoutMs: number): Promise<void> {
341
+ const deadline = Date.now() + timeoutMs;
342
+ while (Date.now() < deadline && isProcessAlive(pid)) {
343
+ await Bun.sleep(ORPHAN_STOP_POLL_INTERVAL_MS);
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Stop a worker orphaned by a previous owner and release its PID file.
349
+ * Reports whether it actually exited.
350
+ *
351
+ * Escalates to SIGKILL because the orphan runs a different generation's
352
+ * shutdown path, which this process cannot make assumptions about. Signalling
353
+ * can still fail outright, most plausibly EPERM for a process owned by another
354
+ * user, so the PID file is released only once the process is confirmed gone:
355
+ * dropping it while the worker still runs would let the caller spawn a second
356
+ * one against the same workspace. The unlink is also identity-checked, since a
357
+ * concurrent launcher may already have replaced the worker and deleting that
358
+ * successor's entry would strand it.
359
+ */
360
+ async function stopOrphanedWorker(
361
+ pid: number,
362
+ pidPath: string,
363
+ workerLabel: string,
364
+ signature: readonly string[],
365
+ ): Promise<boolean> {
366
+ try {
367
+ process.kill(pid, "SIGTERM");
368
+ } catch {
369
+ // Exited between the probe and the signal.
370
+ }
371
+
372
+ await waitForExit(pid, ORPHAN_STOP_TIMEOUT_MS);
373
+ // Identity is re-proven after every awaited gap, not carried by the PID:
374
+ // the orphan may have exited during the wait and the OS may have handed its
375
+ // PID to a stranger. Only a positive re-match escalates, and only a proven
376
+ // "gone" releases the slot.
377
+ let fate = orphanFate(pid, signature);
378
+ if (fate === "orphan") {
379
+ try {
380
+ process.kill(pid, "SIGKILL");
381
+ } catch {
382
+ // Best-effort.
383
+ }
384
+ await waitForExit(pid, ORPHAN_KILL_CONFIRM_MS);
385
+ fate = orphanFate(pid, signature);
386
+ }
387
+
388
+ if (fate !== "gone") {
389
+ log.warn(
390
+ { pid, pidPath, fate },
391
+ fate === "orphan"
392
+ ? `${workerLabel} orphaned by a previous owner could not be stopped; reusing it rather than running a second one`
393
+ : `${workerLabel} is still live but its identity could not be re-read; reusing it rather than risking a duplicate`,
394
+ );
395
+ return false;
396
+ }
397
+
398
+ unlinkPidFileIfNames(pidPath, pid);
399
+ log.warn(
400
+ { pid, pidPath },
401
+ `${workerLabel} orphaned by a previous owner was stopped so this process can start its own`,
402
+ );
403
+ return true;
404
+ }
405
+
406
+ /**
407
+ * What to do about the process a worker's PID file currently names.
408
+ *
409
+ * - `adopt`: reuse it. Either it is this process's own worker, or it belongs
410
+ * to another live owner, or it is not recognisably one of our workers at
411
+ * all and must never be signalled.
412
+ * - `reclaim`: an orphan left by an owner that is gone. Stop it, then spawn.
413
+ * - `spawn`: nothing is holding the slot.
414
+ */
415
+ export type WorkerSlotDecision =
416
+ | { action: "adopt"; pid: number }
417
+ | { action: "reclaim"; pid: number }
418
+ | { action: "spawn" };
419
+
420
+ /**
421
+ * The whole safety decision, with no IO, so every branch is testable.
422
+ *
423
+ * `reclaim` is the only outcome that signals a process, and it requires two
424
+ * independent things to agree: the command line marks the process as this
425
+ * worker, and parentage says no daemon owns it. A missing row, an unreadable
426
+ * command line, and a command line that does not match all fall back to
427
+ * `adopt`, so an uncertain answer never costs a process its life.
428
+ *
429
+ * `isOwnerAlive` is the caller's definition of a legitimate owner. These
430
+ * workers have exactly one, the daemon, so passing a plain liveness probe
431
+ * would let a recycled owner PID keep a stranded worker looking owned forever.
432
+ * The embed worker passes a liveness probe instead, because two processes may
433
+ * each legitimately run one of its workers.
434
+ */
435
+ export function decideWorkerSlot(
436
+ status: WorkerProcessStatus,
437
+ row: Pick<ProcessTableRow, "pid" | "ppid" | "command"> | null,
438
+ signature: readonly string[],
439
+ selfPid: number,
440
+ isOwnerAlive: (pid: number) => boolean,
441
+ pid1OwnsWorkers: boolean,
442
+ ): WorkerSlotDecision {
443
+ if (status.status !== "running" || status.pid == null) {
444
+ return { action: "spawn" };
445
+ }
446
+ if (!row) {
447
+ return { action: "adopt", pid: status.pid };
448
+ }
449
+ if (!matchesSignature(row.command, signature)) {
450
+ return { action: "adopt", pid: status.pid };
451
+ }
452
+ const ownership = classifyWorkerOwnership(
453
+ row,
454
+ selfPid,
455
+ isOwnerAlive,
456
+ pid1OwnsWorkers,
457
+ );
458
+ return ownership === "orphan"
459
+ ? { action: "reclaim", pid: status.pid }
460
+ : { action: "adopt", pid: status.pid };
461
+ }
462
+
463
+ /**
464
+ * The slot decision for the worker at `pidPath`, made without yielding.
465
+ *
466
+ * Callers spawn inside their own synchronous prefix, so this stays sync: an
467
+ * `await` here would let a caller's next tick run before the child exists.
468
+ */
469
+ function inspectWorkerSlot(
470
+ pidPath: string,
471
+ signature: readonly string[],
472
+ ): WorkerSlotDecision {
473
+ const status = probeWorkerPidFile(pidPath);
474
+ if (status.status !== "running" || status.pid == null) {
475
+ return { action: "spawn" };
476
+ }
477
+
478
+ // The worker's row, its owner's identity, and what PID 1 is all come from
479
+ // one snapshot. Reading the table is a subprocess on most platforms, so
480
+ // asking three separate times would be three of them per worker.
481
+ let table: readonly ProcessTableRow[];
482
+ try {
483
+ table = listProcessTable();
484
+ } catch {
485
+ table = [];
486
+ }
487
+ const commandOf = (pid: number): string | null =>
488
+ table.find((row) => row.pid === pid)?.command ?? null;
489
+
490
+ const row = table.find((entry) => entry.pid === status.pid) ?? null;
491
+ if (row && !matchesSignature(row.command, signature)) {
492
+ log.info(
493
+ { pid: row.pid, pidPath },
494
+ "Worker PID file names a live process this runtime does not recognise as its worker; reusing it rather than signalling it",
495
+ );
496
+ }
497
+
498
+ return decideWorkerSlot(
499
+ status,
500
+ row,
501
+ signature,
502
+ process.pid,
503
+ (pid) => isDaemonCommand(commandOf(pid)),
504
+ pid1OwnsWorkers(commandOf(1)),
505
+ );
506
+ }
507
+
508
+ /**
509
+ * Stop the orphan holding the slot, then report a PID to adopt instead of
510
+ * spawning: either the orphan itself when it could not be stopped, or a
511
+ * replacement a concurrent launcher brought up while we waited.
512
+ */
513
+ async function reclaimWorkerSlot(
514
+ pid: number,
515
+ pidPath: string,
516
+ workerLabel: string,
517
+ signature: readonly string[],
518
+ ): Promise<number | null> {
519
+ if (!(await stopOrphanedWorker(pid, pidPath, workerLabel, signature))) {
520
+ // Still running and beyond our reach. One stale worker beats two live ones.
521
+ return pid;
522
+ }
523
+ const replacement = probeWorkerPidFile(pidPath);
524
+ return replacement.status === "running" && replacement.pid != null
525
+ ? replacement.pid
526
+ : null;
527
+ }
528
+
197
529
  /**
198
530
  * Spawn a worker entry script as a background process and wait for it to
199
531
  * report readiness by writing its PID file. `opts.detached` (default `true`)
200
- * controls process parentage — see {@link SpawnWorkerProcessOptions}.
532
+ * controls process parentage, see {@link SpawnWorkerProcessOptions}.
201
533
  *
202
534
  * If a worker is already running (per the PID file), returns its PID with
203
535
  * `alreadyRunning: true` rather than spawning a second one. Throws
@@ -220,9 +552,21 @@ export async function spawnWorkerProcess(args: {
220
552
  const pidPollIntervalMs = opts.pidPollIntervalMs ?? PID_FILE_POLL_INTERVAL_MS;
221
553
  const detached = opts.detached ?? true;
222
554
 
223
- const current = probeWorkerPidFile(args.pidPath);
224
- if (current.status === "running" && current.pid != null) {
225
- return { pid: current.pid, alreadyRunning: true };
555
+ const signature = workerKindSignature(args.entry, args.packagedEntry);
556
+ const decision = inspectWorkerSlot(args.pidPath, signature);
557
+ if (decision.action === "adopt") {
558
+ return { pid: decision.pid, alreadyRunning: true };
559
+ }
560
+ if (decision.action === "reclaim") {
561
+ const adopted = await reclaimWorkerSlot(
562
+ decision.pid,
563
+ args.pidPath,
564
+ args.workerLabel,
565
+ signature,
566
+ );
567
+ if (adopted != null) {
568
+ return { pid: adopted, alreadyRunning: true };
569
+ }
226
570
  }
227
571
 
228
572
  // Pipe the worker's stderr into the same daily log file the daemon writes
@@ -319,23 +663,28 @@ export function stopWorkerProcess(pidPath: string): WorkerProcessStatus {
319
663
  // ---------------------------------------------------------------------------
320
664
 
321
665
  /**
322
- * Remove the worker PID file only while it still names this process. A
323
- * successor worker overwrites the file with its own PID at startup, so an
324
- * exiting predecessor that unlinked unconditionally would delete the
325
- * successor's entry — the liveness supervisor would then respawn a
326
- * duplicate and orphan the successor.
666
+ * Remove `pidPath` only while it still names `pid`.
667
+ *
668
+ * A successor worker overwrites the file with its own PID at startup, so an
669
+ * unconditional unlink would delete the successor's entry: the liveness
670
+ * supervisor would then respawn a duplicate and orphan the successor.
327
671
  */
328
- export function cleanupWorkerPidFile(pidPath: string): void {
672
+ export function unlinkPidFileIfNames(pidPath: string, pid: number): void {
329
673
  try {
330
674
  const raw = readFileSync(pidPath, "utf-8").trim();
331
- if (parseInt(raw, 10) === process.pid) {
675
+ if (parseInt(raw, 10) === pid) {
332
676
  unlinkSync(pidPath);
333
677
  }
334
678
  } catch {
335
- // best-effort — a missing or unreadable file needs no cleanup
679
+ // best-effort: a missing or unreadable file needs no cleanup
336
680
  }
337
681
  }
338
682
 
683
+ /** Release this process's own PID file on the way out. */
684
+ export function cleanupWorkerPidFile(pidPath: string): void {
685
+ unlinkPidFileIfNames(pidPath, process.pid);
686
+ }
687
+
339
688
  /**
340
689
  * Watch the worker PID file and evict this process when the file stops
341
690
  * naming it. The PID file is a worker's sole tracking handle: stop