@tracemarketplace/cli 0.0.13 → 0.0.15

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.
Files changed (84) hide show
  1. package/dist/api-client.d.ts +2 -2
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +2 -2
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/cli.js +45 -14
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/auto-submit.d.ts +2 -1
  8. package/dist/commands/auto-submit.d.ts.map +1 -1
  9. package/dist/commands/auto-submit.js +43 -56
  10. package/dist/commands/auto-submit.js.map +1 -1
  11. package/dist/commands/daemon.d.ts +8 -1
  12. package/dist/commands/daemon.d.ts.map +1 -1
  13. package/dist/commands/daemon.js +118 -62
  14. package/dist/commands/daemon.js.map +1 -1
  15. package/dist/commands/history.d.ts +3 -1
  16. package/dist/commands/history.d.ts.map +1 -1
  17. package/dist/commands/history.js +8 -4
  18. package/dist/commands/history.js.map +1 -1
  19. package/dist/commands/login.d.ts +5 -1
  20. package/dist/commands/login.d.ts.map +1 -1
  21. package/dist/commands/login.js +25 -9
  22. package/dist/commands/login.js.map +1 -1
  23. package/dist/commands/register.d.ts +1 -0
  24. package/dist/commands/register.d.ts.map +1 -1
  25. package/dist/commands/register.js +4 -39
  26. package/dist/commands/register.js.map +1 -1
  27. package/dist/commands/remove-hook.d.ts +6 -0
  28. package/dist/commands/remove-hook.d.ts.map +1 -0
  29. package/dist/commands/remove-hook.js +174 -0
  30. package/dist/commands/remove-hook.js.map +1 -0
  31. package/dist/commands/setup-hook.d.ts +2 -0
  32. package/dist/commands/setup-hook.d.ts.map +1 -1
  33. package/dist/commands/setup-hook.js +85 -41
  34. package/dist/commands/setup-hook.js.map +1 -1
  35. package/dist/commands/status.d.ts +3 -1
  36. package/dist/commands/status.d.ts.map +1 -1
  37. package/dist/commands/status.js +8 -4
  38. package/dist/commands/status.js.map +1 -1
  39. package/dist/commands/submit.d.ts +1 -0
  40. package/dist/commands/submit.d.ts.map +1 -1
  41. package/dist/commands/submit.js +136 -83
  42. package/dist/commands/submit.js.map +1 -1
  43. package/dist/commands/whoami.d.ts +3 -1
  44. package/dist/commands/whoami.d.ts.map +1 -1
  45. package/dist/commands/whoami.js +8 -4
  46. package/dist/commands/whoami.js.map +1 -1
  47. package/dist/config.d.ts +33 -6
  48. package/dist/config.d.ts.map +1 -1
  49. package/dist/config.js +163 -17
  50. package/dist/config.js.map +1 -1
  51. package/dist/constants.d.ts +8 -0
  52. package/dist/constants.d.ts.map +1 -0
  53. package/dist/constants.js +16 -0
  54. package/dist/constants.js.map +1 -0
  55. package/dist/flush.d.ts +46 -0
  56. package/dist/flush.d.ts.map +1 -0
  57. package/dist/flush.js +338 -0
  58. package/dist/flush.js.map +1 -0
  59. package/dist/flush.test.d.ts +2 -0
  60. package/dist/flush.test.d.ts.map +1 -0
  61. package/dist/flush.test.js +175 -0
  62. package/dist/flush.test.js.map +1 -0
  63. package/dist/submitter.d.ts.map +1 -1
  64. package/dist/submitter.js +5 -2
  65. package/dist/submitter.js.map +1 -1
  66. package/package.json +8 -7
  67. package/src/api-client.ts +3 -3
  68. package/src/cli.ts +51 -14
  69. package/src/commands/auto-submit.ts +80 -40
  70. package/src/commands/daemon.ts +166 -59
  71. package/src/commands/history.ts +9 -4
  72. package/src/commands/login.ts +37 -9
  73. package/src/commands/register.ts +5 -49
  74. package/src/commands/remove-hook.ts +194 -0
  75. package/src/commands/setup-hook.ts +93 -43
  76. package/src/commands/status.ts +8 -4
  77. package/src/commands/submit.ts +189 -83
  78. package/src/commands/whoami.ts +8 -4
  79. package/src/config.ts +223 -21
  80. package/src/constants.ts +18 -0
  81. package/src/flush.test.ts +214 -0
  82. package/src/flush.ts +505 -0
  83. package/vitest.config.ts +8 -0
  84. package/src/submitter.ts +0 -110
package/src/config.ts CHANGED
@@ -1,19 +1,45 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
+ import { DEFAULT_PROFILE } from "./constants.js";
4
5
 
5
- export interface Config {
6
+ export interface StoredProfileConfig {
6
7
  apiKey: string;
7
8
  serverUrl: string;
8
9
  email: string;
9
10
  }
10
11
 
11
- // Tracks which chunks of each session have been submitted.
12
- // Key: "<source_tool>:<source_session_id>", value: highest chunk_index submitted.
12
+ export interface Config extends StoredProfileConfig {
13
+ profile: string;
14
+ }
15
+
16
+ export interface ConfigStore {
17
+ defaultProfile: string;
18
+ profiles: Record<string, StoredProfileConfig>;
19
+ }
20
+
21
+ export type TrackedSessionTool = "claude_code" | "codex_cli" | "cursor";
22
+
23
+ export interface SessionUploadState {
24
+ sourceTool: TrackedSessionTool;
25
+ sourceSessionId: string;
26
+ locator: string;
27
+ nextChunkIndex: number;
28
+ openChunkStartTurn: number;
29
+ lastSeenTurnCount: number;
30
+ lastActivityAt: string | null;
31
+ lastFlushedTurnId: string | null;
32
+ }
33
+
13
34
  export interface SubmitState {
35
+ version: 2;
14
36
  chunks: Record<string, number>;
37
+ sessions: Record<string, SessionUploadState>;
15
38
  }
16
39
 
40
+ type JsonRecord = Record<string, unknown>;
41
+ const EMPTY_SUBMIT_STATE: SubmitState = { version: 2, chunks: {}, sessions: {} };
42
+
17
43
  export function getConfigDir(): string {
18
44
  return join(homedir(), ".config", "tracemarketplace");
19
45
  }
@@ -22,40 +48,216 @@ export function getConfigPath(): string {
22
48
  return join(getConfigDir(), "config.json");
23
49
  }
24
50
 
25
- export function getStatePath(): string {
26
- return join(getConfigDir(), "state.json");
51
+ export function getStatePath(profile?: string): string {
52
+ return join(getConfigDir(), `state${profileSuffix(profile)}.json`);
27
53
  }
28
54
 
29
- export function loadConfig(): Config | null {
30
- const p = getConfigPath();
31
- if (!existsSync(p)) return null;
55
+ export function getAutoSubmitLogPath(profile?: string): string {
56
+ return join(getConfigDir(), `auto-submit${profileSuffix(profile)}.log`);
57
+ }
58
+
59
+ export function getDaemonStatePath(profile?: string): string {
60
+ return join(getConfigDir(), `daemon-state${profileSuffix(profile)}.json`);
61
+ }
62
+
63
+ export function resolveProfile(profile?: string): string {
64
+ if (profile) return normalizeProfile(profile);
65
+ if (process.env.TRACEMP_PROFILE) return normalizeProfile(process.env.TRACEMP_PROFILE);
66
+ return normalizeProfile(loadConfigStore().defaultProfile);
67
+ }
68
+
69
+ export function loadConfigStore(): ConfigStore {
70
+ const raw = readConfigFile();
71
+ if (isStoredProfileConfig(raw)) {
72
+ return {
73
+ defaultProfile: DEFAULT_PROFILE,
74
+ profiles: { [DEFAULT_PROFILE]: normalizeStoredProfileConfig(raw) },
75
+ };
76
+ }
77
+
78
+ if (!isRecord(raw)) {
79
+ return {
80
+ defaultProfile: DEFAULT_PROFILE,
81
+ profiles: {},
82
+ };
83
+ }
84
+
85
+ const profiles = normalizeProfiles(raw.profiles);
86
+ const requestedDefault = normalizeProfile(
87
+ typeof raw.defaultProfile === "string" ? raw.defaultProfile : DEFAULT_PROFILE
88
+ );
89
+
90
+ return {
91
+ defaultProfile: profiles[requestedDefault]
92
+ ? requestedDefault
93
+ : profiles[DEFAULT_PROFILE]
94
+ ? DEFAULT_PROFILE
95
+ : requestedDefault,
96
+ profiles,
97
+ };
98
+ }
99
+
100
+ export function loadConfig(profile?: string): Config | null {
101
+ const store = loadConfigStore();
102
+ const resolvedProfile = resolveProfileFromStore(store, profile);
103
+ const selected = store.profiles[resolvedProfile];
104
+ if (!selected) return null;
105
+ return { profile: resolvedProfile, ...selected };
106
+ }
107
+
108
+ export function saveConfig(
109
+ config: StoredProfileConfig,
110
+ options: { profile?: string; setDefault?: boolean } = {}
111
+ ): Config {
112
+ const store = loadConfigStore();
113
+ const profile = normalizeProfile(options.profile);
114
+
115
+ store.profiles[profile] = normalizeStoredProfileConfig(config);
116
+
117
+ const normalizedDefault = normalizeProfile(store.defaultProfile);
118
+ if (options.setDefault || !store.profiles[normalizedDefault]) {
119
+ store.defaultProfile = profile;
120
+ } else {
121
+ store.defaultProfile = normalizedDefault;
122
+ }
123
+
124
+ writeConfigStore(store);
125
+ return { profile, ...store.profiles[profile] };
126
+ }
127
+
128
+ export function loadState(profile?: string): SubmitState {
129
+ const p = getStatePath(profile);
130
+ if (!existsSync(p)) return EMPTY_SUBMIT_STATE;
32
131
  try {
33
- return JSON.parse(readFileSync(p, "utf-8")) as Config;
132
+ return normalizeSubmitState(JSON.parse(readFileSync(p, "utf-8")));
34
133
  } catch {
35
- return null;
134
+ return EMPTY_SUBMIT_STATE;
36
135
  }
37
136
  }
38
137
 
39
- export function saveConfig(config: Config): void {
138
+ export function saveState(state: SubmitState, profile?: string): void {
40
139
  mkdirSync(getConfigDir(), { recursive: true });
41
- writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8");
140
+ writeFileSync(getStatePath(profile), JSON.stringify(state, null, 2) + "\n", "utf-8");
141
+ }
142
+
143
+ export function stateKey(sourceTool: string, sessionId: string): string {
144
+ return `${sourceTool}:${sessionId}`;
42
145
  }
43
146
 
44
- export function loadState(): SubmitState {
45
- const p = getStatePath();
46
- if (!existsSync(p)) return { chunks: {} };
147
+ function readConfigFile(): unknown {
148
+ const p = getConfigPath();
149
+ if (!existsSync(p)) return null;
47
150
  try {
48
- return JSON.parse(readFileSync(p, "utf-8")) as SubmitState;
151
+ return JSON.parse(readFileSync(p, "utf-8"));
49
152
  } catch {
50
- return { chunks: {} };
153
+ return null;
51
154
  }
52
155
  }
53
156
 
54
- export function saveState(state: SubmitState): void {
157
+ function writeConfigStore(store: ConfigStore): void {
158
+ const profiles = normalizeProfiles(store.profiles);
159
+ const requestedDefault = normalizeProfile(store.defaultProfile);
160
+ const defaultProfile = profiles[requestedDefault]
161
+ ? requestedDefault
162
+ : profiles[DEFAULT_PROFILE]
163
+ ? DEFAULT_PROFILE
164
+ : requestedDefault;
165
+
55
166
  mkdirSync(getConfigDir(), { recursive: true });
56
- writeFileSync(getStatePath(), JSON.stringify(state, null, 2), "utf-8");
167
+ writeFileSync(
168
+ getConfigPath(),
169
+ JSON.stringify({ defaultProfile, profiles }, null, 2) + "\n",
170
+ "utf-8"
171
+ );
57
172
  }
58
173
 
59
- export function stateKey(sourceTool: string, sessionId: string): string {
60
- return `${sourceTool}:${sessionId}`;
174
+ function resolveProfileFromStore(store: ConfigStore, profile?: string): string {
175
+ if (profile) return normalizeProfile(profile);
176
+ if (process.env.TRACEMP_PROFILE) return normalizeProfile(process.env.TRACEMP_PROFILE);
177
+ return normalizeProfile(store.defaultProfile);
178
+ }
179
+
180
+ function profileSuffix(profile?: string): string {
181
+ const resolvedProfile = normalizeProfile(profile);
182
+ return resolvedProfile === DEFAULT_PROFILE ? "" : `.${resolvedProfile}`;
183
+ }
184
+
185
+ function normalizeProfile(profile?: string | null): string {
186
+ const trimmed = (profile ?? "").trim().toLowerCase();
187
+ if (!trimmed) return DEFAULT_PROFILE;
188
+
189
+ const normalized = trimmed
190
+ .replace(/[^a-z0-9_-]+/g, "-")
191
+ .replace(/^-+/, "")
192
+ .replace(/-+$/, "");
193
+
194
+ return normalized || DEFAULT_PROFILE;
195
+ }
196
+
197
+ function normalizeProfiles(value: unknown): Record<string, StoredProfileConfig> {
198
+ if (!isRecord(value)) return {};
199
+
200
+ const profiles: Record<string, StoredProfileConfig> = {};
201
+ for (const [profile, config] of Object.entries(value)) {
202
+ if (!isStoredProfileConfig(config)) continue;
203
+ profiles[normalizeProfile(profile)] = normalizeStoredProfileConfig(config);
204
+ }
205
+ return profiles;
206
+ }
207
+
208
+ function normalizeStoredProfileConfig(config: StoredProfileConfig): StoredProfileConfig {
209
+ return {
210
+ apiKey: config.apiKey,
211
+ serverUrl: config.serverUrl,
212
+ email: config.email,
213
+ };
214
+ }
215
+
216
+ function normalizeSubmitState(value: unknown): SubmitState {
217
+ if (!isRecord(value)) {
218
+ return EMPTY_SUBMIT_STATE;
219
+ }
220
+
221
+ const chunks = isRecord(value.chunks)
222
+ ? Object.fromEntries(
223
+ Object.entries(value.chunks)
224
+ .filter((entry): entry is [string, number] => typeof entry[1] === "number")
225
+ )
226
+ : {};
227
+
228
+ const sessions = isRecord(value.sessions)
229
+ ? Object.fromEntries(
230
+ Object.entries(value.sessions)
231
+ .filter((entry): entry is [string, SessionUploadState] => isSessionUploadState(entry[1]))
232
+ )
233
+ : {};
234
+
235
+ return {
236
+ version: 2,
237
+ chunks,
238
+ sessions,
239
+ };
240
+ }
241
+
242
+ function isRecord(value: unknown): value is JsonRecord {
243
+ return typeof value === "object" && value !== null && !Array.isArray(value);
244
+ }
245
+
246
+ function isStoredProfileConfig(value: unknown): value is StoredProfileConfig {
247
+ return isRecord(value)
248
+ && typeof value.apiKey === "string"
249
+ && typeof value.serverUrl === "string"
250
+ && typeof value.email === "string";
251
+ }
252
+
253
+ function isSessionUploadState(value: unknown): value is SessionUploadState {
254
+ return isRecord(value)
255
+ && (value.sourceTool === "claude_code" || value.sourceTool === "codex_cli" || value.sourceTool === "cursor")
256
+ && typeof value.sourceSessionId === "string"
257
+ && typeof value.locator === "string"
258
+ && typeof value.nextChunkIndex === "number"
259
+ && typeof value.openChunkStartTurn === "number"
260
+ && typeof value.lastSeenTurnCount === "number"
261
+ && (value.lastActivityAt === null || typeof value.lastActivityAt === "string")
262
+ && (value.lastFlushedTurnId === null || typeof value.lastFlushedTurnId === "string");
61
263
  }
@@ -0,0 +1,18 @@
1
+ export const CLI_NAME = "tracemp";
2
+ export const DEFAULT_PROFILE = "prod";
3
+ export const PROD_SERVER_URL = "https://trace-marketplace-api.fly.dev";
4
+ export const DEV_SERVER_URL = "http://localhost:3001";
5
+
6
+ export function defaultServerUrlForProfile(profile: string): string {
7
+ return profile === "dev" ? DEV_SERVER_URL : PROD_SERVER_URL;
8
+ }
9
+
10
+ export function inferProfileFromServerUrl(serverUrl: string): string {
11
+ return serverUrl === PROD_SERVER_URL ? DEFAULT_PROFILE : "dev";
12
+ }
13
+
14
+ export function loginCommandForProfile(profile: string): string {
15
+ return profile === DEFAULT_PROFILE
16
+ ? `${CLI_NAME} login`
17
+ : `${CLI_NAME} login --profile ${profile}`;
18
+ }
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { NormalizedTrace, Turn } from "@tracemarketplace/shared";
3
+ import type { SessionSource } from "./flush.js";
4
+ import {
5
+ collectIdleSessionSources,
6
+ createFreshSessionState,
7
+ migrateLegacySessionState,
8
+ planSessionUploads,
9
+ } from "./flush.js";
10
+
11
+ function makeTurn(
12
+ turnId: string,
13
+ role: "user" | "assistant",
14
+ timestamp: string,
15
+ outputTokens = 0
16
+ ): Turn {
17
+ return {
18
+ turn_id: turnId,
19
+ parent_turn_id: null,
20
+ role,
21
+ actor: role === "user" ? "human" : "assistant",
22
+ timestamp,
23
+ model: "test-model",
24
+ usage: {
25
+ input_tokens: 0,
26
+ output_tokens: outputTokens,
27
+ cache_read_input_tokens: null,
28
+ cache_creation_input_tokens: null,
29
+ reasoning_tokens: null,
30
+ },
31
+ source_metadata: {},
32
+ content: [
33
+ {
34
+ type: "text",
35
+ text: `${turnId}:${role}`,
36
+ },
37
+ ],
38
+ };
39
+ }
40
+
41
+ function makeTrace(
42
+ sessionId: string,
43
+ turns: Turn[],
44
+ endedAt = turns[turns.length - 1]?.timestamp ?? "2026-03-21T00:00:00.000Z"
45
+ ): NormalizedTrace {
46
+ const outputTokens = turns.reduce((sum, turn) => sum + (turn.usage?.output_tokens ?? 0), 0);
47
+
48
+ return {
49
+ trace_id: `trace-${sessionId}`,
50
+ schema_version: "1.0",
51
+ source_tool: "codex_cli",
52
+ source_session_id: sessionId,
53
+ source_version: null,
54
+ submitted_by: "tester@example.com",
55
+ submitted_at: "2026-03-21T00:00:00.000Z",
56
+ extracted_at: endedAt,
57
+ git_branch: null,
58
+ cwd_hash: null,
59
+ working_language: null,
60
+ started_at: turns[0]?.timestamp ?? endedAt,
61
+ ended_at: endedAt,
62
+ turns,
63
+ turn_count: turns.length,
64
+ tool_call_count: 0,
65
+ has_tool_calls: false,
66
+ has_thinking_blocks: false,
67
+ has_file_changes: false,
68
+ has_shell_commands: false,
69
+ total_input_tokens: 0,
70
+ total_output_tokens: outputTokens,
71
+ total_cache_read_tokens: null,
72
+ content_fidelity: "full",
73
+ env_state: null,
74
+ score: null,
75
+ raw_r2_key: "",
76
+ normalized_r2_key: "",
77
+ };
78
+ }
79
+
80
+ function makeSource(tool: SessionSource["tool"], locator: string): SessionSource {
81
+ return { tool, locator, label: locator };
82
+ }
83
+
84
+ describe("planSessionUploads", () => {
85
+ it("flushes a sealed 100k chunk and keeps the tail pending", () => {
86
+ const trace = makeTrace("session-100k", [
87
+ makeTurn("u1", "user", "2026-03-21T00:00:00.000Z"),
88
+ makeTurn("a1", "assistant", "2026-03-21T00:01:00.000Z", 100_200),
89
+ makeTurn("u2", "user", "2026-03-21T00:02:00.000Z"),
90
+ makeTurn("a2", "assistant", "2026-03-21T00:03:00.000Z", 25),
91
+ ]);
92
+ const cursor = createFreshSessionState(makeSource("codex_cli", "/tmp/session.jsonl"), trace);
93
+
94
+ const plan = planSessionUploads(trace, cursor, new Date("2026-03-21T00:04:00.000Z"));
95
+
96
+ expect(plan.uploads).toHaveLength(1);
97
+ expect(plan.uploads[0]?.trace.chunk_index).toBe(0);
98
+ expect(plan.uploads[0]?.trace.chunk_start_turn).toBe(0);
99
+ expect(plan.uploads[0]?.trace.chunk_complete).toBe(true);
100
+ expect(plan.uploads[0]?.trace.chunk_close_reason).toBe("100k_tokens");
101
+ expect(plan.pending).toBe(true);
102
+ expect(plan.uploads[0]?.nextState.openChunkStartTurn).toBe(2);
103
+ expect(plan.uploads[0]?.nextState.nextChunkIndex).toBe(1);
104
+ });
105
+
106
+ it("flushes an under-100k tail after two days of inactivity", () => {
107
+ const trace = makeTrace("session-idle", [
108
+ makeTurn("u1", "user", "2026-03-21T00:00:00.000Z"),
109
+ makeTurn("a1", "assistant", "2026-03-21T00:05:00.000Z", 40),
110
+ ], "2026-03-21T00:05:00.000Z");
111
+ const cursor = createFreshSessionState(makeSource("codex_cli", "/tmp/session.jsonl"), trace);
112
+
113
+ const plan = planSessionUploads(trace, cursor, new Date("2026-03-23T00:06:00.000Z"));
114
+
115
+ expect(plan.uploads).toHaveLength(1);
116
+ expect(plan.uploads[0]?.trace.chunk_index).toBe(0);
117
+ expect(plan.uploads[0]?.trace.chunk_close_reason).toBe("idle_2d");
118
+ expect(plan.pending).toBe(false);
119
+ expect(plan.uploads[0]?.nextState.openChunkStartTurn).toBe(trace.turn_count);
120
+ });
121
+
122
+ it("creates a later chunk when a session resumes after an idle-finalized chunk", () => {
123
+ const initialTrace = makeTrace("session-resume", [
124
+ makeTurn("u1", "user", "2026-03-21T00:00:00.000Z"),
125
+ makeTurn("a1", "assistant", "2026-03-21T00:05:00.000Z", 20),
126
+ ], "2026-03-21T00:05:00.000Z");
127
+ const source = makeSource("codex_cli", "/tmp/session.jsonl");
128
+ const initialCursor = createFreshSessionState(source, initialTrace);
129
+ const initialPlan = planSessionUploads(initialTrace, initialCursor, new Date("2026-03-23T00:06:00.000Z"));
130
+ const finalizedCursor = initialPlan.uploads[0]?.nextState;
131
+
132
+ if (!finalizedCursor) {
133
+ throw new Error("expected initial chunk upload");
134
+ }
135
+
136
+ const resumedTrace = makeTrace("session-resume", [
137
+ ...initialTrace.turns,
138
+ makeTurn("u2", "user", "2026-03-24T00:00:00.000Z"),
139
+ makeTurn("a2", "assistant", "2026-03-24T00:10:00.000Z", 15),
140
+ ], "2026-03-24T00:10:00.000Z");
141
+
142
+ const resumedPlan = planSessionUploads(resumedTrace, finalizedCursor, new Date("2026-03-26T00:11:00.000Z"));
143
+
144
+ expect(resumedPlan.uploads).toHaveLength(1);
145
+ expect(resumedPlan.uploads[0]?.trace.chunk_index).toBe(1);
146
+ expect(resumedPlan.uploads[0]?.trace.chunk_start_turn).toBe(2);
147
+ expect(resumedPlan.uploads[0]?.trace.chunk_close_reason).toBe("idle_2d");
148
+ expect(resumedPlan.pending).toBe(false);
149
+ });
150
+
151
+ it("does not re-upload a legacy session on first migration", () => {
152
+ const trace = makeTrace("legacy-session", [
153
+ makeTurn("u1", "user", "2026-03-21T00:00:00.000Z"),
154
+ makeTurn("a1", "assistant", "2026-03-21T00:05:00.000Z", 55),
155
+ ]);
156
+ const migratedCursor = migrateLegacySessionState(
157
+ makeSource("codex_cli", "/tmp/legacy.jsonl"),
158
+ trace,
159
+ 0
160
+ );
161
+
162
+ const plan = planSessionUploads(trace, migratedCursor, new Date("2026-03-23T00:06:00.000Z"));
163
+
164
+ expect(plan.uploads).toHaveLength(0);
165
+ expect(plan.pending).toBe(false);
166
+ expect(plan.observedState.nextChunkIndex).toBe(1);
167
+ expect(plan.observedState.openChunkStartTurn).toBe(trace.turn_count);
168
+ });
169
+ });
170
+
171
+ describe("collectIdleSessionSources", () => {
172
+ it("returns only tracked sessions with an open tail older than two days", () => {
173
+ const sources = collectIdleSessionSources({
174
+ "codex_cli:idle": {
175
+ sourceTool: "codex_cli",
176
+ sourceSessionId: "idle",
177
+ locator: "/tmp/idle.jsonl",
178
+ nextChunkIndex: 1,
179
+ openChunkStartTurn: 2,
180
+ lastSeenTurnCount: 4,
181
+ lastActivityAt: "2026-03-21T00:00:00.000Z",
182
+ lastFlushedTurnId: "a1",
183
+ },
184
+ "codex_cli:closed": {
185
+ sourceTool: "codex_cli",
186
+ sourceSessionId: "closed",
187
+ locator: "/tmp/closed.jsonl",
188
+ nextChunkIndex: 1,
189
+ openChunkStartTurn: 4,
190
+ lastSeenTurnCount: 4,
191
+ lastActivityAt: "2026-03-21T00:00:00.000Z",
192
+ lastFlushedTurnId: "a2",
193
+ },
194
+ "codex_cli:fresh": {
195
+ sourceTool: "codex_cli",
196
+ sourceSessionId: "fresh",
197
+ locator: "/tmp/fresh.jsonl",
198
+ nextChunkIndex: 0,
199
+ openChunkStartTurn: 0,
200
+ lastSeenTurnCount: 2,
201
+ lastActivityAt: "2026-03-22T23:59:59.000Z",
202
+ lastFlushedTurnId: null,
203
+ },
204
+ }, new Date("2026-03-23T00:01:00.000Z"));
205
+
206
+ expect(sources).toEqual([
207
+ {
208
+ tool: "codex_cli",
209
+ locator: "/tmp/idle.jsonl",
210
+ label: "codex_cli:idle",
211
+ },
212
+ ]);
213
+ });
214
+ });