openworkgraph 0.3.0 → 0.3.1

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.
@@ -7,7 +7,7 @@ import { waitForBackups, clearStaleResourceLeases } from './backups.js';
7
7
  import { localCapacity } from './capacity.js';
8
8
  import { registerLocalPlugin } from './plugins.js';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { isGlobalNpmInstallation, startWebUpdate } from './update.js';
10
+ import { isGlobalNpmInstallation, isNpxInstallation, startWebUpdate } from './update.js';
11
11
  export function activeRunCount(db) {
12
12
  return Number(db.prepare("SELECT count(*) AS n FROM runs WHERE status NOT IN ('accepted','queued','paused_restore','succeeded','failed','cancelled','interrupted')").get().n);
13
13
  }
@@ -17,9 +17,11 @@ export async function serveManaged(dataDir, host, port, onStopped) {
17
17
  let service;
18
18
  const cliPath = fileURLToPath(new URL('../cli.js', import.meta.url));
19
19
  const npmInstallation = isGlobalNpmInstallation(cliPath);
20
+ const npxInstallation = npmInstallation.then(global => global ? false : isNpxInstallation(cliPath));
20
21
  try {
21
22
  service = createService(lock.root, {
22
23
  npmInstallation: () => npmInstallation,
24
+ npxInstallation: () => npxInstallation,
23
25
  updateRuntime: async (onExit) => {
24
26
  if (state !== 'running')
25
27
  throw new Error('Runtime is stopping.');
@@ -40,10 +42,13 @@ export async function serveManaged(dataDir, host, port, onStopped) {
40
42
  let stopping = false;
41
43
  const drains = new Set();
42
44
  let closing;
45
+ let listenAddress;
43
46
  let recoveryAttempted = false;
44
47
  const onSignal = () => { void stop(false).catch(() => log.append('shutdown_failed')); };
45
48
  const removeSignals = () => { process.removeListener('SIGINT', onSignal); process.removeListener('SIGTERM', onSignal); };
46
- const status = () => ({ state, activeRuns: activeRunCount(service.db), listenHost: service.server.address().address, ...listenerInfo(service.server.address(), service.info.serviceId, service.instanceId) });
49
+ // server.address() becomes null as soon as HTTP shutdown begins; local
50
+ // management must remain available until connections close and locks release.
51
+ const status = () => ({ state, activeRuns: closing ? 0 : activeRunCount(service.db), listenHost: listenAddress.address, ...listenerInfo(listenAddress, service.info.serviceId, service.instanceId) });
47
52
  async function stop(interrupt) {
48
53
  // Escalation remains available while an earlier drain waits indefinitely.
49
54
  if (stopping && (!interrupt || state === 'interrupting'))
@@ -91,6 +96,7 @@ export async function serveManaged(dataDir, host, port, onStopped) {
91
96
  clearStaleResourceLeases(service.db, lock);
92
97
  await service.api.backups.recoverCreating(lock);
93
98
  await new Promise((done, reject) => { service.server.once('error', reject); service.server.listen(port, host, done); });
99
+ listenAddress = service.server.address();
94
100
  // Own the kernel lock before transferring scheduler epochs. Runtime.start
95
101
  // resets acceptingRuns after a normal shutdown and starts its dispatch timer.
96
102
  recoveryAttempted = true;
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readInstance, requestInstance, startBackground, waitUntilStopped } from './lifecycle.js';
3
+ /** Recognize the package actually running from npm exec/npx's cache, including custom cache roots. */
4
+ export declare function isNpxInstallation(cliPath: string, spawnProcess?: SpawnProcess): Promise<boolean>;
3
5
  /** Compare the running package against npm's actual global package location. */
4
6
  export declare function isGlobalNpmInstallation(cliPath: string, spawnProcess?: SpawnProcess): Promise<boolean>;
5
7
  /** The updater must outlive the Runtime that it stops. */
@@ -4,20 +4,39 @@ import { join, relative, isAbsolute, sep } from 'node:path';
4
4
  import { SERVICE_VERSION } from "../vendor/protocol/index.js";
5
5
  import { readInstance, requestInstance, startBackground, waitUntilStopped } from './lifecycle.js';
6
6
  const PACKAGE_NAME = 'openworkgraph';
7
+ async function npmPath(args, spawnProcess) {
8
+ const invocation = process.platform === 'win32'
9
+ ? { command: process.env.ComSpec ?? process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', 'npm ' + args.join(' ')] }
10
+ : { command: 'npm', args };
11
+ return new Promise((resolve, reject) => {
12
+ const child = spawnProcess(invocation.command, invocation.args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
13
+ let output = '';
14
+ child.stdout?.on('data', chunk => { if (output.length < 8192)
15
+ output += chunk.toString('utf8'); });
16
+ child.once('error', reject);
17
+ child.once('close', code => code === 0 ? resolve(output.trim()) : reject(new Error('npm path query failed')));
18
+ });
19
+ }
20
+ /** Recognize the package actually running from npm exec/npx's cache, including custom cache roots. */
21
+ export async function isNpxInstallation(cliPath, spawnProcess = spawn) {
22
+ try {
23
+ const cache = await npmPath(['config', 'get', 'cache'], spawnProcess);
24
+ if (!cache)
25
+ return false;
26
+ const npxRoot = await realpath(join(cache, '_npx'));
27
+ const runningPath = await realpath(cliPath);
28
+ const inside = relative(npxRoot, runningPath).split(sep);
29
+ return inside.length === 5 && inside[0] !== '..' && inside[0] !== '' && !isAbsolute(inside[0])
30
+ && inside[1] === 'node_modules' && inside[2] === PACKAGE_NAME && inside[3] === 'dist' && inside[4] === 'cli.js';
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
7
36
  /** Compare the running package against npm's actual global package location. */
8
37
  export async function isGlobalNpmInstallation(cliPath, spawnProcess = spawn) {
9
- const invocation = process.platform === 'win32'
10
- ? { command: process.env.ComSpec ?? process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', 'npm root -g'] }
11
- : { command: 'npm', args: ['root', '-g'] };
12
38
  try {
13
- const root = await new Promise((resolve, reject) => {
14
- const child = spawnProcess(invocation.command, invocation.args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
15
- let output = '';
16
- child.stdout?.on('data', chunk => { if (output.length < 8192)
17
- output += chunk.toString('utf8'); });
18
- child.once('error', reject);
19
- child.once('close', code => code === 0 ? resolve(output.trim()) : reject(new Error('npm root failed')));
20
- });
39
+ const root = await npmPath(['root', '-g'], spawnProcess);
21
40
  if (!root)
22
41
  return false;
23
42
  const installedPath = join(root, PACKAGE_NAME);
package/dist/service.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare function createService(dataDir?: string, options?: {
11
11
  projectProbe?: ProjectProbe;
12
12
  runtime?: WorkflowRuntimeOptions;
13
13
  npmInstallation?: () => Promise<boolean>;
14
+ npxInstallation?: () => Promise<boolean>;
14
15
  latestNpmVersion?: () => Promise<string>;
15
16
  updateRuntime?: (onExit: () => void) => Promise<void>;
16
17
  }): {
package/dist/service.js CHANGED
@@ -58,6 +58,7 @@ export function createService(dataDir, options = {}) {
58
58
  const events = new Events(db, serviceId, auth);
59
59
  const instanceId = randomUUID();
60
60
  const npmInstallation = options.npmInstallation ?? (async () => false);
61
+ const npxInstallation = options.npxInstallation ?? (async () => false);
61
62
  let updateQueued = false;
62
63
  const unavailable = {
63
64
  status: "unavailable",
@@ -202,7 +203,7 @@ export function createService(dataDir, options = {}) {
202
203
  const runtimeInfo = runtime.serviceInfo;
203
204
  send({
204
205
  ...info,
205
- installation: await npmInstallation() ? 'npm-global' : 'other',
206
+ installation: await npmInstallation() ? 'npm-global' : await npxInstallation() ? 'npx' : 'other',
206
207
  imageRoutes: runtimeInfo.imageRoutes,
207
208
  capabilities: {
208
209
  ...info.capabilities,
@@ -470,13 +471,29 @@ export function createService(dataDir, options = {}) {
470
471
  db.prepare("INSERT INTO settings(key,value) VALUES('listener',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify(listenerInfo(address, serviceId, instanceId)));
471
472
  });
472
473
  let closing;
474
+ async function closeHttpServer() {
475
+ if (!server.listening)
476
+ return;
477
+ await new Promise((resolve, reject) => {
478
+ // Runs and event streams have already drained. A browser's unfinished
479
+ // request must not hold the service's lifetime locks indefinitely.
480
+ const timeout = setTimeout(() => server.closeAllConnections(), 1000);
481
+ timeout.unref();
482
+ server.close(error => {
483
+ clearTimeout(timeout);
484
+ if (error)
485
+ reject(error);
486
+ else
487
+ resolve();
488
+ });
489
+ });
490
+ }
473
491
  async function abortStartup() {
474
492
  runtime.disposeUnstarted();
475
493
  projectProbe.close();
476
494
  events.close();
477
495
  try {
478
- if (server.listening)
479
- await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
496
+ await closeHttpServer();
480
497
  }
481
498
  finally {
482
499
  db.close();
@@ -489,8 +506,7 @@ export function createService(dataDir, options = {}) {
489
506
  projectProbe.close();
490
507
  events.close();
491
508
  try {
492
- if (server.listening)
493
- await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
509
+ await closeHttpServer();
494
510
  db.prepare("DELETE FROM settings WHERE key='listener' AND json_extract(value,'$.instanceId')=?").run(instanceId);
495
511
  }
496
512
  finally {
@@ -5,7 +5,7 @@ export * from './execution-chain.js';
5
5
  export * from './graph-title.js';
6
6
  export * from './graph-binary.js';
7
7
  export declare const PROTOCOL_VERSION: '1.0';
8
- export declare const SERVICE_VERSION: '0.3.0';
8
+ export declare const SERVICE_VERSION: '0.3.1';
9
9
  /** Work Graph uploads use the UI's binary MB convention. */
10
10
  export declare const WORKGRAPH_UPLOAD_MAX_BYTES: number;
11
11
  export declare const WORKGRAPH_TRANSFER_TOTAL_BYTES: number;
@@ -57,7 +57,7 @@ export interface ServiceInfo {
57
57
  serviceId: string;
58
58
  version: string;
59
59
  protocolVersion: string;
60
- installation?: 'npm-global' | 'other';
60
+ installation?: 'npm-global' | 'npx' | 'other';
61
61
  capabilities: Record<CoreCapabilityName, Capability> & Partial<Record<ProjectFileCapabilityName, Capability>>;
62
62
  imageRoutes?: ImageRouteCapability[];
63
63
  }
@@ -5,7 +5,7 @@ export * from './execution-chain.js';
5
5
  export * from './graph-title.js';
6
6
  export * from './graph-binary.js';
7
7
  export const PROTOCOL_VERSION = '1.0';
8
- export const SERVICE_VERSION = '0.3.0';
8
+ export const SERVICE_VERSION = '0.3.1';
9
9
  /** Work Graph uploads use the UI's binary MB convention. */
10
10
  export const WORKGRAPH_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
11
11
  export const WORKGRAPH_TRANSFER_TOTAL_BYTES = 1024 * 1024 * 1024;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "openworkgraph", "version": "0.3.0", "type": "module",
2
+ "name": "openworkgraph", "version": "0.3.1", "type": "module",
3
3
  "publishConfig": { "access": "public" },
4
4
  "engines": { "node": ">=24.0.0" },
5
5
  "bin": { "openworkgraph": "dist/cli.js" },