@tracemarketplace/cli 0.0.15 → 0.0.18

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 (42) hide show
  1. package/dist/api-client.d.ts +7 -0
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +79 -14
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/api-client.test.d.ts +2 -0
  6. package/dist/api-client.test.d.ts.map +1 -0
  7. package/dist/api-client.test.js +34 -0
  8. package/dist/api-client.test.js.map +1 -0
  9. package/dist/cli.js +9 -8
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/daemon.d.ts.map +1 -1
  12. package/dist/commands/daemon.js +71 -6
  13. package/dist/commands/daemon.js.map +1 -1
  14. package/dist/commands/remove-daemon.d.ts +6 -0
  15. package/dist/commands/remove-daemon.d.ts.map +1 -0
  16. package/dist/commands/remove-daemon.js +66 -0
  17. package/dist/commands/remove-daemon.js.map +1 -0
  18. package/dist/commands/submit.d.ts +1 -0
  19. package/dist/commands/submit.d.ts.map +1 -1
  20. package/dist/commands/submit.js +25 -15
  21. package/dist/commands/submit.js.map +1 -1
  22. package/dist/config.d.ts +5 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +12 -0
  25. package/dist/config.js.map +1 -1
  26. package/dist/flush.d.ts +5 -1
  27. package/dist/flush.d.ts.map +1 -1
  28. package/dist/flush.js +107 -27
  29. package/dist/flush.js.map +1 -1
  30. package/dist/flush.test.js +162 -7
  31. package/dist/flush.test.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/api-client.test.ts +47 -0
  34. package/src/api-client.ts +98 -14
  35. package/src/cli.ts +10 -9
  36. package/src/commands/daemon.ts +82 -6
  37. package/src/commands/remove-daemon.ts +75 -0
  38. package/src/commands/submit.ts +28 -18
  39. package/src/config.ts +18 -0
  40. package/src/flush.test.ts +187 -6
  41. package/src/flush.ts +140 -39
  42. package/src/commands/register.ts +0 -8
package/src/flush.ts CHANGED
@@ -7,11 +7,11 @@ import {
7
7
  extractCursor,
8
8
  redactTrace,
9
9
  type NormalizedTrace,
10
- type Turn,
11
10
  } from "@tracemarketplace/shared";
12
11
  import { ApiClient } from "./api-client.js";
13
12
  import {
14
13
  loadState,
14
+ migrateSessionUploadState,
15
15
  saveState,
16
16
  stateKey,
17
17
  type Config,
@@ -21,6 +21,8 @@ import {
21
21
  import { CURSOR_DB_PATH } from "./sessions.js";
22
22
 
23
23
  const IDLE_FINALIZATION_MS = 2 * 24 * 60 * 60 * 1000;
24
+ const UNCONFIRMED_RESUBMIT_MS = 2 * 60 * 60 * 1000; // re-submit after 2hr with no confirmation
25
+ const INGEST_CONCURRENCY = 12;
24
26
 
25
27
  export interface SessionSource {
26
28
  tool: TrackedSessionTool;
@@ -67,6 +69,12 @@ interface ChunkUploadResult {
67
69
  error?: string;
68
70
  }
69
71
 
72
+ interface IngestResponse {
73
+ queued?: boolean;
74
+ duplicate?: boolean;
75
+ trace_id?: string;
76
+ }
77
+
70
78
  export function collectIdleSessionSources(
71
79
  sessions: Record<string, SessionUploadState>,
72
80
  now = new Date()
@@ -80,10 +88,60 @@ export function collectIdleSessionSources(
80
88
  }));
81
89
  }
82
90
 
91
+ interface ChunkExistsResponse {
92
+ exists: boolean;
93
+ trace_id?: string;
94
+ }
95
+
96
+ export async function verifyUnconfirmedChunks(
97
+ state: ReturnType<typeof loadState>,
98
+ client: ApiClient,
99
+ now: Date,
100
+ ): Promise<void> {
101
+ for (const [key, session] of Object.entries(state.sessions)) {
102
+ if (session.confirmedChunkIndex >= session.nextChunkIndex) continue;
103
+
104
+ // Check if timed out — reset to re-submit from last confirmed point
105
+ if (session.unconfirmedSince) {
106
+ const age = now.getTime() - Date.parse(session.unconfirmedSince);
107
+ if (age >= UNCONFIRMED_RESUBMIT_MS) {
108
+ state.sessions[key] = {
109
+ ...session,
110
+ nextChunkIndex: session.confirmedChunkIndex,
111
+ openChunkStartTurn: session.confirmedOpenChunkStartTurn,
112
+ unconfirmedSince: null,
113
+ };
114
+ continue;
115
+ }
116
+ }
117
+
118
+ // Check each unconfirmed chunk sequentially — stop at first missing one
119
+ for (let i = session.confirmedChunkIndex; i < session.nextChunkIndex; i++) {
120
+ try {
121
+ const params = new URLSearchParams({
122
+ source_tool: session.sourceTool,
123
+ source_session_id: session.sourceSessionId,
124
+ chunk_index: String(i),
125
+ });
126
+ const result = await client.get(`/api/v1/traces/exists?${params}`) as ChunkExistsResponse;
127
+ if (!result.exists) break;
128
+ state.sessions[key] = {
129
+ ...state.sessions[key]!,
130
+ confirmedChunkIndex: i + 1,
131
+ confirmedOpenChunkStartTurn: state.sessions[key]!.openChunkStartTurn,
132
+ unconfirmedSince: i + 1 >= session.nextChunkIndex ? null : state.sessions[key]!.unconfirmedSince,
133
+ };
134
+ } catch {
135
+ break;
136
+ }
137
+ }
138
+ }
139
+ }
140
+
83
141
  export async function flushTrackedSessions(
84
142
  config: Config,
85
143
  sources: SessionSource[],
86
- opts: { includeIdleTracked?: boolean; now?: Date } = {}
144
+ opts: { includeIdleTracked?: boolean; now?: Date; prefetchedTraces?: Map<string, NormalizedTrace>; sync?: boolean } = {}
87
145
  ): Promise<FlushResult> {
88
146
  const now = opts.now ?? new Date();
89
147
  const state = loadState(config.profile);
@@ -94,12 +152,24 @@ export async function flushTrackedSessions(
94
152
  ...(opts.includeIdleTracked ? collectIdleSessionSources(state.sessions, now) : []),
95
153
  ]);
96
154
 
97
- const results: SessionFlushResult[] = [];
98
-
99
- for (const source of allSources) {
100
- results.push(await processSessionSource(source, state, config, client, now));
155
+ // Migrate any existing sessions that predate the confirmation fields
156
+ for (const [key, session] of Object.entries(state.sessions)) {
157
+ state.sessions[key] = migrateSessionUploadState(session);
101
158
  }
102
159
 
160
+ // Verify previously queued chunks and handle 2hr re-submit timeout
161
+ await verifyUnconfirmedChunks(state, client, now);
162
+
163
+ // Process sessions concurrently — each session's chunks stay sequential internally
164
+ const results = await pLimit(
165
+ allSources.map((source) => () => processSessionSource(
166
+ source, state, config, client, now,
167
+ opts.prefetchedTraces?.get(`${source.tool}:${source.locator}`),
168
+ opts.sync,
169
+ )),
170
+ INGEST_CONCURRENCY,
171
+ );
172
+
103
173
  saveState(state, config.profile);
104
174
 
105
175
  return {
@@ -174,6 +244,9 @@ export function createFreshSessionState(
174
244
  lastSeenTurnCount: 0,
175
245
  lastActivityAt: null,
176
246
  lastFlushedTurnId: null,
247
+ confirmedChunkIndex: 0,
248
+ confirmedOpenChunkStartTurn: 0,
249
+ unconfirmedSince: null,
177
250
  };
178
251
  }
179
252
 
@@ -182,15 +255,20 @@ export function migrateLegacySessionState(
182
255
  trace: NormalizedTrace,
183
256
  legacyChunkIndex: number
184
257
  ): SessionUploadState {
258
+ const nextChunkIndex = legacyChunkIndex + 1;
259
+ const openChunkStartTurn = trace.turn_count;
185
260
  return {
186
261
  sourceTool: source.tool,
187
262
  sourceSessionId: trace.source_session_id,
188
263
  locator: source.locator,
189
- nextChunkIndex: legacyChunkIndex + 1,
190
- openChunkStartTurn: trace.turn_count,
264
+ nextChunkIndex,
265
+ openChunkStartTurn,
191
266
  lastSeenTurnCount: trace.turn_count,
192
267
  lastActivityAt: getLastActivityAt(trace),
193
268
  lastFlushedTurnId: trace.turns[trace.turn_count - 1]?.turn_id ?? null,
269
+ confirmedChunkIndex: nextChunkIndex,
270
+ confirmedOpenChunkStartTurn: openChunkStartTurn,
271
+ unconfirmedSince: null,
194
272
  };
195
273
  }
196
274
 
@@ -199,12 +277,14 @@ async function processSessionSource(
199
277
  state: ReturnType<typeof loadState>,
200
278
  config: Config,
201
279
  client: ApiClient,
202
- now: Date
280
+ now: Date,
281
+ prefetchedTrace?: NormalizedTrace,
282
+ sync = false,
203
283
  ): Promise<SessionFlushResult> {
204
284
  let trace: NormalizedTrace;
205
285
 
206
286
  try {
207
- trace = await extractTraceFromSource(source, config.email);
287
+ trace = prefetchedTrace ?? await extractTraceFromSource(source, config.email);
208
288
  } catch (err) {
209
289
  return {
210
290
  source,
@@ -250,7 +330,10 @@ async function processSessionSource(
250
330
  let payoutCents = 0;
251
331
 
252
332
  for (const upload of plan.uploads) {
253
- const result = await uploadTraceChunk(upload.trace, client);
333
+ const tUpload = Date.now();
334
+ const result = await uploadTraceChunk(upload.trace, client, sync);
335
+ if (!sync) console.error(`[flush] ${key} chunk${upload.trace.chunk_index} queued in ${Date.now()-tUpload}ms`);
336
+ else console.error(`[flush] ${key} chunk${upload.trace.chunk_index} done in ${Date.now()-tUpload}ms err=${result.error?.slice(0,60) ?? 'none'}`);
254
337
  if (result.error) {
255
338
  state.sessions[key] = workingState;
256
339
  if (workingState.nextChunkIndex > 0) {
@@ -313,44 +396,41 @@ async function extractTraceFromSource(
313
396
 
314
397
  async function uploadTraceChunk(
315
398
  trace: NormalizedTrace,
316
- client: ApiClient
399
+ client: ApiClient,
400
+ sync = false,
317
401
  ): Promise<ChunkUploadResult> {
402
+ // Client-side regex redaction runs before transmission; Presidio runs server-side async.
403
+ const payloadTrace = redactTrace(trace, { homeDir: homedir() });
404
+ const jsonSize = JSON.stringify({ trace: payloadTrace, source_tool: payloadTrace.source_tool }).length;
405
+ console.error(`[upload] ${payloadTrace.source_session_id} payload=${Math.round(jsonSize/1024)}KB turns=${payloadTrace.turn_count}`);
406
+
407
+ if (!sync) {
408
+ // Fire and forget — optimistically treat as queued; verifyUnconfirmedChunks confirms later
409
+ client.post("/api/v1/traces/ingest", {
410
+ trace: payloadTrace,
411
+ source_tool: payloadTrace.source_tool,
412
+ }).catch(() => { /* errors handled by unconfirmed retry mechanism */ });
413
+ return { duplicate: false, payoutCents: 0, traceId: null };
414
+ }
415
+
318
416
  try {
319
- const result = await client.post("/api/v1/traces/batch", {
320
- traces: [redactTrace(trace, { homeDir: homedir() })],
321
- source_tool: trace.source_tool,
322
- }) as {
323
- accepted: number;
324
- duplicate: number;
325
- traces: Array<{
326
- error?: string;
327
- is_duplicate?: boolean;
328
- payout_cents?: number;
329
- trace_id?: string;
330
- }>;
331
- };
417
+ const result = await client.post("/api/v1/traces/ingest", {
418
+ trace: payloadTrace,
419
+ source_tool: payloadTrace.source_tool,
420
+ }) as IngestResponse;
332
421
 
333
- const traceResult = result.traces?.[0] ?? {};
334
- if (traceResult.error) {
335
- return {
336
- duplicate: false,
337
- payoutCents: 0,
338
- traceId: traceResult.trace_id ?? null,
339
- error: traceResult.error,
340
- };
422
+ if (result.duplicate) {
423
+ return { duplicate: true, payoutCents: 0, traceId: result.trace_id ?? null };
341
424
  }
342
425
 
343
- return {
344
- duplicate: traceResult.is_duplicate === true || (result.duplicate > 0 && result.accepted === 0),
345
- payoutCents: traceResult.payout_cents ?? 0,
346
- traceId: traceResult.trace_id ?? null,
347
- };
426
+ // 202 queued — payout credited asynchronously; show $0 until processed
427
+ return { duplicate: false, payoutCents: 0, traceId: null };
348
428
  } catch (err) {
349
429
  return {
350
430
  duplicate: false,
351
431
  payoutCents: 0,
352
432
  traceId: null,
353
- error: `Submit failed: ${err}`,
433
+ error: formatSubmitFailure(err),
354
434
  };
355
435
  }
356
436
  }
@@ -379,6 +459,7 @@ function applyUploadedChunk(
379
459
  const nextOpenChunkStartTurn = (uploadedChunk.chunk_start_turn ?? cursor.openChunkStartTurn)
380
460
  + (uploadedChunk.turn_count ?? uploadedChunk.turns.length);
381
461
 
462
+ const nowIso = new Date().toISOString();
382
463
  return {
383
464
  ...cursor,
384
465
  nextChunkIndex: (uploadedChunk.chunk_index ?? cursor.nextChunkIndex) + 1,
@@ -386,6 +467,7 @@ function applyUploadedChunk(
386
467
  lastSeenTurnCount: trace.turn_count,
387
468
  lastActivityAt: getLastActivityAt(trace),
388
469
  lastFlushedTurnId: trace.turns[nextOpenChunkStartTurn - 1]?.turn_id ?? cursor.lastFlushedTurnId,
470
+ unconfirmedSince: cursor.unconfirmedSince ?? nowIso,
389
471
  };
390
472
  }
391
473
 
@@ -485,6 +567,25 @@ function dedupeSources(sources: SessionSource[]): SessionSource[] {
485
567
  return [...unique.values()];
486
568
  }
487
569
 
570
+ function formatSubmitFailure(err: unknown): string {
571
+ return err instanceof Error ? `Submit failed: ${err.message}` : `Submit failed: ${String(err)}`;
572
+ }
573
+
574
+ async function pLimit<T>(tasks: Array<() => Promise<T>>, concurrency: number): Promise<T[]> {
575
+ const results: T[] = new Array(tasks.length);
576
+ let index = 0;
577
+
578
+ async function worker() {
579
+ while (index < tasks.length) {
580
+ const i = index++;
581
+ results[i] = await tasks[i]!();
582
+ }
583
+ }
584
+
585
+ await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
586
+ return results;
587
+ }
588
+
488
589
  export function buildFileSessionSource(
489
590
  tool: "claude_code" | "codex_cli",
490
591
  filePath: string
@@ -1,8 +0,0 @@
1
- import chalk from "chalk";
2
- import { CLI_NAME } from "../constants.js";
3
- import { loginCommand } from "./login.js";
4
-
5
- export async function registerCommand(opts: { profile?: string; serverUrl?: string }): Promise<void> {
6
- console.log(chalk.yellow(`\`${CLI_NAME} register\` is now an alias for \`${CLI_NAME} login\`.`));
7
- await loginCommand({ profile: opts.profile, serverUrl: opts.serverUrl });
8
- }