feat(flight-finder): implement milestone M2 - report workflow and delivery gates
This commit is contained in:
55
docs/flight-finder.md
Normal file
55
docs/flight-finder.md
Normal 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.
|
||||||
@@ -101,6 +101,25 @@ Rules:
|
|||||||
- If recipient email is missing, ask for it before the render/send phase.
|
- 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.
|
- 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
|
## 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.
|
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.
|
||||||
|
|||||||
18
skills/flight-finder/package-lock.json
generated
18
skills/flight-finder/package-lock.json
generated
@@ -8,9 +8,11 @@
|
|||||||
"name": "flight-finder-scripts",
|
"name": "flight-finder-scripts",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.8",
|
||||||
"pdfkit": "^0.17.2"
|
"pdfkit": "^0.17.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/minimist": "^1.2.5",
|
||||||
"@types/pdfkit": "^0.17.3",
|
"@types/pdfkit": "^0.17.3",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
@@ -467,6 +469,13 @@
|
|||||||
"tslib": "^2.8.0"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.5.0",
|
"version": "25.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||||
@@ -656,6 +665,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/pako": {
|
||||||
"version": "0.2.9",
|
"version": "0.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
|||||||
@@ -4,12 +4,18 @@
|
|||||||
"description": "Flight finder helpers for request normalization, readiness gating, and fixed-template PDF rendering",
|
"description": "Flight finder helpers for request normalization, readiness gating, and fixed-template PDF rendering",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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"
|
"test": "node --import tsx --test tests/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.8",
|
||||||
"pdfkit": "^0.17.2"
|
"pdfkit": "^0.17.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/minimist": "^1.2.5",
|
||||||
"@types/pdfkit": "^0.17.3",
|
"@types/pdfkit": "^0.17.3",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
|
|||||||
86
skills/flight-finder/src/cli.ts
Normal file
86
skills/flight-finder/src/cli.ts
Normal 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;
|
||||||
|
});
|
||||||
25
skills/flight-finder/src/input-validation.ts
Normal file
25
skills/flight-finder/src/input-validation.ts
Normal 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);
|
||||||
|
}
|
||||||
36
skills/flight-finder/src/price-normalization.ts
Normal file
36
skills/flight-finder/src/price-normalization.ts
Normal 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}.`]
|
||||||
|
};
|
||||||
|
}
|
||||||
50
skills/flight-finder/src/report-delivery.ts
Normal file
50
skills/flight-finder/src/report-delivery.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
94
skills/flight-finder/src/report-pdf.ts
Normal file
94
skills/flight-finder/src/report-pdf.ts
Normal 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;
|
||||||
|
}
|
||||||
83
skills/flight-finder/src/report-status.ts
Normal file
83
skills/flight-finder/src/report-status.ts
Normal 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."
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
FlightSearchPreferences,
|
FlightSearchPreferences,
|
||||||
NormalizedFlightReportRequest
|
NormalizedFlightReportRequest
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import { isPlausibleEmail, normalizeMarketCountry } from "./input-validation.js";
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: FlightSearchPreferences = {
|
const DEFAULT_PREFERENCES: FlightSearchPreferences = {
|
||||||
preferOneStop: true,
|
preferOneStop: true,
|
||||||
@@ -34,30 +35,6 @@ function uniqueStrings(values: Array<string | null | undefined> | undefined): st
|
|||||||
return cleaned.length ? Array.from(new Set(cleaned)) : undefined;
|
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(
|
function normalizePassengerGroups(
|
||||||
groups: FlightReportRequestDraft["passengerGroups"]
|
groups: FlightReportRequestDraft["passengerGroups"]
|
||||||
): FlightPassengerGroup[] {
|
): FlightPassengerGroup[] {
|
||||||
|
|||||||
59
skills/flight-finder/src/run-state.ts
Normal file
59
skills/flight-finder/src/run-state.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
skills/flight-finder/src/search-orchestration.ts
Normal file
81
skills/flight-finder/src/search-orchestration.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -51,6 +51,37 @@ export type FlightReportStatus = {
|
|||||||
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
|
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 =
|
export type FlightSearchSourceName =
|
||||||
| "kayak"
|
| "kayak"
|
||||||
| "skyscanner"
|
| "skyscanner"
|
||||||
@@ -67,6 +98,28 @@ export type FlightSearchSourceFinding = {
|
|||||||
evidence?: string[];
|
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 = {
|
export type FlightReportRequestDraft = {
|
||||||
tripName?: string | null;
|
tripName?: string | null;
|
||||||
legs?: Array<Partial<FlightLegWindow> | null | undefined> | null;
|
legs?: Array<Partial<FlightLegWindow> | null | undefined> | null;
|
||||||
@@ -93,3 +146,42 @@ export type NormalizedFlightReportRequest = {
|
|||||||
missingDeliveryInputs: string[];
|
missingDeliveryInputs: string[];
|
||||||
warnings: 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";
|
||||||
|
};
|
||||||
|
|||||||
38
skills/flight-finder/tests/price-normalization.test.ts
Normal file
38
skills/flight-finder/tests/price-normalization.test.ts
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
44
skills/flight-finder/tests/report-delivery.test.ts
Normal file
44
skills/flight-finder/tests/report-delivery.test.ts
Normal 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'/);
|
||||||
|
});
|
||||||
76
skills/flight-finder/tests/report-pdf.test.ts
Normal file
76
skills/flight-finder/tests/report-pdf.test.ts
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
99
skills/flight-finder/tests/report-status.test.ts
Normal file
99
skills/flight-finder/tests/report-status.test.ts
Normal 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);
|
||||||
|
});
|
||||||
42
skills/flight-finder/tests/run-state.test.ts
Normal file
42
skills/flight-finder/tests/run-state.test.ts
Normal 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);
|
||||||
|
});
|
||||||
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal file
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user