diff --git a/package.json b/package.json index fe9a224..9efb94f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@withone/cli", - "version": "1.43.4", + "version": "1.43.5", "description": "CLI for managing One", "type": "module", "files": [ diff --git a/src/lib/memory/sync/index.ts b/src/lib/memory/sync/index.ts index 315e164..6a9ed5d 100644 --- a/src/lib/memory/sync/index.ts +++ b/src/lib/memory/sync/index.ts @@ -610,13 +610,18 @@ async function syncRunCommand(platform: string, options: SyncRunOptions): Promis } catch (err) { // Use real counts attached by the runner (if available) instead of 0 const errObj = err as any; + const errorContext: { message: string; httpStatus?: number; retryAfter?: number } = { + message: err instanceof Error ? err.message : String(err), + ...(errObj?._httpStatus !== undefined ? { httpStatus: errObj._httpStatus } : {}), + ...(errObj?._retryAfter !== undefined ? { retryAfter: errObj._retryAfter } : {}), + }; results.push({ model: profile.model, recordsSynced: errObj?._recordsSynced ?? 0, pagesProcessed: errObj?._pagesProcessed ?? 0, duration: '0s', status: 'failed', - error: err instanceof Error ? err.message : String(err), + error: errorContext, }); } } @@ -643,8 +648,11 @@ async function syncRunCommand(platform: string, options: SyncRunOptions): Promis const archivedColor = sc.archived > sc.active ? pc.red : pc.dim; console.log(` memory: ${pc.green(String(sc.active))} active, ${archivedColor(String(sc.archived))} archived`); } - if ('error' in r && r.error) { - console.log(` ${pc.red(r.error as string)}`); + if (r.error) { + const errParts = [r.error.message]; + if (r.error.httpStatus) errParts.push(`HTTP ${r.error.httpStatus}`); + if (r.error.retryAfter) errParts.push(`retry after ${r.error.retryAfter}s`); + console.log(` ${pc.red(errParts.join(' — '))}`); } } } diff --git a/src/lib/memory/sync/runner.ts b/src/lib/memory/sync/runner.ts index 7a0f9b9..fcc1596 100644 --- a/src/lib/memory/sync/runner.ts +++ b/src/lib/memory/sync/runner.ts @@ -847,6 +847,11 @@ export async function syncModel( const rawMsg = err instanceof Error ? err.message : String(err); const shortMsg = truncate(rawMsg, 500); + // Propagate HTTP-level context so callers can distinguish 429/401/5xx + // from network failures without parsing the message string. + const httpStatus = err instanceof ApiError ? err.status : undefined; + const retryAfter = err instanceof ApiError ? err.retryAfterSeconds : undefined; + if (pagesProcessed > 0) { const resumeErr = new Error( `Sync interrupted after page ${pagesProcessed} (${totalRecords} records). ` + @@ -855,6 +860,8 @@ export async function syncModel( // Attach real counts so callers can report progress (resumeErr as any)._recordsSynced = totalRecords; (resumeErr as any)._pagesProcessed = pagesProcessed; + (resumeErr as any)._httpStatus = httpStatus; + (resumeErr as any)._retryAfter = retryAfter; throw resumeErr; } @@ -862,6 +869,8 @@ export async function syncModel( const wrapped = new Error(shortMsg); (wrapped as any)._recordsSynced = totalRecords; (wrapped as any)._pagesProcessed = pagesProcessed; + (wrapped as any)._httpStatus = httpStatus; + (wrapped as any)._retryAfter = retryAfter; throw wrapped; } finally { process.off('SIGINT', onSigint); diff --git a/src/lib/memory/sync/types.ts b/src/lib/memory/sync/types.ts index ce43c38..b10be4d 100644 --- a/src/lib/memory/sync/types.ts +++ b/src/lib/memory/sync/types.ts @@ -180,12 +180,22 @@ export interface ModelSyncState { export type SyncState = Record>; +export interface SyncRunError { + message: string; + /** HTTP status code when the failure originated from an API response (e.g. 429, 401). */ + httpStatus?: number; + /** Seconds until retry is safe, parsed from Retry-After (set when httpStatus is 429). */ + retryAfter?: number; +} + export interface SyncRunResult { model: string; recordsSynced: number; pagesProcessed: number; duration: string; status: 'complete' | 'failed' | 'dry-run'; + /** Populated when status is "failed" — structured error context for programmatic handling. */ + error?: SyncRunError; /** Rows removed by --full-refresh because they were no longer in the source. */ deletedStale?: number; /** Whether --full-refresh actually ran reconcile (skipped on truncated pagination). */