feat(flight-finder): implement milestone M2 - report workflow and delivery gates

This commit is contained in:
2026-03-30 17:00:09 -05:00
parent ba5b0e4e67
commit c30ad85e0d
20 changed files with 1050 additions and 24 deletions

55
docs/flight-finder.md Normal file
View File

@@ -0,0 +1,55 @@
# flight-finder
Reusable flight-search report skill for OpenClaw. It replaces the brittle one-off `dfw-blq-2026.md` prompt with typed intake, bounded source orchestration, explicit report gates, fixed-template PDF output, and Luke-sender email delivery.
## Core behavior
`flight-finder` is designed to:
- collect missing trip inputs explicitly instead of relying on hardcoded prompt prose
- support split returns, flexible dates, passenger groups, and exclusions
- use bounded search phases across KAYAK, Skyscanner, Expedia, and a best-effort airline direct cross-check
- normalize pricing to USD before ranking
- produce a report payload first, then render PDF/email only when the report is complete
- behave safely on WhatsApp-style chat surfaces by treating status nudges as updates, not resets
## Important rules
- Recipient email is a delivery gate, not a search gate.
- `marketCountry` is explicit-only in this implementation pass.
- It must be an ISO 3166-1 alpha-2 uppercase code such as `TH` or `DE`.
- If present, it activates VPN only for the bounded search phase.
- If omitted, no VPN change happens.
- If VPN connect or verification fails, the run falls back to the default market and records a degraded warning instead of hanging.
- Direct-airline cross-checking is currently best-effort, not a hard blocker.
## Helper package
From `skills/flight-finder/`:
```bash
npm install
npm run normalize-request -- --legacy-dfw-blq
npm run normalize-request -- --input "<request.json>"
npm run report-status -- --input "<report-payload.json>"
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
```
## Delivery
- sender identity: `luke@fiorinis.com`
- send path:
- `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`
- if the user already provided the destination email, that counts as delivery authorization once the report is ready
## Source viability for implementation pass 1
Bounded checks on Stefano's MacBook Air showed:
- `KAYAK`: viable
- `Skyscanner`: viable
- `Expedia`: viable
- airline direct-booking cross-check: degraded / best-effort
That means the first implementation pass should rely primarily on the three aggregator sources and treat direct-airline confirmation as additive evidence when it succeeds.

View File

@@ -101,6 +101,25 @@ Rules:
- If recipient email is missing, ask for it before the render/send phase.
- If the user already provided the recipient email, do not ask for a redundant “send it” confirmation.
## Helper commands
From `~/.openclaw/workspace/skills/flight-finder/` or the repo mirror copy:
```bash
npm run normalize-request -- --legacy-dfw-blq
npm run normalize-request -- --input "<request.json>"
npm run report-status -- --input "<report-payload.json>"
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
```
Rules:
- `normalize-request` should report missing search inputs separately from delivery-only email gaps
- `report-status` should expose whether the run is ready to search, ready for a chat summary, ready to render a PDF, or ready to email
- `render-report` must reject incomplete report payloads
- `delivery-plan` must stay on the Luke sender path and must not silently fall back to another sender
## Prompt migration rule
The old DFW↔BLQ prompt is legacy input only. Do not treat it as the execution engine. Convert its hardcoded logic into the typed request model and run the skill workflow instead.

View File

@@ -8,9 +8,11 @@
"name": "flight-finder-scripts",
"version": "1.0.0",
"dependencies": {
"minimist": "^1.2.8",
"pdfkit": "^0.17.2"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/pdfkit": "^0.17.3",
"tsx": "^4.20.6",
"typescript": "^5.9.2"
@@ -467,6 +469,13 @@
"tslib": "^2.8.0"
}
},
"node_modules/@types/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
@@ -656,6 +665,15 @@
"node": ">= 0.4"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",

View File

@@ -4,12 +4,18 @@
"description": "Flight finder helpers for request normalization, readiness gating, and fixed-template PDF rendering",
"type": "module",
"scripts": {
"normalize-request": "tsx src/cli.ts normalize-request",
"report-status": "tsx src/cli.ts report-status",
"render-report": "tsx src/cli.ts render-report",
"delivery-plan": "tsx src/cli.ts delivery-plan",
"test": "node --import tsx --test tests/*.test.ts"
},
"dependencies": {
"minimist": "^1.2.8",
"pdfkit": "^0.17.2"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/pdfkit": "^0.17.3",
"tsx": "^4.20.6",
"typescript": "^5.9.2"

View File

@@ -0,0 +1,86 @@
import fs from "node:fs/promises";
import minimist from "minimist";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "./request-normalizer.js";
import { getFlightReportStatus } from "./report-status.js";
import { renderFlightReportPdf } from "./report-pdf.js";
import { buildLukeDeliveryPlan } from "./report-delivery.js";
import type { FlightReportPayload, FlightReportRequestDraft } from "./types.js";
function usage(): never {
throw new Error(`Usage:
flight-finder normalize-request --input "<request.json>"
flight-finder normalize-request --legacy-dfw-blq
flight-finder report-status --input "<report-payload.json>"
flight-finder render-report --input "<report-payload.json>" --output "<report.pdf>"
flight-finder delivery-plan --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"`);
}
async function readJsonFile<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
}
async function main(): Promise<void> {
const argv = minimist(process.argv.slice(2), {
string: ["input", "output", "to", "subject", "body", "attach"],
boolean: ["legacy-dfw-blq"]
});
const command = argv._[0];
if (!command) {
usage();
}
if (command === "normalize-request") {
const draft = argv["legacy-dfw-blq"]
? DFW_BLQ_2026_PROMPT_DRAFT
: await readJsonFile<FlightReportRequestDraft>(argv.input || usage());
console.log(JSON.stringify(normalizeFlightReportRequest(draft), null, 2));
return;
}
if (command === "report-status") {
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
console.log(
JSON.stringify(
getFlightReportStatus(normalizeFlightReportRequest(payload.request), payload),
null,
2
)
);
return;
}
if (command === "render-report") {
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
const rendered = await renderFlightReportPdf(payload, argv.output || usage());
console.log(rendered);
return;
}
if (command === "delivery-plan") {
console.log(
JSON.stringify(
buildLukeDeliveryPlan({
recipientEmail: argv.to,
subject: argv.subject || "",
body: argv.body || "",
attachmentPath: argv.attach || usage()
}),
null,
2
)
);
return;
}
usage();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View File

@@ -0,0 +1,25 @@
export function normalizeMarketCountry(
value: string | null | undefined
): string | null | undefined {
const cleaned = typeof value === "string" ? value.trim() : undefined;
if (!cleaned) {
return undefined;
}
const normalized = cleaned.toUpperCase();
if (!/^[A-Z]{2}$/.test(normalized)) {
throw new Error(
`Invalid marketCountry "${value}". Use an ISO 3166-1 alpha-2 uppercase country code such as "TH" or "DE".`
);
}
return normalized;
}
export function isPlausibleEmail(value: string | null | undefined): boolean {
if (!value) {
return false;
}
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

View File

@@ -0,0 +1,36 @@
export type NormalizePriceInput = {
amount: number;
currency: string;
exchangeRates?: Record<string, number>;
};
export type NormalizedPrice = {
currency: "USD";
amountUsd: number;
notes: string[];
};
export function normalizePriceToUsd(
input: NormalizePriceInput
): NormalizedPrice {
const currency = input.currency.trim().toUpperCase();
if (currency === "USD") {
return {
currency: "USD",
amountUsd: input.amount,
notes: ["Price already in USD."]
};
}
const rate = input.exchangeRates?.[currency];
if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
throw new Error(`Missing usable exchange rate for ${currency}.`);
}
const amountUsd = Math.round(input.amount * rate * 100) / 100;
return {
currency: "USD",
amountUsd,
notes: [`Converted ${currency} to USD using rate ${rate}.`]
};
}

View File

@@ -0,0 +1,50 @@
import { isPlausibleEmail } from "./input-validation.js";
type DeliveryPlanInput = {
recipientEmail?: string | null;
subject: string;
body: string;
attachmentPath: string;
};
export type DeliveryPlan = {
ready: boolean;
needsRecipientEmail: boolean;
command: string | null;
sender: "luke@fiorinis.com";
recipientEmail: string | null;
};
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
export function buildLukeDeliveryPlan(input: DeliveryPlanInput): DeliveryPlan {
const recipientEmail = input.recipientEmail?.trim() || null;
if (!recipientEmail || !isPlausibleEmail(recipientEmail)) {
return {
ready: false,
needsRecipientEmail: true,
command: null,
sender: "luke@fiorinis.com",
recipientEmail
};
}
const validRecipientEmail: string = recipientEmail;
const command = [
"zsh ~/.openclaw/workspace/bin/gog-luke gmail send",
`--to ${shellQuote(validRecipientEmail)}`,
`--subject ${shellQuote(input.subject)}`,
`--body ${shellQuote(input.body)}`,
`--attach ${shellQuote(input.attachmentPath)}`
].join(" ");
return {
ready: true,
needsRecipientEmail: false,
command,
sender: "luke@fiorinis.com",
recipientEmail: validRecipientEmail
};
}

View File

@@ -0,0 +1,94 @@
import fs from "node:fs";
import path from "node:path";
import PDFDocument from "pdfkit";
import { getFlightReportStatus } from "./report-status.js";
import { normalizeFlightReportRequest } from "./request-normalizer.js";
import type { FlightReportPayload } from "./types.js";
export class ReportValidationError extends Error {}
function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] {
return value?.length ? value : [fallback];
}
export async function renderFlightReportPdf(
payload: FlightReportPayload,
outputPath: string
): Promise<string> {
const status = getFlightReportStatus(
normalizeFlightReportRequest(payload.request),
payload
);
if (!status.pdfReady) {
throw new ReportValidationError(
"The flight report payload is still incomplete. Finish the report before generating the PDF."
);
}
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const doc = new PDFDocument({
size: "LETTER",
margin: 50
});
const stream = fs.createWriteStream(outputPath);
doc.pipe(stream);
try {
doc.fontSize(20).text(payload.request.tripName || "Flight report", {
align: "left"
});
doc.moveDown(0.25);
doc.fontSize(10).fillColor("#555").text(`Generated: ${payload.generatedAt}`);
doc.fillColor("#000");
doc.moveDown();
doc.fontSize(14).text("Executive Summary");
bulletLines(payload.executiveSummary).forEach((line) => {
doc.fontSize(10).text(`${line}`);
});
doc.moveDown();
doc.fontSize(14).text("Recommended Options");
payload.rankedOptions.forEach((option) => {
doc.fontSize(11).text(
`${option.backup ? "Backup" : "Primary"}: ${option.title}$${option.totalPriceUsd.toFixed(2)}`
);
doc.fontSize(10).text(option.rationale);
doc.moveDown(0.5);
});
doc.moveDown();
doc.fontSize(14).text("Source Findings");
payload.sourceFindings.forEach((finding) => {
doc.fontSize(11).text(`${finding.source}: ${finding.status}`);
bulletLines(finding.notes).forEach((line) => doc.fontSize(10).text(`${line}`));
doc.moveDown(0.25);
});
doc.moveDown();
doc.fontSize(14).text("Warnings And Degraded Conditions");
bulletLines(
[...payload.reportWarnings, ...payload.degradedReasons],
"No additional warnings."
).forEach((line) => {
doc.fontSize(10).text(`${line}`);
});
doc.end();
await new Promise<void>((resolve, reject) => {
stream.on("finish", () => resolve());
stream.on("error", reject);
});
} catch (error) {
stream.destroy();
await fs.promises.unlink(outputPath).catch(() => {});
throw error;
}
return outputPath;
}

View File

@@ -0,0 +1,83 @@
import type {
FlightReportPayload,
FlightReportStatusResult,
NormalizedFlightReportRequest
} from "./types.js";
import { isPlausibleEmail } from "./input-validation.js";
export function getFlightReportStatus(
normalizedRequest: NormalizedFlightReportRequest,
payload?: FlightReportPayload | null
): FlightReportStatusResult {
if (normalizedRequest.missingSearchInputs.length) {
return {
needsMissingInputs: normalizedRequest.missingSearchInputs,
readyToSearch: false,
pdfReady: false,
emailReady: false,
chatSummaryReady: false,
terminalOutcome: "missing-inputs",
degraded: false,
degradedReasons: [],
blockingReason: "Missing search-critical trip inputs."
};
}
if (!payload) {
return {
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
readyToSearch: true,
pdfReady: false,
emailReady: false,
chatSummaryReady: false,
terminalOutcome: "report-incomplete",
degraded: false,
degradedReasons: [],
lastCompletedPhase: "intake",
blockingReason: "Search and report assembly have not completed yet."
};
}
const allSourcesFailed =
payload.sourceFindings.length > 0 &&
payload.sourceFindings.every((finding) => finding.status === "blocked") &&
payload.quotes.length === 0;
if (allSourcesFailed) {
return {
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
readyToSearch: true,
pdfReady: false,
emailReady: false,
chatSummaryReady: true,
terminalOutcome: "all-sources-failed",
degraded: true,
degradedReasons: payload.degradedReasons,
lastCompletedPhase: payload.lastCompletedPhase || "search",
blockingReason: "All configured travel sources failed or were blocked."
};
}
const reportComplete = Boolean(
payload.quotes.length &&
payload.rankedOptions.length &&
payload.executiveSummary.length
);
const validRecipient = isPlausibleEmail(normalizedRequest.request.recipientEmail);
return {
needsMissingInputs: validRecipient ? [] : normalizedRequest.missingDeliveryInputs,
readyToSearch: true,
pdfReady: reportComplete,
emailReady: reportComplete && validRecipient,
chatSummaryReady:
payload.quotes.length > 0 ||
payload.rankedOptions.length > 0 ||
payload.executiveSummary.length > 0,
terminalOutcome: reportComplete ? "ready" : "report-incomplete",
degraded: payload.degradedReasons.length > 0,
degradedReasons: payload.degradedReasons,
lastCompletedPhase: payload.lastCompletedPhase,
blockingReason: reportComplete ? undefined : "The report payload is still incomplete."
};
}

View File

@@ -6,6 +6,7 @@ import type {
FlightSearchPreferences,
NormalizedFlightReportRequest
} from "./types.js";
import { isPlausibleEmail, normalizeMarketCountry } from "./input-validation.js";
const DEFAULT_PREFERENCES: FlightSearchPreferences = {
preferOneStop: true,
@@ -34,30 +35,6 @@ function uniqueStrings(values: Array<string | null | undefined> | undefined): st
return cleaned.length ? Array.from(new Set(cleaned)) : undefined;
}
function normalizeMarketCountry(value: string | null | undefined): string | null | undefined {
const cleaned = cleanString(value);
if (!cleaned) {
return undefined;
}
const normalized = cleaned.toUpperCase();
if (!/^[A-Z]{2}$/.test(normalized)) {
throw new Error(
`Invalid marketCountry "${value}". Use an ISO 3166-1 alpha-2 uppercase country code such as "TH" or "DE".`
);
}
return normalized;
}
function isPlausibleEmail(value: string | null | undefined): boolean {
if (!value) {
return false;
}
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function normalizePassengerGroups(
groups: FlightReportRequestDraft["passengerGroups"]
): FlightPassengerGroup[] {

View File

@@ -0,0 +1,59 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { FlightRunState } from "./types.js";
const DEFAULT_STATE_DIR = path.join(
os.homedir(),
".openclaw",
"workspace",
"state",
"flight-finder"
);
export function getFlightFinderStatePath(
baseDir = DEFAULT_STATE_DIR
): string {
return path.join(baseDir, "flight-finder-run.json");
}
export async function saveFlightFinderRunState(
state: FlightRunState,
baseDir = DEFAULT_STATE_DIR
): Promise<string> {
await fs.mkdir(baseDir, { recursive: true });
const statePath = getFlightFinderStatePath(baseDir);
await fs.writeFile(statePath, JSON.stringify(state, null, 2));
return statePath;
}
export async function loadFlightFinderRunState(
baseDir = DEFAULT_STATE_DIR
): Promise<FlightRunState | null> {
const statePath = getFlightFinderStatePath(baseDir);
try {
const raw = await fs.readFile(statePath, "utf8");
return JSON.parse(raw) as FlightRunState;
} catch (error) {
const maybeNodeError = error as NodeJS.ErrnoException;
if (maybeNodeError.code === "ENOENT") {
return null;
}
throw error;
}
}
export async function clearFlightFinderRunState(
baseDir = DEFAULT_STATE_DIR
): Promise<void> {
const statePath = getFlightFinderStatePath(baseDir);
try {
await fs.unlink(statePath);
} catch (error) {
const maybeNodeError = error as NodeJS.ErrnoException;
if (maybeNodeError.code !== "ENOENT") {
throw error;
}
}
}

View File

@@ -0,0 +1,81 @@
import type {
FlightReportRequest,
FlightSearchPlan,
FlightSearchSourceFinding,
FlightSearchSourceName,
FlightSearchSourceViability
} from "./types.js";
export const SOURCE_SILENT_TIMEOUT_MS = 45_000;
export const SOURCE_TOTAL_TIMEOUT_MS = 180_000;
export const RUN_TOTAL_TIMEOUT_MS = 900_000;
export const VPN_CONNECT_TIMEOUT_MS = 30_000;
export const VPN_DISCONNECT_TIMEOUT_MS = 15_000;
const DEFAULT_SOURCE_ORDER: FlightSearchSourceName[] = [
"kayak",
"skyscanner",
"expedia",
"airline-direct"
];
function findingFor(
source: FlightSearchSourceName,
findings: FlightSearchSourceFinding[]
): FlightSearchSourceFinding | undefined {
return findings.find((entry) => entry.source === source);
}
function sourceStatus(
source: FlightSearchSourceName,
findings: FlightSearchSourceFinding[]
): FlightSearchSourceViability {
return findingFor(source, findings)?.status || "viable";
}
export function buildFlightSearchPlan(
request: FlightReportRequest,
findings: FlightSearchSourceFinding[] = []
): FlightSearchPlan {
const sourceOrder = DEFAULT_SOURCE_ORDER.map((source) => {
const status = sourceStatus(source, findings);
const finding = findingFor(source, findings);
const required = source !== "airline-direct";
const enabled = status !== "blocked";
const reason =
finding?.notes[0] ||
(source === "airline-direct"
? "Best-effort airline direct cross-check."
: "Primary bounded search source.");
return {
source,
enabled,
required,
status,
silentTimeoutMs: SOURCE_SILENT_TIMEOUT_MS,
totalTimeoutMs: SOURCE_TOTAL_TIMEOUT_MS,
reason
};
});
const degradedReasons = sourceOrder
.filter((source) => source.status !== "viable")
.map((source) =>
source.enabled
? `${source.source} is degraded for this run: ${source.reason}`
: `${source.source} is blocked for this run: ${source.reason}`
);
return {
sourceOrder,
vpn: {
enabled: Boolean(request.preferences.marketCountry),
marketCountry: request.preferences.marketCountry || null,
connectTimeoutMs: VPN_CONNECT_TIMEOUT_MS,
disconnectTimeoutMs: VPN_DISCONNECT_TIMEOUT_MS,
fallbackMode: "default-market"
},
degradedReasons
};
}

View File

@@ -51,6 +51,37 @@ export type FlightReportStatus = {
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
};
export type FlightQuote = {
id: string;
source: FlightSearchSourceName;
legId: string;
passengerGroupIds: string[];
bookingLink: string;
itinerarySummary: string;
airlineName?: string;
departureTimeLocal?: string;
arrivalTimeLocal?: string;
stopsText?: string;
layoverText?: string;
totalDurationText?: string;
totalPriceUsd: number;
displayPriceUsd: string;
originalCurrency?: string;
originalTotalPrice?: number;
directBookingUrl?: string | null;
crossCheckStatus?: "verified" | "not-available" | "failed";
notes?: string[];
};
export type FlightRecommendationOption = {
id: string;
title: string;
quoteIds: string[];
totalPriceUsd: number;
rationale: string;
backup?: boolean;
};
export type FlightSearchSourceName =
| "kayak"
| "skyscanner"
@@ -67,6 +98,28 @@ export type FlightSearchSourceFinding = {
evidence?: string[];
};
export type FlightSearchPlanSource = {
source: FlightSearchSourceName;
enabled: boolean;
required: boolean;
status: FlightSearchSourceViability;
silentTimeoutMs: number;
totalTimeoutMs: number;
reason: string;
};
export type FlightSearchPlan = {
sourceOrder: FlightSearchPlanSource[];
vpn: {
enabled: boolean;
marketCountry: string | null;
connectTimeoutMs: number;
disconnectTimeoutMs: number;
fallbackMode: "default-market";
};
degradedReasons: string[];
};
export type FlightReportRequestDraft = {
tripName?: string | null;
legs?: Array<Partial<FlightLegWindow> | null | undefined> | null;
@@ -93,3 +146,42 @@ export type NormalizedFlightReportRequest = {
missingDeliveryInputs: string[];
warnings: string[];
};
export type FlightReportPayload = {
request: FlightReportRequest;
sourceFindings: FlightSearchSourceFinding[];
quotes: FlightQuote[];
rankedOptions: FlightRecommendationOption[];
executiveSummary: string[];
reportWarnings: string[];
degradedReasons: string[];
comparisonCurrency: "USD";
marketCountryUsed?: string | null;
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
renderedPdfPath?: string | null;
deliveredTo?: string | null;
emailAuthorized?: boolean;
generatedAt: string;
};
export type FlightReportStatusResult = FlightReportStatus & {
chatSummaryReady: boolean;
terminalOutcome:
| "ready"
| "missing-inputs"
| "all-sources-failed"
| "report-incomplete";
degraded: boolean;
degradedReasons: string[];
blockingReason?: string;
};
export type FlightRunState = {
request: FlightReportRequest;
lastCompletedPhase: "intake" | "search" | "ranking" | "render" | "delivery";
updatedAt: string;
reportWarnings: string[];
degradedReasons: string[];
sourceFindings: FlightSearchSourceFinding[];
comparisonCurrency: "USD";
};

View File

@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import { normalizePriceToUsd } from "../src/price-normalization.js";
test("normalizePriceToUsd passes through USD values", () => {
const result = normalizePriceToUsd({
amount: 2847,
currency: "USD"
});
assert.equal(result.amountUsd, 2847);
assert.equal(result.currency, "USD");
});
test("normalizePriceToUsd converts foreign currencies when a rate is supplied", () => {
const result = normalizePriceToUsd({
amount: 100,
currency: "EUR",
exchangeRates: {
EUR: 1.08
}
});
assert.equal(result.amountUsd, 108);
assert.match(result.notes.join(" "), /EUR/i);
});
test("normalizePriceToUsd rejects missing exchange rates", () => {
assert.throws(
() =>
normalizePriceToUsd({
amount: 100,
currency: "EUR"
}),
/exchange rate/i
);
});

View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildLukeDeliveryPlan } from "../src/report-delivery.js";
test("buildLukeDeliveryPlan requires a plausible recipient email", () => {
const plan = buildLukeDeliveryPlan({
recipientEmail: "invalid-email",
subject: "DFW ↔ BLQ flight report",
body: "Summary",
attachmentPath: "/tmp/report.pdf"
});
assert.equal(plan.ready, false);
assert.equal(plan.needsRecipientEmail, true);
assert.equal(plan.command, null);
});
test("buildLukeDeliveryPlan uses Luke's wrapper path when recipient is valid", () => {
const plan = buildLukeDeliveryPlan({
recipientEmail: "stefano@fiorinis.com",
subject: "DFW ↔ BLQ flight report",
body: "Summary",
attachmentPath: "/tmp/report.pdf"
});
assert.equal(plan.ready, true);
assert.match(String(plan.command), /gog-luke gmail send/);
assert.match(String(plan.command), /stefano@fiorinis.com/);
assert.match(String(plan.command), /report\.pdf/);
});
test("buildLukeDeliveryPlan safely quotes shell-sensitive content", () => {
const plan = buildLukeDeliveryPlan({
recipientEmail: "stefano@fiorinis.com",
subject: "Luke's flight report; rm -rf /",
body: "It's ready.",
attachmentPath: "/tmp/report's-final.pdf"
});
assert.equal(plan.ready, true);
assert.match(String(plan.command), /--subject 'Luke'"'"'s flight report; rm -rf \/'/);
assert.match(String(plan.command), /--attach '\/tmp\/report'"'"'s-final\.pdf'/);
});

View File

@@ -0,0 +1,76 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import { renderFlightReportPdf, ReportValidationError } from "../src/report-pdf.js";
import type { FlightReportPayload } from "../src/types.js";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "../src/request-normalizer.js";
function samplePayload(): FlightReportPayload {
return {
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
sourceFindings: [
{
source: "kayak",
status: "viable",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Returned usable options."]
}
],
quotes: [
{
id: "quote-1",
source: "kayak",
legId: "outbound",
passengerGroupIds: ["pair", "solo"],
bookingLink: "https://example.com/quote-1",
itinerarySummary: "DFW -> BLQ via LHR",
totalPriceUsd: 2847,
displayPriceUsd: "$2,847"
}
],
rankedOptions: [
{
id: "primary",
title: "Best overall itinerary",
quoteIds: ["quote-1"],
totalPriceUsd: 2847,
rationale: "Best price-to-convenience tradeoff."
}
],
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
reportWarnings: [],
degradedReasons: [],
comparisonCurrency: "USD",
generatedAt: "2026-03-30T21:00:00Z",
lastCompletedPhase: "ranking"
};
}
test("renderFlightReportPdf writes a non-empty PDF", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
await renderFlightReportPdf(samplePayload(), outputPath);
assert.ok(fs.existsSync(outputPath));
assert.ok(fs.statSync(outputPath).size > 0);
});
test("renderFlightReportPdf rejects incomplete report payloads", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
await assert.rejects(
() =>
renderFlightReportPdf(
{
...samplePayload(),
rankedOptions: [],
executiveSummary: []
},
outputPath
),
ReportValidationError
);
});

View File

@@ -0,0 +1,99 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "../src/request-normalizer.js";
import { getFlightReportStatus } from "../src/report-status.js";
import type { FlightReportPayload } from "../src/types.js";
function buildPayload(): FlightReportPayload {
return {
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
sourceFindings: [
{
source: "kayak",
status: "viable",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Returned usable options."]
}
],
quotes: [
{
id: "quote-1",
source: "kayak",
legId: "outbound",
passengerGroupIds: ["pair", "solo"],
bookingLink: "https://example.com/quote-1",
itinerarySummary: "DFW -> BLQ via LHR",
totalPriceUsd: 2847,
displayPriceUsd: "$2,847",
crossCheckStatus: "not-available"
}
],
rankedOptions: [
{
id: "primary",
title: "Best overall itinerary",
quoteIds: ["quote-1"],
totalPriceUsd: 2847,
rationale: "Best price-to-convenience tradeoff."
}
],
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
reportWarnings: [],
degradedReasons: [],
comparisonCurrency: "USD",
generatedAt: "2026-03-30T21:00:00Z",
lastCompletedPhase: "ranking"
};
}
test("getFlightReportStatus treats missing recipient email as delivery-only blocker", () => {
const normalized = normalizeFlightReportRequest({
...DFW_BLQ_2026_PROMPT_DRAFT,
recipientEmail: null
});
const payload = {
...buildPayload(),
request: normalized.request
};
const status = getFlightReportStatus(normalized, payload);
assert.equal(status.readyToSearch, true);
assert.equal(status.pdfReady, true);
assert.equal(status.emailReady, false);
assert.deepEqual(status.needsMissingInputs, ["recipient email"]);
});
test("getFlightReportStatus marks all-sources-failed as a blocked but chat-summarizable outcome", () => {
const normalized = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
const status = getFlightReportStatus(normalized, {
...buildPayload(),
sourceFindings: [
{
source: "kayak",
status: "blocked",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Timed out."]
},
{
source: "skyscanner",
status: "blocked",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Timed out."]
}
],
quotes: [],
rankedOptions: [],
executiveSummary: [],
degradedReasons: ["All configured travel sources failed."]
});
assert.equal(status.terminalOutcome, "all-sources-failed");
assert.equal(status.chatSummaryReady, true);
assert.equal(status.pdfReady, false);
assert.equal(status.degraded, true);
});

View File

@@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import fs from "node:fs/promises";
import {
clearFlightFinderRunState,
loadFlightFinderRunState,
saveFlightFinderRunState
} from "../src/run-state.js";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "../src/request-normalizer.js";
test("save/load/clearFlightFinderRunState persists the resumable phase context", async () => {
const baseDir = await fs.mkdtemp(
path.join(os.tmpdir(), "flight-finder-run-state-")
);
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
await saveFlightFinderRunState(
{
request,
lastCompletedPhase: "search",
updatedAt: "2026-03-30T21:00:00Z",
reportWarnings: [],
degradedReasons: [],
sourceFindings: [],
comparisonCurrency: "USD"
},
baseDir
);
const loaded = await loadFlightFinderRunState(baseDir);
assert.equal(loaded?.lastCompletedPhase, "search");
assert.equal(loaded?.request.tripName, "DFW ↔ BLQ flight report");
await clearFlightFinderRunState(baseDir);
assert.equal(await loadFlightFinderRunState(baseDir), null);
});

View File

@@ -0,0 +1,46 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DFW_BLQ_2026_PROMPT_DRAFT,
normalizeFlightReportRequest
} from "../src/request-normalizer.js";
import {
VPN_CONNECT_TIMEOUT_MS,
VPN_DISCONNECT_TIMEOUT_MS,
buildFlightSearchPlan
} from "../src/search-orchestration.js";
test("buildFlightSearchPlan activates VPN only when marketCountry is explicit", () => {
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
const plan = buildFlightSearchPlan(request);
assert.equal(plan.vpn.enabled, true);
assert.equal(plan.vpn.marketCountry, "TH");
assert.equal(plan.vpn.connectTimeoutMs, VPN_CONNECT_TIMEOUT_MS);
assert.equal(plan.vpn.disconnectTimeoutMs, VPN_DISCONNECT_TIMEOUT_MS);
});
test("buildFlightSearchPlan keeps airline-direct best-effort when degraded", () => {
const request = normalizeFlightReportRequest({
...DFW_BLQ_2026_PROMPT_DRAFT,
preferences: {
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
marketCountry: null
}
}).request;
const plan = buildFlightSearchPlan(request, [
{
source: "airline-direct",
status: "degraded",
checkedAt: "2026-03-30T21:00:00Z",
notes: ["Direct booking shell loads but search completion is unreliable."]
}
]);
assert.equal(plan.vpn.enabled, false);
const directSource = plan.sourceOrder.find((entry) => entry.source === "airline-direct");
assert.equal(directSource?.enabled, true);
assert.equal(directSource?.required, false);
assert.match(plan.degradedReasons.join(" "), /airline-direct/i);
});