Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,18 @@ 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}"` : "";
p.outro(`No servings found${suffix}`);
}
return;
}
if (options.json) {
console.log(JSON.stringify(isRange ? entries : entries, null, 2));
} else {
formatServings(entries, isRange);
}
formatServings(entries, isRange);
return;
}

Expand Down Expand Up @@ -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);
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/commands/quick-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export async function quickAdd(options: QuickAddOptions): Promise<void> {
: "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...");
Expand All @@ -77,8 +80,11 @@ export async function quickAdd(options: QuickAddOptions): Promise<void> {
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);
}
}
6 changes: 5 additions & 1 deletion src/cronometer/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion src/kernel/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export async function getKernelClient(): Promise<KernelClient> {
}

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));
}
Expand Down
5 changes: 3 additions & 2 deletions src/kernel/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

Expand All @@ -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."
);
}

Expand Down
27 changes: 25 additions & 2 deletions tests/cronometer/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(["", "<html>Login required</html>", "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" }),
]);
});
});
75 changes: 75 additions & 0 deletions tests/export-output.test.ts
Original file line number Diff line number Diff line change
@@ -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(["", "<html>Login required</html>"])(
"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);
});
});
9 changes: 9 additions & 0 deletions tests/kernel/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
);
});
});
4 changes: 4 additions & 0 deletions tests/kernel/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
76 changes: 76 additions & 0 deletions tests/quick-add-outcome.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getAutomationClient>>);
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<void>((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);
});
});
Loading