@tracemarketplace/cli 0.0.17 → 0.0.19
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/dist/cli.js +9 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +14 -0
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/submit.d.ts +18 -0
- package/dist/commands/submit.d.ts.map +1 -1
- package/dist/commands/submit.js +141 -21
- package/dist/commands/submit.js.map +1 -1
- package/dist/commands/submit.test.d.ts +2 -0
- package/dist/commands/submit.test.d.ts.map +1 -0
- package/dist/commands/submit.test.js +58 -0
- package/dist/commands/submit.test.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/flush.d.ts +1 -0
- package/dist/flush.d.ts.map +1 -1
- package/dist/flush.js +18 -5
- package/dist/flush.js.map +1 -1
- package/dist/submit-worker-state.d.ts +17 -0
- package/dist/submit-worker-state.d.ts.map +1 -0
- package/dist/submit-worker-state.js +88 -0
- package/dist/submit-worker-state.js.map +1 -0
- package/dist/submit-worker-state.test.d.ts +2 -0
- package/dist/submit-worker-state.test.d.ts.map +1 -0
- package/dist/submit-worker-state.test.js +45 -0
- package/dist/submit-worker-state.test.js.map +1 -0
- package/package.json +2 -2
- package/src/cli.ts +10 -1
- package/src/commands/status.ts +22 -0
- package/src/commands/submit.test.ts +64 -0
- package/src/commands/submit.ts +189 -25
- package/src/config.ts +8 -0
- package/src/flush.ts +19 -4
- package/src/submit-worker-state.test.ts +61 -0
- package/src/submit-worker-state.ts +127 -0
package/src/commands/submit.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { existsSync } from "fs";
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "fs";
|
|
2
2
|
import { readFile } from "fs/promises";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { join } from "path";
|
|
3
5
|
import chalk from "chalk";
|
|
4
6
|
import ora from "ora";
|
|
5
7
|
import inquirer from "inquirer";
|
|
6
8
|
import { extractClaudeCode, extractCodex, extractCursor, type NormalizedTrace } from "@tracemarketplace/shared";
|
|
7
|
-
import { loadConfig, loadState, resolveProfile, stateKey } from "../config.js";
|
|
9
|
+
import { getConfigDir, getSubmitLogPath, loadConfig, loadState, resolveProfile, stateKey } from "../config.js";
|
|
8
10
|
import { loginCommandForProfile } from "../constants.js";
|
|
9
11
|
import {
|
|
10
12
|
buildCursorSessionSource,
|
|
@@ -16,6 +18,7 @@ import {
|
|
|
16
18
|
type SessionSource,
|
|
17
19
|
} from "../flush.js";
|
|
18
20
|
import { CURSOR_DB_PATH, findFiles } from "../sessions.js";
|
|
21
|
+
import { createSubmitWorkerState, removeSubmitWorkerState, writeSubmitWorkerState } from "../submit-worker-state.js";
|
|
19
22
|
|
|
20
23
|
interface SubmitOptions {
|
|
21
24
|
profile?: string;
|
|
@@ -23,6 +26,7 @@ interface SubmitOptions {
|
|
|
23
26
|
session?: string;
|
|
24
27
|
dryRun?: boolean;
|
|
25
28
|
since?: string;
|
|
29
|
+
sync?: boolean;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
interface DiscoveredSession {
|
|
@@ -35,6 +39,18 @@ interface PlannedSession extends DiscoveredSession {
|
|
|
35
39
|
pending: boolean;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
interface SubmitWorkerOptions {
|
|
43
|
+
profile?: string;
|
|
44
|
+
manifest: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SubmitWorkerManifest {
|
|
48
|
+
createdAt: string;
|
|
49
|
+
readyChunkCount: number;
|
|
50
|
+
readySessionCount: number;
|
|
51
|
+
sources: SessionSource[];
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
function pushSessionIfNonEmpty(
|
|
39
55
|
sessions: DiscoveredSession[],
|
|
40
56
|
source: SessionSource,
|
|
@@ -268,41 +284,128 @@ export async function submitCommand(opts: SubmitOptions): Promise<void> {
|
|
|
268
284
|
return;
|
|
269
285
|
}
|
|
270
286
|
|
|
271
|
-
const
|
|
287
|
+
const readySessions = plannedSessions.filter((session) => session.readyChunks > 0);
|
|
272
288
|
|
|
273
|
-
|
|
274
|
-
const
|
|
289
|
+
if (opts.sync) {
|
|
290
|
+
const uploadSpinner = ora(`Submitting ${readyChunkCount} finalized chunk(s) to ${config.profile}...`).start();
|
|
275
291
|
const prefetchedTraces = new Map(readySessions.map((s) => [`${s.source.tool}:${s.source.locator}`, s.trace]));
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
292
|
+
try {
|
|
293
|
+
const result = await flushTrackedSessions(
|
|
294
|
+
config,
|
|
295
|
+
readySessions.map((session) => session.source),
|
|
296
|
+
{ includeIdleTracked: false, prefetchedTraces, sync: true }
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
uploadSpinner.stop();
|
|
300
|
+
const failedSessions = result.results.filter((session) => session.error && session.error !== "Empty session");
|
|
301
|
+
console.log(chalk.green("\nSubmission complete!"));
|
|
302
|
+
console.log(` Uploaded chunks: ${chalk.bold(result.uploadedChunks)}`);
|
|
303
|
+
console.log(` Duplicate chunks: ${chalk.gray(result.duplicateChunks)}`);
|
|
304
|
+
console.log(` Pending sessions: ${result.pendingSessions}`);
|
|
305
|
+
console.log(` Payout: ${chalk.green("$" + (result.payoutCents / 100).toFixed(2))}`);
|
|
306
|
+
if (failedSessions.length > 0) {
|
|
307
|
+
console.log(chalk.yellow(`\n${failedSessions.length} session(s) failed during submit:`));
|
|
308
|
+
failedSessions.slice(0, 3).forEach((session) => {
|
|
309
|
+
console.log(chalk.gray(` ${session.source.label}: ${session.error}`));
|
|
310
|
+
});
|
|
311
|
+
if (failedSessions.length > 3) {
|
|
312
|
+
console.log(chalk.gray(` ...and ${failedSessions.length - 3} more`));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch (e) {
|
|
316
|
+
uploadSpinner.fail("Submission failed");
|
|
317
|
+
console.error(chalk.red(String(e)));
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const launchSpinner = ora(`Starting background submit for ${readyChunkCount} finalized chunk(s)...`).start();
|
|
281
324
|
|
|
282
|
-
|
|
325
|
+
try {
|
|
326
|
+
const { logPath, pid } = spawnDetachedSubmitWorker(config.profile, {
|
|
327
|
+
createdAt: new Date().toISOString(),
|
|
328
|
+
readyChunkCount,
|
|
329
|
+
readySessionCount,
|
|
330
|
+
sources: readySessions.map((session) => session.source),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
launchSpinner.stop();
|
|
334
|
+
console.log(chalk.green(`\nStarted background submit for ${readyChunkCount} chunk(s).`));
|
|
335
|
+
if (pid) {
|
|
336
|
+
console.log(chalk.gray(` Worker PID: ${pid}`));
|
|
337
|
+
}
|
|
338
|
+
console.log(chalk.gray(` Log: ${logPath}`));
|
|
339
|
+
console.log(chalk.gray(" Run `tracemp status` later to check confirmation status."));
|
|
340
|
+
} catch (e) {
|
|
341
|
+
launchSpinner.fail("Could not start background submit");
|
|
342
|
+
console.error(chalk.red(String(e)));
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
283
346
|
|
|
347
|
+
export async function submitWorkerCommand(opts: SubmitWorkerOptions): Promise<void> {
|
|
348
|
+
const profile = resolveProfile(opts.profile);
|
|
349
|
+
try {
|
|
350
|
+
const manifest = parseSubmitWorkerManifest(await readFile(opts.manifest, "utf-8"));
|
|
351
|
+
const config = loadConfig(profile);
|
|
352
|
+
if (!config) {
|
|
353
|
+
throw new Error(`Not authenticated for profile '${profile}'. Run: ${loginCommandForProfile(profile)}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
console.log(
|
|
357
|
+
`[${new Date().toISOString()}] submit worker starting profile=${profile} ready_chunks=${manifest.readyChunkCount} sessions=${manifest.readySessionCount}`
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const result = await flushTrackedSessions(config, manifest.sources, { includeIdleTracked: false, sync: false });
|
|
284
361
|
const failedSessions = result.results.filter((session) => session.error && session.error !== "Empty session");
|
|
285
362
|
|
|
286
|
-
console.log(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
console.log(` Pending sessions: ${result.pendingSessions}`);
|
|
290
|
-
console.log(` Payout: ${chalk.green("$" + (result.payoutCents / 100).toFixed(2))}`);
|
|
363
|
+
console.log(
|
|
364
|
+
`[${new Date().toISOString()}] submit worker queued chunks=${result.uploadedChunks} pending_sessions=${result.pendingSessions}`
|
|
365
|
+
);
|
|
291
366
|
|
|
292
367
|
if (failedSessions.length > 0) {
|
|
293
|
-
console.log(
|
|
294
|
-
failedSessions.slice(0,
|
|
295
|
-
console.log(
|
|
368
|
+
console.log(`[${new Date().toISOString()}] ${failedSessions.length} session(s) failed during submit:`);
|
|
369
|
+
failedSessions.slice(0, 10).forEach((session) => {
|
|
370
|
+
console.log(` ${session.source.label}: ${session.error}`);
|
|
296
371
|
});
|
|
297
|
-
if (failedSessions.length >
|
|
298
|
-
console.log(
|
|
372
|
+
if (failedSessions.length > 10) {
|
|
373
|
+
console.log(` ...and ${failedSessions.length - 10} more`);
|
|
299
374
|
}
|
|
300
375
|
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
376
|
+
} finally {
|
|
377
|
+
removeSubmitWorkerState(profile, process.pid);
|
|
378
|
+
try {
|
|
379
|
+
unlinkSync(opts.manifest);
|
|
380
|
+
} catch {}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function buildSubmitWorkerRuntime(profile: string, manifestPath: string): { command: string; args: string[] } {
|
|
385
|
+
const cliEntrypoint = process.argv[1];
|
|
386
|
+
if (!cliEntrypoint) {
|
|
387
|
+
throw new Error("Unable to resolve tracemp CLI entrypoint for background submit.");
|
|
305
388
|
}
|
|
389
|
+
|
|
390
|
+
const execArgv = process.execArgv.filter((arg) => !arg.startsWith("--inspect"));
|
|
391
|
+
return {
|
|
392
|
+
command: process.execPath,
|
|
393
|
+
args: [...execArgv, cliEntrypoint, "submit-worker", "--profile", profile, "--manifest", manifestPath],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function parseSubmitWorkerManifest(raw: string): SubmitWorkerManifest {
|
|
398
|
+
const parsed = JSON.parse(raw) as Partial<SubmitWorkerManifest>;
|
|
399
|
+
if (!Array.isArray(parsed.sources) || parsed.sources.some((source) => !isSessionSource(source))) {
|
|
400
|
+
throw new Error("Invalid submit worker manifest.");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date(0).toISOString(),
|
|
405
|
+
readyChunkCount: typeof parsed.readyChunkCount === "number" ? parsed.readyChunkCount : parsed.sources.length,
|
|
406
|
+
readySessionCount: typeof parsed.readySessionCount === "number" ? parsed.readySessionCount : parsed.sources.length,
|
|
407
|
+
sources: parsed.sources,
|
|
408
|
+
};
|
|
306
409
|
}
|
|
307
410
|
|
|
308
411
|
function describeSessionStatus(session: PlannedSession): string {
|
|
@@ -311,3 +414,64 @@ function describeSessionStatus(session: PlannedSession): string {
|
|
|
311
414
|
if (session.pending) return "pending";
|
|
312
415
|
return "up-to-date";
|
|
313
416
|
}
|
|
417
|
+
|
|
418
|
+
function isSessionSource(value: unknown): value is SessionSource {
|
|
419
|
+
if (!value || typeof value !== "object") {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const source = value as Record<string, unknown>;
|
|
424
|
+
return (
|
|
425
|
+
(source.tool === "claude_code" || source.tool === "codex_cli" || source.tool === "cursor")
|
|
426
|
+
&& typeof source.locator === "string"
|
|
427
|
+
&& typeof source.label === "string"
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function spawnDetachedSubmitWorker(
|
|
432
|
+
profile: string,
|
|
433
|
+
manifest: SubmitWorkerManifest
|
|
434
|
+
): { logPath: string; pid: number | null } {
|
|
435
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
436
|
+
const manifestPath = join(
|
|
437
|
+
getConfigDir(),
|
|
438
|
+
`submit-job-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}.json`
|
|
439
|
+
);
|
|
440
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
441
|
+
|
|
442
|
+
const logPath = getSubmitLogPath(profile);
|
|
443
|
+
const logFd = openSync(logPath, "a");
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const { command, args } = buildSubmitWorkerRuntime(profile, manifestPath);
|
|
447
|
+
const child = spawn(command, args, {
|
|
448
|
+
cwd: process.cwd(),
|
|
449
|
+
detached: true,
|
|
450
|
+
env: process.env,
|
|
451
|
+
stdio: ["ignore", logFd, logFd],
|
|
452
|
+
});
|
|
453
|
+
child.unref();
|
|
454
|
+
|
|
455
|
+
if (child.pid) {
|
|
456
|
+
writeSubmitWorkerState(
|
|
457
|
+
profile,
|
|
458
|
+
createSubmitWorkerState(
|
|
459
|
+
profile,
|
|
460
|
+
child.pid,
|
|
461
|
+
manifest.createdAt,
|
|
462
|
+
manifest.readyChunkCount,
|
|
463
|
+
manifest.readySessionCount,
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { logPath, pid: child.pid ?? null };
|
|
469
|
+
} catch (err) {
|
|
470
|
+
try {
|
|
471
|
+
unlinkSync(manifestPath);
|
|
472
|
+
} catch {}
|
|
473
|
+
throw err;
|
|
474
|
+
} finally {
|
|
475
|
+
closeSync(logFd);
|
|
476
|
+
}
|
|
477
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -60,6 +60,14 @@ export function getAutoSubmitLogPath(profile?: string): string {
|
|
|
60
60
|
return join(getConfigDir(), `auto-submit${profileSuffix(profile)}.log`);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export function getSubmitLogPath(profile?: string): string {
|
|
64
|
+
return join(getConfigDir(), `submit${profileSuffix(profile)}.log`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getSubmitWorkerStateDir(profile?: string): string {
|
|
68
|
+
return join(getConfigDir(), `submit-workers${profileSuffix(profile)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
export function getDaemonStatePath(profile?: string): string {
|
|
64
72
|
return join(getConfigDir(), `daemon-state${profileSuffix(profile)}.json`);
|
|
65
73
|
}
|
package/src/flush.ts
CHANGED
|
@@ -141,7 +141,7 @@ export async function verifyUnconfirmedChunks(
|
|
|
141
141
|
export async function flushTrackedSessions(
|
|
142
142
|
config: Config,
|
|
143
143
|
sources: SessionSource[],
|
|
144
|
-
opts: { includeIdleTracked?: boolean; now?: Date; prefetchedTraces?: Map<string, NormalizedTrace
|
|
144
|
+
opts: { includeIdleTracked?: boolean; now?: Date; prefetchedTraces?: Map<string, NormalizedTrace>; sync?: boolean } = {}
|
|
145
145
|
): Promise<FlushResult> {
|
|
146
146
|
const now = opts.now ?? new Date();
|
|
147
147
|
const state = loadState(config.profile);
|
|
@@ -165,6 +165,7 @@ export async function flushTrackedSessions(
|
|
|
165
165
|
allSources.map((source) => () => processSessionSource(
|
|
166
166
|
source, state, config, client, now,
|
|
167
167
|
opts.prefetchedTraces?.get(`${source.tool}:${source.locator}`),
|
|
168
|
+
opts.sync,
|
|
168
169
|
)),
|
|
169
170
|
INGEST_CONCURRENCY,
|
|
170
171
|
);
|
|
@@ -278,6 +279,7 @@ async function processSessionSource(
|
|
|
278
279
|
client: ApiClient,
|
|
279
280
|
now: Date,
|
|
280
281
|
prefetchedTrace?: NormalizedTrace,
|
|
282
|
+
sync = false,
|
|
281
283
|
): Promise<SessionFlushResult> {
|
|
282
284
|
let trace: NormalizedTrace;
|
|
283
285
|
|
|
@@ -329,8 +331,9 @@ async function processSessionSource(
|
|
|
329
331
|
|
|
330
332
|
for (const upload of plan.uploads) {
|
|
331
333
|
const tUpload = Date.now();
|
|
332
|
-
const result = await uploadTraceChunk(upload.trace, client);
|
|
333
|
-
console.error(`[flush] ${key} chunk${upload.trace.chunk_index}
|
|
334
|
+
const result = await uploadTraceChunk(upload.trace, client, sync);
|
|
335
|
+
if (!sync) console.error(`[flush] ${key} chunk${upload.trace.chunk_index} queued in ${Date.now()-tUpload}ms`);
|
|
336
|
+
else console.error(`[flush] ${key} chunk${upload.trace.chunk_index} done in ${Date.now()-tUpload}ms err=${result.error?.slice(0,60) ?? 'none'}`);
|
|
334
337
|
if (result.error) {
|
|
335
338
|
state.sessions[key] = workingState;
|
|
336
339
|
if (workingState.nextChunkIndex > 0) {
|
|
@@ -393,10 +396,22 @@ async function extractTraceFromSource(
|
|
|
393
396
|
|
|
394
397
|
async function uploadTraceChunk(
|
|
395
398
|
trace: NormalizedTrace,
|
|
396
|
-
client: ApiClient
|
|
399
|
+
client: ApiClient,
|
|
400
|
+
sync = false,
|
|
397
401
|
): Promise<ChunkUploadResult> {
|
|
398
402
|
// Client-side regex redaction runs before transmission; Presidio runs server-side async.
|
|
399
403
|
const payloadTrace = redactTrace(trace, { homeDir: homedir() });
|
|
404
|
+
const jsonSize = JSON.stringify({ trace: payloadTrace, source_tool: payloadTrace.source_tool }).length;
|
|
405
|
+
console.error(`[upload] ${payloadTrace.source_session_id} payload=${Math.round(jsonSize/1024)}KB turns=${payloadTrace.turn_count}`);
|
|
406
|
+
|
|
407
|
+
if (!sync) {
|
|
408
|
+
// Fire and forget — optimistically treat as queued; verifyUnconfirmedChunks confirms later
|
|
409
|
+
client.post("/api/v1/traces/ingest", {
|
|
410
|
+
trace: payloadTrace,
|
|
411
|
+
source_tool: payloadTrace.source_tool,
|
|
412
|
+
}).catch(() => { /* errors handled by unconfirmed retry mechanism */ });
|
|
413
|
+
return { duplicate: false, payoutCents: 0, traceId: null };
|
|
414
|
+
}
|
|
400
415
|
|
|
401
416
|
try {
|
|
402
417
|
const result = await client.post("/api/v1/traces/ingest", {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import {
|
|
6
|
+
createSubmitWorkerState,
|
|
7
|
+
listActiveSubmitWorkers,
|
|
8
|
+
removeSubmitWorkerState,
|
|
9
|
+
writeSubmitWorkerState,
|
|
10
|
+
} from "./submit-worker-state.js";
|
|
11
|
+
|
|
12
|
+
describe("submit worker state", () => {
|
|
13
|
+
const tempDirs: string[] = [];
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("lists active workers and prunes stale ones", () => {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), "tracemp-submit-workers-"));
|
|
21
|
+
tempDirs.push(dir);
|
|
22
|
+
|
|
23
|
+
writeSubmitWorkerState("prod", createSubmitWorkerState("prod", 111, "2026-03-21T00:00:00.000Z", 3, 2), { dir });
|
|
24
|
+
writeSubmitWorkerState("prod", createSubmitWorkerState("prod", 222, "2026-03-21T01:00:00.000Z", 1, 1), { dir });
|
|
25
|
+
|
|
26
|
+
vi.spyOn(process, "kill").mockImplementation((pid: number | NodeJS.Signals) => {
|
|
27
|
+
if (pid === 111) {
|
|
28
|
+
return true as never;
|
|
29
|
+
}
|
|
30
|
+
const err = new Error("missing") as NodeJS.ErrnoException;
|
|
31
|
+
err.code = "ESRCH";
|
|
32
|
+
throw err;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const workers = listActiveSubmitWorkers("prod", { dir });
|
|
36
|
+
|
|
37
|
+
expect(workers).toEqual([
|
|
38
|
+
createSubmitWorkerState("prod", 111, "2026-03-21T00:00:00.000Z", 3, 2),
|
|
39
|
+
]);
|
|
40
|
+
expect(() => readFileSync(join(dir, "222.json"), "utf-8")).toThrow();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("removes worker state files", () => {
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), "tracemp-submit-workers-"));
|
|
45
|
+
tempDirs.push(dir);
|
|
46
|
+
|
|
47
|
+
writeSubmitWorkerState("prod", createSubmitWorkerState("prod", 333, "2026-03-21T00:00:00.000Z", 2, 1), { dir });
|
|
48
|
+
removeSubmitWorkerState("prod", 333, { dir });
|
|
49
|
+
|
|
50
|
+
expect(() => readFileSync(join(dir, "333.json"), "utf-8")).toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("prunes invalid state payloads", () => {
|
|
54
|
+
const dir = mkdtempSync(join(tmpdir(), "tracemp-submit-workers-"));
|
|
55
|
+
tempDirs.push(dir);
|
|
56
|
+
writeFileSync(join(dir, "bad.json"), JSON.stringify({ nope: true }), "utf-8");
|
|
57
|
+
|
|
58
|
+
expect(listActiveSubmitWorkers("prod", { dir })).toEqual([]);
|
|
59
|
+
expect(() => readFileSync(join(dir, "bad.json"), "utf-8")).toThrow();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { getSubmitLogPath, getSubmitWorkerStateDir } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export interface SubmitWorkerState {
|
|
6
|
+
pid: number;
|
|
7
|
+
startedAt: string;
|
|
8
|
+
readyChunkCount: number;
|
|
9
|
+
readySessionCount: number;
|
|
10
|
+
logPath: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SubmitWorkerStateOptions {
|
|
14
|
+
dir?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isProcessRunning(pid: number): boolean {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function writeSubmitWorkerState(
|
|
27
|
+
profile: string,
|
|
28
|
+
state: SubmitWorkerState,
|
|
29
|
+
opts: SubmitWorkerStateOptions = {},
|
|
30
|
+
): void {
|
|
31
|
+
const dir = resolveStateDir(profile, opts.dir);
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
writeFileSync(join(dir, `${state.pid}.json`), JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function removeSubmitWorkerState(
|
|
37
|
+
profile: string,
|
|
38
|
+
pid: number,
|
|
39
|
+
opts: SubmitWorkerStateOptions = {},
|
|
40
|
+
): void {
|
|
41
|
+
const dir = resolveStateDir(profile, opts.dir);
|
|
42
|
+
const path = join(dir, `${pid}.json`);
|
|
43
|
+
if (!existsSync(path)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
unlinkSync(path);
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function listActiveSubmitWorkers(
|
|
53
|
+
profile: string,
|
|
54
|
+
opts: SubmitWorkerStateOptions = {},
|
|
55
|
+
): SubmitWorkerState[] {
|
|
56
|
+
const dir = resolveStateDir(profile, opts.dir);
|
|
57
|
+
if (!existsSync(dir)) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const workers: SubmitWorkerState[] = [];
|
|
62
|
+
for (const entry of readdirSync(dir)) {
|
|
63
|
+
if (!entry.endsWith(".json")) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const path = join(dir, entry);
|
|
68
|
+
const parsed = readSubmitWorkerStateFile(path);
|
|
69
|
+
if (!parsed || !isProcessRunning(parsed.pid)) {
|
|
70
|
+
try {
|
|
71
|
+
unlinkSync(path);
|
|
72
|
+
} catch {}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
workers.push(parsed);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return workers.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createSubmitWorkerState(
|
|
83
|
+
profile: string,
|
|
84
|
+
pid: number,
|
|
85
|
+
startedAt: string,
|
|
86
|
+
readyChunkCount: number,
|
|
87
|
+
readySessionCount: number,
|
|
88
|
+
): SubmitWorkerState {
|
|
89
|
+
return {
|
|
90
|
+
pid,
|
|
91
|
+
startedAt,
|
|
92
|
+
readyChunkCount,
|
|
93
|
+
readySessionCount,
|
|
94
|
+
logPath: getSubmitLogPath(profile),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveStateDir(profile: string, dir?: string): string {
|
|
99
|
+
return dir ?? getSubmitWorkerStateDir(profile);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readSubmitWorkerStateFile(path: string): SubmitWorkerState | null {
|
|
103
|
+
try {
|
|
104
|
+
const value = JSON.parse(readFileSync(path, "utf-8")) as Partial<SubmitWorkerState>;
|
|
105
|
+
if (
|
|
106
|
+
typeof value.pid !== "number"
|
|
107
|
+
|| !Number.isInteger(value.pid)
|
|
108
|
+
|| value.pid <= 0
|
|
109
|
+
|| typeof value.startedAt !== "string"
|
|
110
|
+
|| typeof value.readyChunkCount !== "number"
|
|
111
|
+
|| typeof value.readySessionCount !== "number"
|
|
112
|
+
|| typeof value.logPath !== "string"
|
|
113
|
+
) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
pid: value.pid,
|
|
119
|
+
startedAt: value.startedAt,
|
|
120
|
+
readyChunkCount: value.readyChunkCount,
|
|
121
|
+
readySessionCount: value.readySessionCount,
|
|
122
|
+
logPath: value.logPath,
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|