@tracemarketplace/cli 0.0.18 → 0.0.20
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 +11 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/history.d.ts.map +1 -1
- package/dist/commands/history.js +26 -8
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/status.d.ts +14 -2
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +50 -6
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/status.test.d.ts +2 -0
- package/dist/commands/status.test.d.ts.map +1 -0
- package/dist/commands/status.test.js +54 -0
- package/dist/commands/status.test.js.map +1 -0
- package/dist/commands/submit.d.ts +17 -0
- package/dist/commands/submit.d.ts.map +1 -1
- package/dist/commands/submit.js +126 -14
- 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/submission-status.d.ts +14 -0
- package/dist/submission-status.d.ts.map +1 -0
- package/dist/submission-status.js +31 -0
- package/dist/submission-status.js.map +1 -0
- package/dist/submission-status.test.d.ts +2 -0
- package/dist/submission-status.test.d.ts.map +1 -0
- package/dist/submission-status.test.js +33 -0
- package/dist/submission-status.test.js.map +1 -0
- 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 +12 -4
- package/src/commands/history.ts +29 -10
- package/src/commands/status.test.ts +57 -0
- package/src/commands/status.ts +88 -9
- package/src/commands/submit.test.ts +64 -0
- package/src/commands/submit.ts +177 -21
- package/src/config.ts +8 -0
- package/src/submission-status.test.ts +35 -0
- package/src/submission-status.ts +48 -0
- package/src/submit-worker-state.test.ts +61 -0
- package/src/submit-worker-state.ts +127 -0
package/src/commands/status.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { loadConfig, resolveProfile } from "../config.js";
|
|
2
|
+
import { loadConfig, loadState, migrateSessionUploadState, resolveProfile, type SubmitState } from "../config.js";
|
|
3
3
|
import { ApiClient } from "../api-client.js";
|
|
4
4
|
import { loginCommandForProfile } from "../constants.js";
|
|
5
|
+
import { listActiveSubmitWorkers, type SubmitWorkerState } from "../submit-worker-state.js";
|
|
6
|
+
import { formatRemoteSubmissionDisplay, isSubmissionPipelinePending } from "../submission-status.js";
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
interface StatusOptions {
|
|
9
|
+
profile?: string;
|
|
10
|
+
remote?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface LocalSubmitStatusSummary {
|
|
14
|
+
trackedSessions: number;
|
|
15
|
+
pendingSessions: number;
|
|
16
|
+
awaitingConfirmationSessions: number;
|
|
17
|
+
awaitingConfirmationChunks: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function statusCommand(opts: StatusOptions = {}): Promise<void> {
|
|
7
21
|
const profile = resolveProfile(opts.profile);
|
|
8
22
|
const config = loadConfig(profile);
|
|
9
23
|
if (!config) {
|
|
@@ -11,20 +25,85 @@ export async function statusCommand(opts: { profile?: string } = {}): Promise<vo
|
|
|
11
25
|
process.exit(1);
|
|
12
26
|
}
|
|
13
27
|
|
|
28
|
+
const localState = loadState(config.profile);
|
|
29
|
+
const activeWorkers = listActiveSubmitWorkers(config.profile);
|
|
30
|
+
const summary = summarizeLocalSubmitState(localState, activeWorkers);
|
|
31
|
+
|
|
32
|
+
console.log(chalk.gray("Profile:"), config.profile);
|
|
33
|
+
console.log(
|
|
34
|
+
chalk.bold("Local upload worker:"),
|
|
35
|
+
activeWorkers.length > 0
|
|
36
|
+
? chalk.yellow(`running (${activeWorkers.length})`)
|
|
37
|
+
: chalk.gray("idle"),
|
|
38
|
+
);
|
|
39
|
+
console.log(chalk.bold("Tracked sessions:"), summary.trackedSessions);
|
|
40
|
+
console.log(chalk.bold("Sessions still in progress:"), summary.pendingSessions);
|
|
41
|
+
console.log(
|
|
42
|
+
chalk.bold("Queued uploads awaiting server confirmation:"),
|
|
43
|
+
`${summary.awaitingConfirmationChunks} chunk(s) across ${summary.awaitingConfirmationSessions} session(s)`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (activeWorkers.length > 0) {
|
|
47
|
+
activeWorkers.slice(0, 3).forEach((worker) => {
|
|
48
|
+
console.log(
|
|
49
|
+
chalk.gray(
|
|
50
|
+
` pid ${worker.pid} started ${worker.startedAt} ready ${worker.readyChunkCount} chunk(s) from ${worker.readySessionCount} session(s)`
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
if (activeWorkers.length > 3) {
|
|
55
|
+
console.log(chalk.gray(` ...and ${activeWorkers.length - 3} more worker(s)`));
|
|
56
|
+
}
|
|
57
|
+
console.log(chalk.gray(` Log: ${activeWorkers[0]!.logPath}`));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!opts.remote) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
14
64
|
const client = new ApiClient(config.serverUrl, config.apiKey);
|
|
15
65
|
const [me, subs] = await Promise.all([
|
|
16
66
|
client.get("/api/v1/me") as Promise<{ email: string; balanceCents?: number; balance_cents?: number }>,
|
|
17
67
|
client.get("/api/v1/submissions") as Promise<any[]>,
|
|
18
68
|
]);
|
|
19
|
-
|
|
69
|
+
const sortedSubs = [...(subs as any[])].sort(
|
|
70
|
+
(a, b) => Date.parse(String(b.submittedAt)) - Date.parse(String(a.submittedAt)),
|
|
71
|
+
);
|
|
20
72
|
const balance = ((me as any).balanceCents ?? (me as any).balance_cents ?? 0) / 100;
|
|
21
|
-
|
|
22
|
-
console.log(chalk.gray("Server:"), config.serverUrl);
|
|
23
|
-
console.log(chalk.bold("Balance:"), chalk.green(`$${balance.toFixed(2)}`));
|
|
24
|
-
console.log(chalk.bold("Submissions:"), (subs as any[]).length);
|
|
73
|
+
const pending = sortedSubs.filter((s) => isSubmissionPipelinePending(s));
|
|
25
74
|
|
|
26
|
-
|
|
75
|
+
console.log();
|
|
76
|
+
console.log(chalk.gray("Remote server:"), config.serverUrl);
|
|
77
|
+
console.log(chalk.bold("Balance:"), chalk.green(`$${balance.toFixed(2)}`));
|
|
78
|
+
console.log(chalk.bold("Submission batches:"), sortedSubs.length);
|
|
27
79
|
if (pending.length > 0) {
|
|
28
|
-
console.log(chalk.yellow(
|
|
80
|
+
console.log(chalk.yellow(`Server data pipeline: processing ${pending.length} submission batch(es)`));
|
|
81
|
+
} else if (sortedSubs.length > 0) {
|
|
82
|
+
console.log(chalk.green("Server data pipeline: idle"));
|
|
29
83
|
}
|
|
84
|
+
|
|
85
|
+
if (sortedSubs.length > 0) {
|
|
86
|
+
const latest = formatRemoteSubmissionDisplay(sortedSubs[0]);
|
|
87
|
+
console.log(chalk.gray(`Latest batch: ${latest.statusLabel} (${latest.ingestedLabel})`));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function summarizeLocalSubmitState(
|
|
92
|
+
state: SubmitState,
|
|
93
|
+
_activeWorkers: SubmitWorkerState[],
|
|
94
|
+
): LocalSubmitStatusSummary {
|
|
95
|
+
const sessions = Object.values(state.sessions).map((session) => migrateSessionUploadState(session));
|
|
96
|
+
const pendingSessions = sessions.filter((session) => session.openChunkStartTurn < session.lastSeenTurnCount).length;
|
|
97
|
+
const awaitingConfirmationSessions = sessions.filter((session) => session.confirmedChunkIndex < session.nextChunkIndex).length;
|
|
98
|
+
const awaitingConfirmationChunks = sessions.reduce(
|
|
99
|
+
(sum, session) => sum + Math.max(0, session.nextChunkIndex - session.confirmedChunkIndex),
|
|
100
|
+
0,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
trackedSessions: sessions.length,
|
|
105
|
+
pendingSessions,
|
|
106
|
+
awaitingConfirmationSessions,
|
|
107
|
+
awaitingConfirmationChunks,
|
|
108
|
+
};
|
|
30
109
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { buildSubmitWorkerRuntime, parseSubmitWorkerManifest } from "./submit.js";
|
|
3
|
+
|
|
4
|
+
const originalArgv = [...process.argv];
|
|
5
|
+
const originalExecArgv = [...process.execArgv];
|
|
6
|
+
|
|
7
|
+
describe("parseSubmitWorkerManifest", () => {
|
|
8
|
+
it("accepts valid session sources", () => {
|
|
9
|
+
const manifest = parseSubmitWorkerManifest(JSON.stringify({
|
|
10
|
+
createdAt: "2026-03-21T00:00:00.000Z",
|
|
11
|
+
readyChunkCount: 3,
|
|
12
|
+
readySessionCount: 2,
|
|
13
|
+
sources: [
|
|
14
|
+
{
|
|
15
|
+
tool: "codex_cli",
|
|
16
|
+
locator: "/tmp/session.jsonl",
|
|
17
|
+
label: "codex_cli:/tmp/session.jsonl",
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
expect(manifest.readyChunkCount).toBe(3);
|
|
23
|
+
expect(manifest.readySessionCount).toBe(2);
|
|
24
|
+
expect(manifest.sources).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects invalid source payloads", () => {
|
|
28
|
+
expect(() => parseSubmitWorkerManifest(JSON.stringify({
|
|
29
|
+
sources: [{ tool: "not-a-tool", locator: 123, label: null }],
|
|
30
|
+
}))).toThrow("Invalid submit worker manifest.");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("buildSubmitWorkerRuntime", () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
process.argv = [...originalArgv];
|
|
37
|
+
Object.defineProperty(process, "execArgv", {
|
|
38
|
+
configurable: true,
|
|
39
|
+
value: [...originalExecArgv],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("targets the hidden submit-worker command and strips inspect flags", () => {
|
|
44
|
+
process.argv = [process.execPath, "/tmp/tracemp-cli.js"];
|
|
45
|
+
Object.defineProperty(process, "execArgv", {
|
|
46
|
+
configurable: true,
|
|
47
|
+
value: ["--inspect=127.0.0.1:9229", "--loader", "tsx"],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(buildSubmitWorkerRuntime("prod", "/tmp/submit-job.json")).toEqual({
|
|
51
|
+
command: process.execPath,
|
|
52
|
+
args: [
|
|
53
|
+
"--loader",
|
|
54
|
+
"tsx",
|
|
55
|
+
"/tmp/tracemp-cli.js",
|
|
56
|
+
"submit-worker",
|
|
57
|
+
"--profile",
|
|
58
|
+
"prod",
|
|
59
|
+
"--manifest",
|
|
60
|
+
"/tmp/submit-job.json",
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
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;
|
|
@@ -36,6 +39,18 @@ interface PlannedSession extends DiscoveredSession {
|
|
|
36
39
|
pending: boolean;
|
|
37
40
|
}
|
|
38
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
|
+
|
|
39
54
|
function pushSessionIfNonEmpty(
|
|
40
55
|
sessions: DiscoveredSession[],
|
|
41
56
|
source: SessionSource,
|
|
@@ -269,24 +284,19 @@ export async function submitCommand(opts: SubmitOptions): Promise<void> {
|
|
|
269
284
|
return;
|
|
270
285
|
}
|
|
271
286
|
|
|
272
|
-
const
|
|
273
|
-
opts.sync
|
|
274
|
-
? `Submitting ${readyChunkCount} finalized chunk(s) to ${config.profile}...`
|
|
275
|
-
: `Queuing ${readyChunkCount} finalized chunk(s) to ${config.profile}...`
|
|
276
|
-
).start();
|
|
287
|
+
const readySessions = plannedSessions.filter((session) => session.readyChunks > 0);
|
|
277
288
|
|
|
278
|
-
|
|
279
|
-
const
|
|
289
|
+
if (opts.sync) {
|
|
290
|
+
const uploadSpinner = ora(`Submitting ${readyChunkCount} finalized chunk(s) to ${config.profile}...`).start();
|
|
280
291
|
const prefetchedTraces = new Map(readySessions.map((s) => [`${s.source.tool}:${s.source.locator}`, s.trace]));
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
uploadSpinner.stop();
|
|
292
|
+
try {
|
|
293
|
+
const result = await flushTrackedSessions(
|
|
294
|
+
config,
|
|
295
|
+
readySessions.map((session) => session.source),
|
|
296
|
+
{ includeIdleTracked: false, prefetchedTraces, sync: true }
|
|
297
|
+
);
|
|
288
298
|
|
|
289
|
-
|
|
299
|
+
uploadSpinner.stop();
|
|
290
300
|
const failedSessions = result.results.filter((session) => session.error && session.error !== "Empty session");
|
|
291
301
|
console.log(chalk.green("\nSubmission complete!"));
|
|
292
302
|
console.log(` Uploaded chunks: ${chalk.bold(result.uploadedChunks)}`);
|
|
@@ -302,20 +312,166 @@ export async function submitCommand(opts: SubmitOptions): Promise<void> {
|
|
|
302
312
|
console.log(chalk.gray(` ...and ${failedSessions.length - 3} more`));
|
|
303
313
|
}
|
|
304
314
|
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
console.
|
|
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();
|
|
324
|
+
|
|
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}`));
|
|
308
337
|
}
|
|
338
|
+
console.log(chalk.gray(` Log: ${logPath}`));
|
|
339
|
+
console.log(chalk.gray(" Run `tracemp status` later to check confirmation status."));
|
|
309
340
|
} catch (e) {
|
|
310
|
-
|
|
341
|
+
launchSpinner.fail("Could not start background submit");
|
|
311
342
|
console.error(chalk.red(String(e)));
|
|
312
343
|
process.exit(1);
|
|
313
344
|
}
|
|
314
345
|
}
|
|
315
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 });
|
|
361
|
+
const failedSessions = result.results.filter((session) => session.error && session.error !== "Empty session");
|
|
362
|
+
|
|
363
|
+
console.log(
|
|
364
|
+
`[${new Date().toISOString()}] submit worker queued chunks=${result.uploadedChunks} pending_sessions=${result.pendingSessions}`
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
if (failedSessions.length > 0) {
|
|
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}`);
|
|
371
|
+
});
|
|
372
|
+
if (failedSessions.length > 10) {
|
|
373
|
+
console.log(` ...and ${failedSessions.length - 10} more`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
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.");
|
|
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
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
316
411
|
function describeSessionStatus(session: PlannedSession): string {
|
|
317
412
|
if (session.readyChunks > 0 && session.pending) return "ready + pending";
|
|
318
413
|
if (session.readyChunks > 0) return "ready";
|
|
319
414
|
if (session.pending) return "pending";
|
|
320
415
|
return "up-to-date";
|
|
321
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
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatRemoteSubmissionDisplay, isSubmissionPipelinePending } from "./submission-status.js";
|
|
3
|
+
|
|
4
|
+
describe("submission status display", () => {
|
|
5
|
+
it("describes pipeline-pending submissions clearly", () => {
|
|
6
|
+
expect(formatRemoteSubmissionDisplay({
|
|
7
|
+
sessionCount: 5,
|
|
8
|
+
acceptedCount: null,
|
|
9
|
+
totalPayoutCents: null,
|
|
10
|
+
})).toEqual({
|
|
11
|
+
ingestedLabel: "—/5",
|
|
12
|
+
statusLabel: "pending data pipeline",
|
|
13
|
+
payoutLabel: "processing",
|
|
14
|
+
pendingPipeline: true,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("describes fully ingested submissions clearly", () => {
|
|
19
|
+
expect(formatRemoteSubmissionDisplay({
|
|
20
|
+
sessionCount: 5,
|
|
21
|
+
acceptedCount: 5,
|
|
22
|
+
totalPayoutCents: 125,
|
|
23
|
+
})).toEqual({
|
|
24
|
+
ingestedLabel: "5/5",
|
|
25
|
+
statusLabel: "ingested successfully",
|
|
26
|
+
payoutLabel: "$1.25",
|
|
27
|
+
pendingPipeline: false,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("detects whether a remote submission is still in the pipeline", () => {
|
|
32
|
+
expect(isSubmissionPipelinePending({ sessionCount: 1, acceptedCount: null, totalPayoutCents: null })).toBe(true);
|
|
33
|
+
expect(isSubmissionPipelinePending({ sessionCount: 1, acceptedCount: 1, totalPayoutCents: 0 })).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface RemoteSubmissionLike {
|
|
2
|
+
sessionCount: number;
|
|
3
|
+
acceptedCount: number | null;
|
|
4
|
+
totalPayoutCents: number | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RemoteSubmissionDisplay {
|
|
8
|
+
ingestedLabel: string;
|
|
9
|
+
statusLabel: string;
|
|
10
|
+
payoutLabel: string;
|
|
11
|
+
pendingPipeline: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isSubmissionPipelinePending(submission: RemoteSubmissionLike): boolean {
|
|
15
|
+
return submission.acceptedCount === null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatRemoteSubmissionDisplay(submission: RemoteSubmissionLike): RemoteSubmissionDisplay {
|
|
19
|
+
if (submission.acceptedCount === null) {
|
|
20
|
+
return {
|
|
21
|
+
ingestedLabel: `—/${submission.sessionCount}`,
|
|
22
|
+
statusLabel: "pending data pipeline",
|
|
23
|
+
payoutLabel: "processing",
|
|
24
|
+
pendingPipeline: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const payoutLabel =
|
|
29
|
+
submission.totalPayoutCents !== null
|
|
30
|
+
? "$" + (submission.totalPayoutCents / 100).toFixed(2)
|
|
31
|
+
: "pending";
|
|
32
|
+
|
|
33
|
+
if (submission.acceptedCount === submission.sessionCount) {
|
|
34
|
+
return {
|
|
35
|
+
ingestedLabel: `${submission.acceptedCount}/${submission.sessionCount}`,
|
|
36
|
+
statusLabel: "ingested successfully",
|
|
37
|
+
payoutLabel,
|
|
38
|
+
pendingPipeline: false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
ingestedLabel: `${submission.acceptedCount}/${submission.sessionCount}`,
|
|
44
|
+
statusLabel: "completed",
|
|
45
|
+
payoutLabel,
|
|
46
|
+
pendingPipeline: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -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
|
+
});
|