diff --git a/README.md b/README.md index 9f17b72..5165ec3 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,24 @@ crono quick-add [options] At least one macro flag (`-p`, `-c`, `-f`, or `-a`) is required. +**Safe execution and verification:** Quick-add is not idempotent and saves each +macro separately (`Quick Add, Protein`, `Quick Add, Carbohydrate`, `Quick Add, Fat`, +and `Quick Add, Alcohol`). Wait for the original CLI process to exit; if your +execution tool returns a session ID, retain it and poll that same session. +Progress output or a tool's wait deadline is not a completed failure. Never +launch a second write while the first may still be running. + +Take an unfiltered `crono export servings -d YYYY-MM-DD --json` snapshot before +writing, then compare another snapshot after the process completes. Check each +requested macro's amount and meal, not the restaurant or sandwich name. Empty +servings exports produce `[]`; invalid exports fail instead of implying absence. +Check the exit status and keep stderr visible. A missing row while a write is +pending does not prove failure. A failed write may have saved only some macros; +never replay the whole meal after a partial save. If the operation's completion +or diary state is uncertain, stop and report an unconfirmed outcome rather than +retrying. The browser SDK's automatic transport retries are disabled to avoid +replaying non-idempotent operations after a lost response. + **Examples:** ```bash diff --git a/src/commands/export.ts b/src/commands/export.ts index fc70791..b480d67 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -103,6 +103,10 @@ export async function exportCmd( const target = options.meal.toLowerCase(); entries = entries.filter((e) => e.meal.toLowerCase() === target); } + if (options.json) { + console.log(JSON.stringify(entries, null, 2)); + return; + } if (entries.length === 0) { if (!silent) { const suffix = options.meal ? ` for meal "${options.meal}"` : ""; @@ -110,11 +114,7 @@ export async function exportCmd( } return; } - if (options.json) { - console.log(JSON.stringify(isRange ? entries : entries, null, 2)); - } else { - formatServings(entries, isRange); - } + formatServings(entries, isRange); return; } @@ -155,7 +155,11 @@ export async function exportCmd( } } catch (error) { s?.stop("Failed."); - p.log.error(formatKernelError(error)); + if (silent) { + console.error(formatKernelError(error)); + } else { + p.log.error(formatKernelError(error)); + } process.exit(1); } } diff --git a/src/commands/quick-add.ts b/src/commands/quick-add.ts index d26acd5..235d285 100644 --- a/src/commands/quick-add.ts +++ b/src/commands/quick-add.ts @@ -55,6 +55,9 @@ export async function quickAdd(options: QuickAddOptions): Promise { : "Uncategorized"; p.intro("🍎 crono quick-add"); + p.log.warn( + "Non-idempotent write: wait for this process to exit. Progress output or a tool timeout does not mean failure. Do not start another write while this one may still be running." + ); const s = p.spinner(); s.start("Connecting..."); @@ -77,8 +80,11 @@ export async function quickAdd(options: QuickAddOptions): Promise { const dateInfo = resolvedDate ? ` on ${resolvedDate}` : ""; p.outro(`Added: ${parts.join(", ")} → ${mealLabel}${dateInfo}`); } catch (error) { - s.stop("Failed."); - p.log.error(`Failed to add entry: ${formatKernelError(error)}`); + s.stop("Write outcome unknown."); + p.log.error(`Could not confirm completion: ${formatKernelError(error)}`); + p.log.warn( + "Some or all macros may already be saved. Do not retry the whole meal. After the original operation has stopped, verify every requested Quick Add macro and amount on the target date. An empty export alone does not prove the write failed. If the outcome remains uncertain, report it and stop." + ); process.exit(1); } } diff --git a/src/cronometer/parse.ts b/src/cronometer/parse.ts index cd87541..8067c0a 100644 --- a/src/cronometer/parse.ts +++ b/src/cronometer/parse.ts @@ -173,9 +173,13 @@ export function parseExercises(csv: string): ExerciseEntry[] { export function parseServings(csv: string): ServingEntry[] { const rows = parseCSV(csv); - if (rows.length < 2) return []; const headers = rows[0]; + for (const name of ["Day", "Group", "Food Name", "Amount"]) { + if (colIndex(headers, name) === -1) { + throw new Error(`Invalid servings export: missing "${name}" column`); + } + } const dayIdx = colIndex(headers, "Day"); const timeIdx = colIndex(headers, "Time"); const groupIdx = colIndex(headers, "Group"); diff --git a/src/kernel/client.ts b/src/kernel/client.ts index 5741517..01c35b4 100644 --- a/src/kernel/client.ts +++ b/src/kernel/client.ts @@ -64,7 +64,10 @@ export async function getKernelClient(): Promise { } process.env["KERNEL_API_KEY"] = apiKey; - const kernel = new Kernel({ timeout: KERNEL_REQUEST_TIMEOUT_MS }); + const kernel = new Kernel({ + timeout: KERNEL_REQUEST_TIMEOUT_MS, + maxRetries: 0, + }); return createAutomationClient(createKernelRuntimeFactory(kernel)); } diff --git a/src/kernel/errors.ts b/src/kernel/errors.ts index 9b38259..c0a3a4b 100644 --- a/src/kernel/errors.ts +++ b/src/kernel/errors.ts @@ -21,7 +21,8 @@ export function formatKernelError(error: unknown): string { return ( "Request to Kernel API timed out.\n" + " This usually means the Kernel service is slow or unreachable.\n" + - " Try again in a few moments, or check https://status.kernel.sh" + " A remote write may still be running or already saved. Do not retry a write without confirming its final state.\n" + + " Check https://status.kernel.sh" ); } @@ -48,7 +49,7 @@ export function formatKernelError(error: unknown): string { if (error instanceof RateLimitError) { return ( `Kernel API rate limit exceeded (HTTP ${error.status}).\n` + - " Please wait a few minutes and try again." + " Please wait a few minutes. Before retrying a write, confirm the previous operation stopped and did not save data." ); } diff --git a/tests/cronometer/parse.test.ts b/tests/cronometer/parse.test.ts index 9954f06..057a2fe 100644 --- a/tests/cronometer/parse.test.ts +++ b/tests/cronometer/parse.test.ts @@ -174,7 +174,30 @@ describe("parseServings", () => { expect(entries[1]["Fiber (g)"]).toBe(2.13); }); - it("should return empty array for empty CSV", () => { - expect(parseServings("")).toEqual([]); + it("returns no servings only for a valid header-only export", () => { + expect(parseServings(sampleCSV.split("\n")[0])).toEqual([]); + }); + + it.each(["", "Login required", "Day,Amount\n2026-09-09,37"])( + "rejects invalid exports instead of implying entries are absent: %s", + (csv) => { + expect(() => parseServings(csv)).toThrow("Invalid servings export"); + } + ); + + it("preserves separate quick-add rows and amounts for verification", () => { + const csv = `Day,Time,Group,Food Name,Amount +2026-09-09,02:12 PM,Breakfast,"Quick Add, Protein",37 g +2026-09-09,02:12 PM,Breakfast,"Quick Add, Carbohydrate",58 g +2026-09-09,02:12 PM,Breakfast,"Quick Add, Fat",38 g`; + + expect(parseServings(csv)).toEqual([ + expect.objectContaining({ food: "Quick Add, Protein", amount: "37 g" }), + expect.objectContaining({ + food: "Quick Add, Carbohydrate", + amount: "58 g", + }), + expect.objectContaining({ food: "Quick Add, Fat", amount: "38 g" }), + ]); }); }); diff --git a/tests/export-output.test.ts b/tests/export-output.test.ts new file mode 100644 index 0000000..a3d0339 --- /dev/null +++ b/tests/export-output.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { exportCmd } from "../src/commands/export.js"; +import { exportData } from "../src/cronometer/export.js"; + +vi.mock("../src/cronometer/export.js", () => ({ exportData: vi.fn() })); + +describe("servings export output", () => { + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process exited"); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.mocked(exportData).mockReset(); + }); + + it("emits an explicit JSON array for a valid empty diary", async () => { + vi.mocked(exportData).mockResolvedValue("Day,Group,Food Name,Amount"); + await exportCmd("servings", { date: "2026-09-09", json: true }); + + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("[]"); + expect(exportData).toHaveBeenCalledWith( + "servings", + "2026-09-09", + "2026-09-09", + expect.any(Function) + ); + }); + + it("emits an explicit array when a meal filter has no matches", async () => { + vi.mocked(exportData).mockResolvedValue( + 'Day,Group,Food Name,Amount\n2026-09-09,Lunch,"Quick Add, Protein",37 g' + ); + await exportCmd("servings", { + date: "2026-09-09", + json: true, + meal: "Breakfast", + }); + + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("[]"); + }); + + it.each(["", "Login required"])( + "fails on an invalid export instead of emitting an empty array", + async (csv) => { + vi.mocked(exportData).mockResolvedValue(csv); + await expect(exportCmd("servings", { json: true })).rejects.toThrow( + "process exited" + ); + + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid servings export") + ); + expect(process.exit).toHaveBeenCalledWith(1); + } + ); + + it("reports a failed read on stderr with a nonzero exit status", async () => { + vi.mocked(exportData).mockRejectedValue(new Error("Export failed: 503")); + await expect(exportCmd("servings", { json: true })).rejects.toThrow( + "process exited" + ); + + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith("Export failed: 503"); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/kernel/client.test.ts b/tests/kernel/client.test.ts index aa41307..bf501b8 100644 --- a/tests/kernel/client.test.ts +++ b/tests/kernel/client.test.ts @@ -37,4 +37,13 @@ describe("getKernelClient", () => { expect(opts?.timeout).toBeDefined(); expect(opts?.timeout).toBeGreaterThan(120_000); }); + + it("disables transport retries that could replay a committed write", async () => { + const { getKernelClient } = await import("../../src/kernel/client.js"); + await getKernelClient(); + + expect(await getCtor()).toHaveBeenCalledWith( + expect.objectContaining({ maxRetries: 0 }) + ); + }); }); diff --git a/tests/kernel/errors.test.ts b/tests/kernel/errors.test.ts index b09cceb..4b12c39 100644 --- a/tests/kernel/errors.test.ts +++ b/tests/kernel/errors.test.ts @@ -14,6 +14,10 @@ describe("formatKernelError", () => { const result = formatKernelError(error); expect(result).toContain("timed out"); expect(result).toContain("status.kernel.sh"); + expect(result).toContain( + "remote write may still be running or already saved" + ); + expect(result).not.toContain("Try again in a few moments"); }); it("should format APIConnectionError with cause", () => { diff --git a/tests/quick-add-outcome.test.ts b/tests/quick-add-outcome.test.ts new file mode 100644 index 0000000..692f947 --- /dev/null +++ b/tests/quick-add-outcome.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as prompts from "@clack/prompts"; +import { quickAdd } from "../src/commands/quick-add.js"; +import { getAutomationClient } from "../src/automation/client.js"; + +vi.mock("../src/automation/client.js", () => ({ + getAutomationClient: vi.fn(), +})); +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { warn: vi.fn(), error: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })), +})); + +describe("quick-add outcome reporting", () => { + const addQuickEntry = vi.fn(); + + beforeEach(() => { + vi.mocked(getAutomationClient).mockResolvedValue({ + addQuickEntry, + } as unknown as Awaited>); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process exited"); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + addQuickEntry.mockReset(); + }); + + it("does not announce completion while the original write is pending", async () => { + let completeWrite!: () => void; + addQuickEntry.mockReturnValue( + new Promise((resolve) => { + completeWrite = resolve; + }) + ); + + const operation = quickAdd({ protein: 37, carbs: 58, fat: 38 }); + await vi.waitFor(() => expect(addQuickEntry).toHaveBeenCalledTimes(1)); + + expect(prompts.outro).not.toHaveBeenCalled(); + expect(prompts.log.warn).toHaveBeenCalledWith( + expect.stringContaining("wait for this process to exit") + ); + + completeWrite(); + await operation; + + expect(prompts.outro).toHaveBeenCalledTimes(1); + expect(addQuickEntry).toHaveBeenCalledTimes(1); + }); + + it("reports an uncertain write without automatically retrying it", async () => { + addQuickEntry.mockRejectedValue( + new Error("Response lost after saving protein") + ); + + await expect(quickAdd({ protein: 37, carbs: 58, fat: 38 })).rejects.toThrow( + "process exited" + ); + + expect(addQuickEntry).toHaveBeenCalledTimes(1); + expect(prompts.outro).not.toHaveBeenCalled(); + expect(prompts.log.error).toHaveBeenCalledWith( + expect.stringContaining("Could not confirm completion") + ); + expect(prompts.log.warn).toHaveBeenCalledWith( + expect.stringContaining("Some or all macros may already be saved") + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); +});