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 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.
|
||||
|
||||
18
skills/flight-finder/package-lock.json
generated
18
skills/flight-finder/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
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,
|
||||
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[] {
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
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