@tracemarketplace/cli 0.0.15 → 0.0.17
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/api-client.d.ts +7 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +79 -14
- package/dist/api-client.js.map +1 -1
- package/dist/api-client.test.d.ts +2 -0
- package/dist/api-client.test.d.ts.map +1 -0
- package/dist/api-client.test.js +34 -0
- package/dist/api-client.test.js.map +1 -0
- package/dist/cli.js +7 -8
- package/dist/cli.js.map +1 -1
- package/dist/commands/daemon.d.ts.map +1 -1
- package/dist/commands/daemon.js +71 -6
- package/dist/commands/daemon.js.map +1 -1
- package/dist/commands/remove-daemon.d.ts +6 -0
- package/dist/commands/remove-daemon.d.ts.map +1 -0
- package/dist/commands/remove-daemon.js +66 -0
- package/dist/commands/remove-daemon.js.map +1 -0
- package/dist/commands/submit.d.ts.map +1 -1
- package/dist/commands/submit.js +3 -1
- package/dist/commands/submit.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/flush.d.ts +4 -1
- package/dist/flush.d.ts.map +1 -1
- package/dist/flush.js +92 -25
- package/dist/flush.js.map +1 -1
- package/dist/flush.test.js +162 -7
- package/dist/flush.test.js.map +1 -1
- package/package.json +2 -2
- package/src/api-client.test.ts +47 -0
- package/src/api-client.ts +98 -14
- package/src/cli.ts +8 -9
- package/src/commands/daemon.ts +82 -6
- package/src/commands/remove-daemon.ts +75 -0
- package/src/commands/submit.ts +4 -2
- package/src/config.ts +18 -0
- package/src/flush.test.ts +187 -6
- package/src/flush.ts +123 -37
- package/src/commands/register.ts +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tracemarketplace/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
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.
|
|
16
|
+
"@tracemarketplace/shared": "^0.0.11",
|
|
17
17
|
"better-sqlite3": "^12.8.0",
|
|
18
18
|
"chalk": "^5.3.0",
|
|
19
19
|
"commander": "^12.0.0",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ApiClient, ApiError } from "./api-client.js";
|
|
3
|
+
|
|
4
|
+
describe("ApiClient", () => {
|
|
5
|
+
const originalFetch = global.fetch;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
global.fetch = originalFetch;
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("parses retry-after and json error bodies", async () => {
|
|
13
|
+
global.fetch = vi.fn().mockResolvedValue(
|
|
14
|
+
new Response(JSON.stringify({ error: "busy" }), {
|
|
15
|
+
status: 503,
|
|
16
|
+
headers: {
|
|
17
|
+
"content-type": "application/json",
|
|
18
|
+
"retry-after": "12",
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
) as typeof fetch;
|
|
22
|
+
|
|
23
|
+
const client = new ApiClient("https://example.test", "token");
|
|
24
|
+
|
|
25
|
+
await expect(client.post("/api/v1/traces/batch", {})).rejects.toEqual(
|
|
26
|
+
expect.objectContaining<Partial<ApiError>>({
|
|
27
|
+
name: "ApiError",
|
|
28
|
+
status: 503,
|
|
29
|
+
retryAfterSeconds: 12,
|
|
30
|
+
body: { error: "busy" },
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns parsed json bodies on success", async () => {
|
|
36
|
+
global.fetch = vi.fn().mockResolvedValue(
|
|
37
|
+
new Response(JSON.stringify({ ok: true }), {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
}),
|
|
41
|
+
) as typeof fetch;
|
|
42
|
+
|
|
43
|
+
const client = new ApiClient("https://example.test", "token");
|
|
44
|
+
|
|
45
|
+
await expect(client.get("/health")).resolves.toEqual({ ok: true });
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/api-client.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
message: string,
|
|
4
|
+
readonly status: number,
|
|
5
|
+
readonly body: unknown,
|
|
6
|
+
readonly retryAfterSeconds: number | null,
|
|
7
|
+
) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "ApiError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
1
13
|
export class ApiClient {
|
|
2
14
|
constructor(
|
|
3
15
|
private baseUrl: string,
|
|
@@ -5,29 +17,101 @@ export class ApiClient {
|
|
|
5
17
|
) {}
|
|
6
18
|
|
|
7
19
|
async get(path: string): Promise<unknown> {
|
|
8
|
-
|
|
9
|
-
headers: this.apiKey ? { "X-Api-Key": this.apiKey } : {},
|
|
10
|
-
});
|
|
11
|
-
if (!res.ok) {
|
|
12
|
-
const text = await res.text();
|
|
13
|
-
throw new Error(`API error ${res.status}: ${text}`);
|
|
14
|
-
}
|
|
15
|
-
return res.json();
|
|
20
|
+
return this.request("GET", path);
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
async post(path: string, body: unknown): Promise<unknown> {
|
|
24
|
+
return this.request("POST", path, body);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async request(method: "GET" | "POST", path: string, body?: unknown): Promise<unknown> {
|
|
28
|
+
let reqBody: string | Uint8Array | undefined;
|
|
29
|
+
const extraHeaders: Record<string, string> = {};
|
|
30
|
+
|
|
31
|
+
if (method === "POST" && body !== undefined) {
|
|
32
|
+
const json = JSON.stringify(body);
|
|
33
|
+
try {
|
|
34
|
+
const { gzip } = await import("zlib");
|
|
35
|
+
const buf = await new Promise<Buffer>((resolve, reject) =>
|
|
36
|
+
gzip(Buffer.from(json), (err, result) => err ? reject(err) : resolve(result))
|
|
37
|
+
);
|
|
38
|
+
reqBody = new Uint8Array(buf);
|
|
39
|
+
extraHeaders["X-Content-Encoding"] = "gzip";
|
|
40
|
+
const ratio = Math.round((1 - buf.length / json.length) * 100);
|
|
41
|
+
console.error(`[gzip] ${Math.round(json.length / 1024)}KB → ${Math.round(buf.length / 1024)}KB (${ratio}% reduction)`);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error(`[gzip] compression failed, sending uncompressed: ${e}`);
|
|
44
|
+
reqBody = json;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
19
48
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
20
|
-
method
|
|
49
|
+
method,
|
|
21
50
|
headers: {
|
|
22
|
-
"Content-Type": "application/json",
|
|
51
|
+
...(method === "POST" ? { "Content-Type": "application/json" } : {}),
|
|
23
52
|
...(this.apiKey ? { "X-Api-Key": this.apiKey } : {}),
|
|
53
|
+
...extraHeaders,
|
|
24
54
|
},
|
|
25
|
-
body:
|
|
55
|
+
body: reqBody as BodyInit | undefined,
|
|
26
56
|
});
|
|
57
|
+
|
|
58
|
+
const parsedBody = await parseResponseBody(res);
|
|
27
59
|
if (!res.ok) {
|
|
28
|
-
|
|
29
|
-
|
|
60
|
+
throw new ApiError(
|
|
61
|
+
`API error ${res.status}: ${formatErrorBody(parsedBody)}`,
|
|
62
|
+
res.status,
|
|
63
|
+
parsedBody,
|
|
64
|
+
parseRetryAfterHeader(res.headers.get("retry-after")),
|
|
65
|
+
);
|
|
30
66
|
}
|
|
31
|
-
|
|
67
|
+
|
|
68
|
+
return parsedBody;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function parseResponseBody(res: Response): Promise<unknown> {
|
|
73
|
+
const text = await res.text();
|
|
74
|
+
if (!text) {
|
|
75
|
+
return null;
|
|
32
76
|
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(text);
|
|
80
|
+
} catch {
|
|
81
|
+
return text;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatErrorBody(body: unknown): string {
|
|
86
|
+
if (typeof body === "string") {
|
|
87
|
+
return body;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (body && typeof body === "object") {
|
|
91
|
+
const error = (body as { error?: unknown }).error;
|
|
92
|
+
if (typeof error === "string") {
|
|
93
|
+
return error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return JSON.stringify(body);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseRetryAfterHeader(value: string | null): number | null {
|
|
101
|
+
if (!value) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const numeric = Number.parseInt(value, 10);
|
|
106
|
+
if (Number.isFinite(numeric)) {
|
|
107
|
+
return Math.max(0, numeric);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const dateMs = Date.parse(value);
|
|
111
|
+
if (Number.isNaN(dateMs)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const seconds = Math.ceil((dateMs - Date.now()) / 1000);
|
|
116
|
+
return Math.max(0, seconds);
|
|
33
117
|
}
|
package/src/cli.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { dirname, join } from "path";
|
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { program } from "commander";
|
|
6
6
|
import { loginCommand } from "./commands/login.js";
|
|
7
|
-
import { registerCommand } from "./commands/register.js";
|
|
8
7
|
import { whoamiCommand } from "./commands/whoami.js";
|
|
9
8
|
import { submitCommand } from "./commands/submit.js";
|
|
10
9
|
import { statusCommand } from "./commands/status.js";
|
|
@@ -13,6 +12,7 @@ import { autoSubmitCommand } from "./commands/auto-submit.js";
|
|
|
13
12
|
import { setupHookCommand } from "./commands/setup-hook.js";
|
|
14
13
|
import { daemonCommand } from "./commands/daemon.js";
|
|
15
14
|
import { removeHookCommand } from "./commands/remove-hook.js";
|
|
15
|
+
import { removeDaemonCommand } from "./commands/remove-daemon.js";
|
|
16
16
|
import { CLI_NAME, DEFAULT_PROFILE, PROD_SERVER_URL } from "./constants.js";
|
|
17
17
|
|
|
18
18
|
const profileOptionDescription = `Config profile (default: ${DEFAULT_PROFILE})`;
|
|
@@ -32,13 +32,6 @@ program
|
|
|
32
32
|
.option("--server-url <url>", `Server URL (default prod: ${PROD_SERVER_URL})`)
|
|
33
33
|
.action((opts) => loginCommand({ profile: opts.profile, serverUrl: opts.serverUrl }).catch(handleError));
|
|
34
34
|
|
|
35
|
-
program
|
|
36
|
-
.command("register")
|
|
37
|
-
.description("Alias for login — signs in via browser and saves your API key")
|
|
38
|
-
.option("--profile <name>", profileOptionDescription)
|
|
39
|
-
.option("--server-url <url>", `Server URL (default prod: ${PROD_SERVER_URL})`)
|
|
40
|
-
.action((opts) => registerCommand({ profile: opts.profile, serverUrl: opts.serverUrl }).catch(handleError));
|
|
41
|
-
|
|
42
35
|
program
|
|
43
36
|
.command("whoami")
|
|
44
37
|
.description("Show your account info and balance")
|
|
@@ -76,7 +69,7 @@ program
|
|
|
76
69
|
.action((opts) => historyCommand({ profile: opts.profile }).catch(handleError));
|
|
77
70
|
|
|
78
71
|
program
|
|
79
|
-
.command("auto-submit")
|
|
72
|
+
.command("auto-submit", { hidden: true })
|
|
80
73
|
.description("Submit the current session (called by tool hooks — do not run manually)")
|
|
81
74
|
.option("--profile <name>", profileOptionDescription)
|
|
82
75
|
.option("--tool <tool>", "Tool that triggered the hook (claude-code|cursor|codex)")
|
|
@@ -113,6 +106,12 @@ program
|
|
|
113
106
|
.option("--tool <tool>", "Only remove hook for specific tool (claude-code|cursor|codex)")
|
|
114
107
|
.action((opts) => removeHookCommand({ tool: opts.tool }).catch(handleError));
|
|
115
108
|
|
|
109
|
+
program
|
|
110
|
+
.command("remove-daemon")
|
|
111
|
+
.description("Stop a running tracemp daemon")
|
|
112
|
+
.option("--profile <name>", profileOptionDescription)
|
|
113
|
+
.action((opts) => removeDaemonCommand({ profile: opts.profile }).catch(handleError));
|
|
114
|
+
|
|
116
115
|
function handleError(e: unknown) {
|
|
117
116
|
console.error((e as Error).message ?? String(e));
|
|
118
117
|
process.exit(1);
|
package/src/commands/daemon.ts
CHANGED
|
@@ -6,16 +6,25 @@
|
|
|
6
6
|
* pass and exits. `--watch` preserves the old live-watch behavior.
|
|
7
7
|
*
|
|
8
8
|
* State: ~/.config/tracemarketplace/daemon-state(.<profile>).json
|
|
9
|
-
* { [filePath]: { mtime: number
|
|
9
|
+
* { [filePath]: { mtime: number; size: number } }
|
|
10
|
+
*
|
|
11
|
+
* PID: ~/.config/tracemarketplace/daemon(.<profile>).pid
|
|
10
12
|
*/
|
|
11
|
-
import { readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from "fs";
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, existsSync, unlinkSync } from "fs";
|
|
12
14
|
import { homedir } from "os";
|
|
13
15
|
import { join } from "path";
|
|
14
16
|
import chalk from "chalk";
|
|
15
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type Config,
|
|
19
|
+
getConfigDir,
|
|
20
|
+
getDaemonPidPath,
|
|
21
|
+
getDaemonStatePath,
|
|
22
|
+
loadConfig,
|
|
23
|
+
resolveProfile,
|
|
24
|
+
} from "../config.js";
|
|
16
25
|
import { findFiles, watchDirs } from "../sessions.js";
|
|
17
26
|
import { log } from "./auto-submit.js";
|
|
18
|
-
import { loginCommandForProfile } from "../constants.js";
|
|
27
|
+
import { CLI_NAME, DEFAULT_PROFILE, loginCommandForProfile } from "../constants.js";
|
|
19
28
|
import { buildFileSessionSource, flushTrackedSessions } from "../flush.js";
|
|
20
29
|
|
|
21
30
|
type DaemonState = Record<string, { mtime: number; size: number }>;
|
|
@@ -29,6 +38,68 @@ interface DaemonOptions {
|
|
|
29
38
|
watch?: boolean;
|
|
30
39
|
}
|
|
31
40
|
|
|
41
|
+
function readDaemonPid(profile: string): number | null {
|
|
42
|
+
const pidPath = getDaemonPidPath(profile);
|
|
43
|
+
if (!existsSync(pidPath)) return null;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const parsed = Number.parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
47
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isProcessRunning(pid: number): boolean {
|
|
54
|
+
try {
|
|
55
|
+
process.kill(pid, 0);
|
|
56
|
+
return true;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function removeDaemonPidFile(profile: string, expectedPid?: number) {
|
|
63
|
+
const pidPath = getDaemonPidPath(profile);
|
|
64
|
+
if (!existsSync(pidPath)) return;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
if (expectedPid !== undefined) {
|
|
68
|
+
const currentPid = Number.parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
69
|
+
if (currentPid !== expectedPid) return;
|
|
70
|
+
}
|
|
71
|
+
unlinkSync(pidPath);
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function reserveDaemonPid(profile: string): () => void {
|
|
76
|
+
const existingPid = readDaemonPid(profile);
|
|
77
|
+
if (existingPid !== null) {
|
|
78
|
+
if (isProcessRunning(existingPid)) {
|
|
79
|
+
const profileArg = profile === DEFAULT_PROFILE ? "" : ` --profile ${profile}`;
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Daemon already running for profile '${profile}' (pid ${existingPid}). Run: ${CLI_NAME} remove-daemon${profileArg}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
removeDaemonPidFile(profile);
|
|
85
|
+
} else {
|
|
86
|
+
removeDaemonPidFile(profile);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
90
|
+
writeFileSync(getDaemonPidPath(profile), `${process.pid}\n`, "utf-8");
|
|
91
|
+
|
|
92
|
+
let released = false;
|
|
93
|
+
const release = () => {
|
|
94
|
+
if (released) return;
|
|
95
|
+
released = true;
|
|
96
|
+
removeDaemonPidFile(profile, process.pid);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
process.once("exit", release);
|
|
100
|
+
return release;
|
|
101
|
+
}
|
|
102
|
+
|
|
32
103
|
function loadState(profile: string): DaemonState {
|
|
33
104
|
try { return JSON.parse(readFileSync(getDaemonStatePath(profile), "utf-8")); } catch { return {}; }
|
|
34
105
|
}
|
|
@@ -93,6 +164,7 @@ export async function daemonCommand(opts: DaemonOptions = {}): Promise<void> {
|
|
|
93
164
|
|
|
94
165
|
const intervalSeconds = parseIntervalSeconds(opts.interval);
|
|
95
166
|
let state = loadState(config.profile);
|
|
167
|
+
const releasePid = opts.once ? null : reserveDaemonPid(config.profile);
|
|
96
168
|
|
|
97
169
|
console.log(chalk.bold("tracemp daemon starting"));
|
|
98
170
|
console.log(chalk.gray(`Profile: ${config.profile}`));
|
|
@@ -102,7 +174,7 @@ export async function daemonCommand(opts: DaemonOptions = {}): Promise<void> {
|
|
|
102
174
|
if (opts.watch) {
|
|
103
175
|
console.log(chalk.gray("Mode: live watch"));
|
|
104
176
|
console.log(chalk.gray("Press Ctrl+C to stop\n"));
|
|
105
|
-
await runWatchLoop(config, tools, state);
|
|
177
|
+
await runWatchLoop(config, tools, state, releasePid ?? (() => {}));
|
|
106
178
|
return;
|
|
107
179
|
}
|
|
108
180
|
|
|
@@ -115,6 +187,7 @@ export async function daemonCommand(opts: DaemonOptions = {}): Promise<void> {
|
|
|
115
187
|
const stop = () => {
|
|
116
188
|
if (shuttingDown) return;
|
|
117
189
|
shuttingDown = true;
|
|
190
|
+
releasePid?.();
|
|
118
191
|
console.log(chalk.gray("\nDaemon stopped."));
|
|
119
192
|
process.exit(0);
|
|
120
193
|
};
|
|
@@ -169,7 +242,8 @@ async function runScanPass(
|
|
|
169
242
|
async function runWatchLoop(
|
|
170
243
|
config: Config,
|
|
171
244
|
tools: Array<"claude_code" | "codex_cli">,
|
|
172
|
-
state: DaemonState
|
|
245
|
+
state: DaemonState,
|
|
246
|
+
releasePid: () => void
|
|
173
247
|
): Promise<void> {
|
|
174
248
|
let nextState = await runScanPass(config, tools, state, { logWhenEmpty: true });
|
|
175
249
|
const stop = watchDirs(tools, async (tool, filePath) => {
|
|
@@ -180,11 +254,13 @@ async function runWatchLoop(
|
|
|
180
254
|
|
|
181
255
|
process.on("SIGINT", () => {
|
|
182
256
|
stop();
|
|
257
|
+
releasePid();
|
|
183
258
|
console.log(chalk.gray("\nDaemon stopped."));
|
|
184
259
|
process.exit(0);
|
|
185
260
|
});
|
|
186
261
|
process.on("SIGTERM", () => {
|
|
187
262
|
stop();
|
|
263
|
+
releasePid();
|
|
188
264
|
process.exit(0);
|
|
189
265
|
});
|
|
190
266
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { DEFAULT_PROFILE } from "../constants.js";
|
|
4
|
+
import { getDaemonPidPath, resolveProfile } from "../config.js";
|
|
5
|
+
|
|
6
|
+
interface RemoveDaemonOptions {
|
|
7
|
+
profile?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readDaemonPid(profile: string): number | null {
|
|
11
|
+
const pidPath = getDaemonPidPath(profile);
|
|
12
|
+
if (!existsSync(pidPath)) return null;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const parsed = Number.parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
16
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isProcessRunning(pid: number): boolean {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function removePidFile(profile: string) {
|
|
32
|
+
const pidPath = getDaemonPidPath(profile);
|
|
33
|
+
if (!existsSync(pidPath)) return;
|
|
34
|
+
try {
|
|
35
|
+
unlinkSync(pidPath);
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function waitForExit(pid: number, timeoutMs = 3000): Promise<boolean> {
|
|
40
|
+
const deadline = Date.now() + timeoutMs;
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
if (!isProcessRunning(pid)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
46
|
+
}
|
|
47
|
+
return !isProcessRunning(pid);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function removeDaemonCommand(opts: RemoveDaemonOptions = {}): Promise<void> {
|
|
51
|
+
const profile = resolveProfile(opts.profile);
|
|
52
|
+
const pid = readDaemonPid(profile);
|
|
53
|
+
const profileLabel = profile === DEFAULT_PROFILE ? "default profile" : `profile '${profile}'`;
|
|
54
|
+
|
|
55
|
+
if (pid === null) {
|
|
56
|
+
console.log(chalk.gray(`No tracemp daemon found for ${profileLabel}.`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!isProcessRunning(pid)) {
|
|
61
|
+
removePidFile(profile);
|
|
62
|
+
console.log(chalk.gray(`Removed stale daemon PID file for ${profileLabel}.`));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
process.kill(pid, "SIGTERM");
|
|
67
|
+
|
|
68
|
+
if (await waitForExit(pid)) {
|
|
69
|
+
removePidFile(profile);
|
|
70
|
+
console.log(chalk.green(`✓ Stopped tracemp daemon for ${profileLabel}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(chalk.yellow(`Sent SIGTERM to tracemp daemon for ${profileLabel} (pid ${pid}), but it is still running.`));
|
|
75
|
+
}
|
package/src/commands/submit.ts
CHANGED
|
@@ -271,10 +271,12 @@ export async function submitCommand(opts: SubmitOptions): Promise<void> {
|
|
|
271
271
|
const uploadSpinner = ora(`Submitting ${readyChunkCount} finalized chunk(s) to ${config.profile}...`).start();
|
|
272
272
|
|
|
273
273
|
try {
|
|
274
|
+
const readySessions = plannedSessions.filter((session) => session.readyChunks > 0);
|
|
275
|
+
const prefetchedTraces = new Map(readySessions.map((s) => [`${s.source.tool}:${s.source.locator}`, s.trace]));
|
|
274
276
|
const result = await flushTrackedSessions(
|
|
275
277
|
config,
|
|
276
|
-
|
|
277
|
-
{ includeIdleTracked: false }
|
|
278
|
+
readySessions.map((session) => session.source),
|
|
279
|
+
{ includeIdleTracked: false, prefetchedTraces }
|
|
278
280
|
);
|
|
279
281
|
|
|
280
282
|
uploadSpinner.stop();
|
package/src/config.ts
CHANGED
|
@@ -29,6 +29,10 @@ export interface SessionUploadState {
|
|
|
29
29
|
lastSeenTurnCount: number;
|
|
30
30
|
lastActivityAt: string | null;
|
|
31
31
|
lastFlushedTurnId: string | null;
|
|
32
|
+
// Async confirmation tracking
|
|
33
|
+
confirmedChunkIndex: number; // all chunks below this index are confirmed in DB
|
|
34
|
+
confirmedOpenChunkStartTurn: number; // openChunkStartTurn to restore if re-submitting
|
|
35
|
+
unconfirmedSince: string | null; // ISO timestamp when chunks first went unconfirmed
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export interface SubmitState {
|
|
@@ -60,6 +64,10 @@ export function getDaemonStatePath(profile?: string): string {
|
|
|
60
64
|
return join(getConfigDir(), `daemon-state${profileSuffix(profile)}.json`);
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
export function getDaemonPidPath(profile?: string): string {
|
|
68
|
+
return join(getConfigDir(), `daemon${profileSuffix(profile)}.pid`);
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
export function resolveProfile(profile?: string): string {
|
|
64
72
|
if (profile) return normalizeProfile(profile);
|
|
65
73
|
if (process.env.TRACEMP_PROFILE) return normalizeProfile(process.env.TRACEMP_PROFILE);
|
|
@@ -261,3 +269,13 @@ function isSessionUploadState(value: unknown): value is SessionUploadState {
|
|
|
261
269
|
&& (value.lastActivityAt === null || typeof value.lastActivityAt === "string")
|
|
262
270
|
&& (value.lastFlushedTurnId === null || typeof value.lastFlushedTurnId === "string");
|
|
263
271
|
}
|
|
272
|
+
|
|
273
|
+
export function migrateSessionUploadState(value: SessionUploadState): SessionUploadState {
|
|
274
|
+
return {
|
|
275
|
+
...value,
|
|
276
|
+
// Backward compat: existing sessions assume all submitted chunks are confirmed
|
|
277
|
+
confirmedChunkIndex: value.confirmedChunkIndex ?? value.nextChunkIndex,
|
|
278
|
+
confirmedOpenChunkStartTurn: value.confirmedOpenChunkStartTurn ?? value.openChunkStartTurn,
|
|
279
|
+
unconfirmedSince: value.unconfirmedSince ?? null,
|
|
280
|
+
};
|
|
281
|
+
}
|