@tracemarketplace/cli 0.0.18 → 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.
@@ -0,0 +1,88 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { getSubmitLogPath, getSubmitWorkerStateDir } from "./config.js";
4
+ export function isProcessRunning(pid) {
5
+ try {
6
+ process.kill(pid, 0);
7
+ return true;
8
+ }
9
+ catch (err) {
10
+ return err.code === "EPERM";
11
+ }
12
+ }
13
+ export function writeSubmitWorkerState(profile, state, opts = {}) {
14
+ const dir = resolveStateDir(profile, opts.dir);
15
+ mkdirSync(dir, { recursive: true });
16
+ writeFileSync(join(dir, `${state.pid}.json`), JSON.stringify(state, null, 2) + "\n", "utf-8");
17
+ }
18
+ export function removeSubmitWorkerState(profile, pid, opts = {}) {
19
+ const dir = resolveStateDir(profile, opts.dir);
20
+ const path = join(dir, `${pid}.json`);
21
+ if (!existsSync(path)) {
22
+ return;
23
+ }
24
+ try {
25
+ unlinkSync(path);
26
+ }
27
+ catch { }
28
+ }
29
+ export function listActiveSubmitWorkers(profile, opts = {}) {
30
+ const dir = resolveStateDir(profile, opts.dir);
31
+ if (!existsSync(dir)) {
32
+ return [];
33
+ }
34
+ const workers = [];
35
+ for (const entry of readdirSync(dir)) {
36
+ if (!entry.endsWith(".json")) {
37
+ continue;
38
+ }
39
+ const path = join(dir, entry);
40
+ const parsed = readSubmitWorkerStateFile(path);
41
+ if (!parsed || !isProcessRunning(parsed.pid)) {
42
+ try {
43
+ unlinkSync(path);
44
+ }
45
+ catch { }
46
+ continue;
47
+ }
48
+ workers.push(parsed);
49
+ }
50
+ return workers.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
51
+ }
52
+ export function createSubmitWorkerState(profile, pid, startedAt, readyChunkCount, readySessionCount) {
53
+ return {
54
+ pid,
55
+ startedAt,
56
+ readyChunkCount,
57
+ readySessionCount,
58
+ logPath: getSubmitLogPath(profile),
59
+ };
60
+ }
61
+ function resolveStateDir(profile, dir) {
62
+ return dir ?? getSubmitWorkerStateDir(profile);
63
+ }
64
+ function readSubmitWorkerStateFile(path) {
65
+ try {
66
+ const value = JSON.parse(readFileSync(path, "utf-8"));
67
+ if (typeof value.pid !== "number"
68
+ || !Number.isInteger(value.pid)
69
+ || value.pid <= 0
70
+ || typeof value.startedAt !== "string"
71
+ || typeof value.readyChunkCount !== "number"
72
+ || typeof value.readySessionCount !== "number"
73
+ || typeof value.logPath !== "string") {
74
+ return null;
75
+ }
76
+ return {
77
+ pid: value.pid,
78
+ startedAt: value.startedAt,
79
+ readyChunkCount: value.readyChunkCount,
80
+ readySessionCount: value.readySessionCount,
81
+ logPath: value.logPath,
82
+ };
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ //# sourceMappingURL=submit-worker-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"submit-worker-state.js","sourceRoot":"","sources":["../src/submit-worker-state.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=submit-worker-state.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"submit-worker-state.test.d.ts","sourceRoot":"","sources":["../src/submit-worker-state.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,45 @@
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 { createSubmitWorkerState, listActiveSubmitWorkers, removeSubmitWorkerState, writeSubmitWorkerState, } from "./submit-worker-state.js";
6
+ describe("submit worker state", () => {
7
+ const tempDirs = [];
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+ it("lists active workers and prunes stale ones", () => {
12
+ const dir = mkdtempSync(join(tmpdir(), "tracemp-submit-workers-"));
13
+ tempDirs.push(dir);
14
+ writeSubmitWorkerState("prod", createSubmitWorkerState("prod", 111, "2026-03-21T00:00:00.000Z", 3, 2), { dir });
15
+ writeSubmitWorkerState("prod", createSubmitWorkerState("prod", 222, "2026-03-21T01:00:00.000Z", 1, 1), { dir });
16
+ vi.spyOn(process, "kill").mockImplementation((pid) => {
17
+ if (pid === 111) {
18
+ return true;
19
+ }
20
+ const err = new Error("missing");
21
+ err.code = "ESRCH";
22
+ throw err;
23
+ });
24
+ const workers = listActiveSubmitWorkers("prod", { dir });
25
+ expect(workers).toEqual([
26
+ createSubmitWorkerState("prod", 111, "2026-03-21T00:00:00.000Z", 3, 2),
27
+ ]);
28
+ expect(() => readFileSync(join(dir, "222.json"), "utf-8")).toThrow();
29
+ });
30
+ it("removes worker state files", () => {
31
+ const dir = mkdtempSync(join(tmpdir(), "tracemp-submit-workers-"));
32
+ tempDirs.push(dir);
33
+ writeSubmitWorkerState("prod", createSubmitWorkerState("prod", 333, "2026-03-21T00:00:00.000Z", 2, 1), { dir });
34
+ removeSubmitWorkerState("prod", 333, { dir });
35
+ expect(() => readFileSync(join(dir, "333.json"), "utf-8")).toThrow();
36
+ });
37
+ it("prunes invalid state payloads", () => {
38
+ const dir = mkdtempSync(join(tmpdir(), "tracemp-submit-workers-"));
39
+ tempDirs.push(dir);
40
+ writeFileSync(join(dir, "bad.json"), JSON.stringify({ nope: true }), "utf-8");
41
+ expect(listActiveSubmitWorkers("prod", { dir })).toEqual([]);
42
+ expect(() => readFileSync(join(dir, "bad.json"), "utf-8")).toThrow();
43
+ });
44
+ });
45
+ //# sourceMappingURL=submit-worker-state.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"submit-worker-state.test.js","sourceRoot":"","sources":["../src/submit-worker-state.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC7D,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,IAAI,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC;AAC5B,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EACL,uBAAuB,EACvB,uBAAuB,EACvB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,0BAA0B,CAAC;AAElC,QAAQ,CAAC,qBAAqB,EAAE,GAAG,EAAE;IACnC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,SAAS,CAAC,GAAG,EAAE;QACb,EAAE,CAAC,eAAe,EAAE,CAAC;IACvB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACpD,MAAM,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,yBAAyB,CAAC,CAAC,CAAC;QACnE,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAEnB,sBAAsB,CAAC,MAAM,EAAE,uBAAuB,CAAC,MAAM,EAAE,GAAG,EAAE,0BAA0B,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QAChH,sBAAsB,CAAC,MAAM,EAAE,uBAAuB,CAAC,MAAM,EAAE,GAAG,EAAE,0BAA0B,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QAEhH,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,kBAAkB,CAAC,CAAC,GAA4B,EAAE,EAAE;YAC5E,IAAI,GAAG,KAAK,GAAG,EAAE,CAAC;gBAChB,OAAO,IAAa,CAAC;YACvB,CAAC;YACD,MAAM,GAAG,GAAG,IAAI,KAAK,CAAC,SAAS,CAA0B,CAAC;YAC1D,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;YACnB,MAAM,GAAG,CAAC;QACZ,CAAC,CAAC,CAAC;QAEH,MAAM,OAAO,GAAG,uBAAuB,CAAC,MAAM,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QAEzD,MAAM,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC;YACtB,uBAAuB,CAAC,MAAM,EAAE,GAAG,EAAE,0BAA0B,EAAE,CAAC,EAAE,CAAC,CAAC;SACvE,CAAC,CAAC;QACH,MAAM,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC;IACvE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,4BAA4B,EAAE,GAAG,EAAE;QACpC,MAAM,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,yBAAyB,CAAC,CAAC,CAAC;QACnE,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAEnB,sBAAsB,CAAC,MAAM,EAAE,uBAAuB,CAAC,MAAM,EAAE,GAAG,EAAE,0BAA0B,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QAChH,uBAAuB,CAAC,MAAM,EAAE,GAAG,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC;QAE9C,MAAM,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC;IACvE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+BAA+B,EAAE,GAAG,EAAE;QACvC,MAAM,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,yBAAyB,CAAC,CAAC,CAAC;QACnE,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACnB,aAAa,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC;QAE9E,MAAM,CAAC,uBAAuB,CAAC,MAAM,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QAC7D,MAAM,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC;IACvE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tracemarketplace/cli",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tracemp": "dist/cli.js"
@@ -13,7 +13,7 @@
13
13
  "test:watch": "vitest"
14
14
  },
15
15
  "dependencies": {
16
- "@tracemarketplace/shared": "^0.0.13",
16
+ "@tracemarketplace/shared": "^0.0.14",
17
17
  "better-sqlite3": "^12.8.0",
18
18
  "chalk": "^5.3.0",
19
19
  "commander": "^12.0.0",
package/src/cli.ts CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
5
5
  import { program } from "commander";
6
6
  import { loginCommand } from "./commands/login.js";
7
7
  import { whoamiCommand } from "./commands/whoami.js";
8
- import { submitCommand } from "./commands/submit.js";
8
+ import { submitCommand, submitWorkerCommand } from "./commands/submit.js";
9
9
  import { statusCommand } from "./commands/status.js";
10
10
  import { historyCommand } from "./commands/history.js";
11
11
  import { autoSubmitCommand } from "./commands/auto-submit.js";
@@ -45,7 +45,7 @@ program
45
45
  .option("--tool <tool>", "Only submit from specific tool (claude-code|codex|cursor)")
46
46
  .option("--session <id>", "Only submit a specific session ID")
47
47
  .option("--dry-run", "Preview without submitting")
48
- .option("--sync", "Wait for server acknowledgement instead of fire-and-forget")
48
+ .option("--sync", "Wait in the foreground instead of launching a background submit worker")
49
49
  .option("--created-since <duration>", "Only include sessions created within duration (e.g. 30d, 12h, 30m)", "30d")
50
50
  .action((opts) =>
51
51
  submitCommand({
@@ -58,6 +58,13 @@ program
58
58
  }).catch(handleError)
59
59
  );
60
60
 
61
+ program
62
+ .command("submit-worker", { hidden: true })
63
+ .description("Background submit worker (internal)")
64
+ .requiredOption("--manifest <path>", "Submit worker manifest path")
65
+ .option("--profile <name>", profileOptionDescription)
66
+ .action((opts) => submitWorkerCommand({ profile: opts.profile, manifest: opts.manifest }).catch(handleError));
67
+
61
68
  program
62
69
  .command("status")
63
70
  .description("Show pending submissions and balance")
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import { loadConfig, resolveProfile } from "../config.js";
3
3
  import { ApiClient } from "../api-client.js";
4
4
  import { loginCommandForProfile } from "../constants.js";
5
+ import { listActiveSubmitWorkers } from "../submit-worker-state.js";
5
6
 
6
7
  export async function statusCommand(opts: { profile?: string } = {}): Promise<void> {
7
8
  const profile = resolveProfile(opts.profile);
@@ -16,13 +17,34 @@ export async function statusCommand(opts: { profile?: string } = {}): Promise<vo
16
17
  client.get("/api/v1/me") as Promise<{ email: string; balanceCents?: number; balance_cents?: number }>,
17
18
  client.get("/api/v1/submissions") as Promise<any[]>,
18
19
  ]);
20
+ const activeWorkers = listActiveSubmitWorkers(config.profile);
19
21
 
20
22
  const balance = ((me as any).balanceCents ?? (me as any).balance_cents ?? 0) / 100;
21
23
  console.log(chalk.gray("Profile:"), config.profile);
22
24
  console.log(chalk.gray("Server:"), config.serverUrl);
25
+ console.log(
26
+ chalk.bold("Background submit:"),
27
+ activeWorkers.length > 0
28
+ ? chalk.yellow(`running (${activeWorkers.length})`)
29
+ : chalk.gray("idle"),
30
+ );
23
31
  console.log(chalk.bold("Balance:"), chalk.green(`$${balance.toFixed(2)}`));
24
32
  console.log(chalk.bold("Submissions:"), (subs as any[]).length);
25
33
 
34
+ if (activeWorkers.length > 0) {
35
+ activeWorkers.slice(0, 3).forEach((worker) => {
36
+ console.log(
37
+ chalk.gray(
38
+ ` pid ${worker.pid} started ${worker.startedAt} ready ${worker.readyChunkCount} chunk(s) from ${worker.readySessionCount} session(s)`
39
+ )
40
+ );
41
+ });
42
+ if (activeWorkers.length > 3) {
43
+ console.log(chalk.gray(` ...and ${activeWorkers.length - 3} more worker(s)`));
44
+ }
45
+ console.log(chalk.gray(` Log: ${activeWorkers[0]!.logPath}`));
46
+ }
47
+
26
48
  const pending = (subs as any[]).filter((s) => s.acceptedCount === null);
27
49
  if (pending.length > 0) {
28
50
  console.log(chalk.yellow(`\n${pending.length} pending submission(s)`));
@@ -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
+ });
@@ -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 uploadSpinner = ora(
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
- try {
279
- const readySessions = plannedSessions.filter((session) => session.readyChunks > 0);
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
- const result = await flushTrackedSessions(
282
- config,
283
- readySessions.map((session) => session.source),
284
- { includeIdleTracked: false, prefetchedTraces, sync: opts.sync }
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
- if (opts.sync) {
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
- } else {
306
- console.log(chalk.green(`\nQueued ${readyChunkCount} chunk(s) — processing in background.`));
307
- console.log(chalk.gray(" Run again to check confirmation status."));
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
- uploadSpinner.fail("Submission failed");
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,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
+ });