@viccydev/pi-fpa 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/extensions/fpa-artifacts/contracts.ts +624 -0
- package/extensions/fpa-artifacts/index.ts +42 -0
- package/extensions/fpa-artifacts/store.ts +129 -0
- package/extensions/fpa-dashboard/actuals.ts +185 -0
- package/extensions/fpa-dashboard/index.ts +145 -0
- package/extensions/fpa-dashboard/projector.ts +482 -0
- package/extensions/fpa-dashboard/publisher.ts +170 -0
- package/extensions/fpa-dashboard/schema.ts +115 -0
- package/extensions/fpa-dashboard/source.ts +152 -0
- package/extensions/fpa-dashboard/status.ts +154 -0
- package/extensions/fpa-data/index.ts +2 -27
- package/extensions/fpa-data/registry.ts +3 -1
- package/extensions/fpa-data/runtime.ts +22 -0
- package/extensions/fpa-data/sql.ts +32 -0
- package/package.json +6 -4
- package/skills/fpa-execute-approved-strategy/SKILL.md +2 -2
- package/skills/fpa-execute-approved-strategy/references/artifact-contract.md +14 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +2 -2
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +14 -4
- package/skills/fpa-refresh-dashboard/SKILL.md +29 -0
- package/skills/fpa-refresh-dashboard/references/dashboard-policy.md +12 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export const DASHBOARD_WIDGET_TYPES = ["stat", "timeseries", "table", "grouped-table"] as const;
|
|
2
|
+
export type DashboardWidgetType = (typeof DASHBOARD_WIDGET_TYPES)[number];
|
|
3
|
+
|
|
4
|
+
const TONES = new Set(["positive", "negative", "warning", "neutral"]);
|
|
5
|
+
|
|
6
|
+
function record(value: unknown, path: string): Record<string, unknown> {
|
|
7
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
|
|
8
|
+
return value as Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function string(value: unknown, path: string): string {
|
|
12
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function optionalString(value: unknown, path: string): void {
|
|
17
|
+
if (value !== undefined) string(value, path);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function tone(value: unknown, path: string): void {
|
|
21
|
+
if (value !== undefined && (typeof value !== "string" || !TONES.has(value))) throw new Error(`${path} has an unsupported tone.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cell(value: unknown, path: string): void {
|
|
25
|
+
if (value === null || typeof value === "string") return;
|
|
26
|
+
const source = record(value, path);
|
|
27
|
+
if (source.value !== null && typeof source.value !== "string") throw new Error(`${path}.value must be a string or null.`);
|
|
28
|
+
tone(source.tone, `${path}.tone`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function columns(value: unknown, path: string): void {
|
|
32
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} must be a non-empty array.`);
|
|
33
|
+
for (const [index, raw] of value.entries()) {
|
|
34
|
+
const source = record(raw, `${path}[${index}]`);
|
|
35
|
+
string(source.key, `${path}[${index}].key`);
|
|
36
|
+
string(source.label, `${path}[${index}].label`);
|
|
37
|
+
if (source.align !== undefined && source.align !== "left" && source.align !== "right") throw new Error(`${path}[${index}].align is unsupported.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cellsRecord(value: unknown, path: string): void {
|
|
42
|
+
const source = record(value, path);
|
|
43
|
+
for (const [key, value] of Object.entries(source)) cell(value, `${path}.${key}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tabularRows(value: unknown, path: string): void {
|
|
47
|
+
if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
|
|
48
|
+
for (const [index, row] of value.entries()) cellsRecord(row, `${path}[${index}]`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateStat(source: Record<string, unknown>): void {
|
|
52
|
+
string(source.label, "dataset.label");
|
|
53
|
+
if (source.value !== null && typeof source.value !== "string") throw new Error("dataset.value must be a string or null.");
|
|
54
|
+
optionalString(source.missingReason, "dataset.missingReason");
|
|
55
|
+
optionalString(source.description, "dataset.description");
|
|
56
|
+
optionalString(source.footnote, "dataset.footnote");
|
|
57
|
+
if (source.delta !== undefined) {
|
|
58
|
+
const delta = record(source.delta, "dataset.delta");
|
|
59
|
+
if (delta.direction !== "up" && delta.direction !== "down" && delta.direction !== "flat") throw new Error("dataset.delta.direction is unsupported.");
|
|
60
|
+
string(delta.label, "dataset.delta.label");
|
|
61
|
+
if (delta.sentiment !== undefined && delta.sentiment !== "positive" && delta.sentiment !== "negative" && delta.sentiment !== "neutral") throw new Error("dataset.delta.sentiment is unsupported.");
|
|
62
|
+
}
|
|
63
|
+
if (source.progress !== undefined) {
|
|
64
|
+
const progress = record(source.progress, "dataset.progress");
|
|
65
|
+
if (typeof progress.fraction !== "number" || !Number.isFinite(progress.fraction) || progress.fraction < 0 || progress.fraction > 1) throw new Error("dataset.progress.fraction must be between 0 and 1.");
|
|
66
|
+
string(progress.label, "dataset.progress.label");
|
|
67
|
+
tone(progress.tone, "dataset.progress.tone");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateTimeseries(source: Record<string, unknown>): void {
|
|
72
|
+
string(source.label, "dataset.label");
|
|
73
|
+
optionalString(source.description, "dataset.description");
|
|
74
|
+
optionalString(source.coverage, "dataset.coverage");
|
|
75
|
+
if (!Array.isArray(source.series) || source.series.length === 0) throw new Error("dataset.series must be a non-empty array.");
|
|
76
|
+
for (const [seriesIndex, rawSeries] of source.series.entries()) {
|
|
77
|
+
const series = record(rawSeries, `dataset.series[${seriesIndex}]`);
|
|
78
|
+
string(series.name, `dataset.series[${seriesIndex}].name`);
|
|
79
|
+
if (!Array.isArray(series.points)) throw new Error(`dataset.series[${seriesIndex}].points must be an array.`);
|
|
80
|
+
for (const [pointIndex, rawPoint] of series.points.entries()) {
|
|
81
|
+
const point = record(rawPoint, `dataset.series[${seriesIndex}].points[${pointIndex}]`);
|
|
82
|
+
string(point.x, `dataset.series[${seriesIndex}].points[${pointIndex}].x`);
|
|
83
|
+
if (point.y !== null && (typeof point.y !== "number" || !Number.isFinite(point.y))) throw new Error(`dataset.series[${seriesIndex}].points[${pointIndex}].y must be finite or null.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateTable(source: Record<string, unknown>): void {
|
|
89
|
+
string(source.label, "dataset.label");
|
|
90
|
+
optionalString(source.description, "dataset.description");
|
|
91
|
+
columns(source.columns, "dataset.columns");
|
|
92
|
+
tabularRows(source.rows, "dataset.rows");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateGroupedTable(source: Record<string, unknown>): void {
|
|
96
|
+
string(source.label, "dataset.label");
|
|
97
|
+
optionalString(source.description, "dataset.description");
|
|
98
|
+
columns(source.columns, "dataset.columns");
|
|
99
|
+
if (!Array.isArray(source.groups) || source.groups.length === 0) throw new Error("dataset.groups must be a non-empty array.");
|
|
100
|
+
for (const [index, raw] of source.groups.entries()) {
|
|
101
|
+
const group = record(raw, `dataset.groups[${index}]`);
|
|
102
|
+
string(group.key, `dataset.groups[${index}].key`);
|
|
103
|
+
string(group.label, `dataset.groups[${index}].label`);
|
|
104
|
+
cellsRecord(group.summary, `dataset.groups[${index}].summary`);
|
|
105
|
+
tabularRows(group.rows, `dataset.groups[${index}].rows`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function validateDatasetForWidget(type: DashboardWidgetType, value: unknown): void {
|
|
110
|
+
const source = record(value, "dataset");
|
|
111
|
+
if (type === "stat") validateStat(source);
|
|
112
|
+
else if (type === "timeseries") validateTimeseries(source);
|
|
113
|
+
else if (type === "table") validateTable(source);
|
|
114
|
+
else validateGroupedTable(source);
|
|
115
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { sliceKey, type Period } from "../fpa-artifacts/contracts.ts";
|
|
2
|
+
|
|
3
|
+
export interface QueryReceipt {
|
|
4
|
+
dataset: string;
|
|
5
|
+
query_fingerprint: string;
|
|
6
|
+
row_count: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DashboardActualSlice {
|
|
10
|
+
app_id: string;
|
|
11
|
+
store: string;
|
|
12
|
+
channel_group: string;
|
|
13
|
+
spend: number | null;
|
|
14
|
+
revenue: number | null;
|
|
15
|
+
source_rows: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DashboardDailyPoint {
|
|
19
|
+
date: string;
|
|
20
|
+
spend: number | null;
|
|
21
|
+
revenue: number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DashboardActualsSnapshot {
|
|
25
|
+
period: Period;
|
|
26
|
+
data_as_of: string;
|
|
27
|
+
reporting_currency: string;
|
|
28
|
+
coverage: {
|
|
29
|
+
date_min: string | null;
|
|
30
|
+
date_max: string | null;
|
|
31
|
+
source_rows: number;
|
|
32
|
+
};
|
|
33
|
+
current: { spend: number | null; revenue: number | null };
|
|
34
|
+
comparison: { spend: number | null; revenue: number | null } | null;
|
|
35
|
+
daily: DashboardDailyPoint[];
|
|
36
|
+
slices: DashboardActualSlice[];
|
|
37
|
+
query_receipts: QueryReceipt[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function record(value: unknown, path: string): Record<string, unknown> {
|
|
41
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
|
|
42
|
+
return value as Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function string(value: unknown, path: string): string {
|
|
46
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function numeric(value: unknown, path: string, nullable = false): number | null {
|
|
51
|
+
if (nullable && value === null) return null;
|
|
52
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path} must be a finite number${nullable ? " or null" : ""}.`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function count(value: unknown, path: string): number {
|
|
57
|
+
const parsed = numeric(value, path) as number;
|
|
58
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${path} must be a non-negative integer.`);
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function aggregate(value: unknown, path: string): { spend: number | null; revenue: number | null } {
|
|
63
|
+
const source = record(value, path);
|
|
64
|
+
return {
|
|
65
|
+
spend: numeric(source.spend, `${path}.spend`, true),
|
|
66
|
+
revenue: numeric(source.revenue, `${path}.revenue`, true),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function period(value: unknown, path: string): Period {
|
|
71
|
+
const source = record(value, path);
|
|
72
|
+
const result = {
|
|
73
|
+
start_inclusive: string(source.start_inclusive, `${path}.start_inclusive`),
|
|
74
|
+
end_exclusive: string(source.end_exclusive, `${path}.end_exclusive`),
|
|
75
|
+
timezone: string(source.timezone, `${path}.timezone`),
|
|
76
|
+
};
|
|
77
|
+
if (Number.isNaN(Date.parse(result.start_inclusive)) || Number.isNaN(Date.parse(result.end_exclusive))) throw new Error(`${path} timestamps must be valid ISO timestamps.`);
|
|
78
|
+
if (Date.parse(result.start_inclusive) >= Date.parse(result.end_exclusive)) throw new Error(`${path}.end_exclusive must be after start_inclusive.`);
|
|
79
|
+
try {
|
|
80
|
+
new Intl.DateTimeFormat("en-US", { timeZone: result.timezone }).format(new Date(result.start_inclusive));
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(`${path}.timezone is not supported.`);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateActualsSnapshot(value: unknown): DashboardActualsSnapshot {
|
|
88
|
+
const source = record(value, "actuals");
|
|
89
|
+
const coverage = record(source.coverage, "actuals.coverage");
|
|
90
|
+
const dailySource = source.daily;
|
|
91
|
+
const slicesSource = source.slices;
|
|
92
|
+
const receiptsSource = source.query_receipts;
|
|
93
|
+
if (!Array.isArray(dailySource)) throw new Error("actuals.daily must be an array.");
|
|
94
|
+
if (!Array.isArray(slicesSource)) throw new Error("actuals.slices must be an array.");
|
|
95
|
+
if (!Array.isArray(receiptsSource)) throw new Error("actuals.query_receipts must be an array.");
|
|
96
|
+
|
|
97
|
+
const daily = dailySource.map((value, index) => {
|
|
98
|
+
const item = record(value, `actuals.daily[${index}]`);
|
|
99
|
+
return {
|
|
100
|
+
date: string(item.date, `actuals.daily[${index}].date`),
|
|
101
|
+
spend: numeric(item.spend, `actuals.daily[${index}].spend`, true),
|
|
102
|
+
revenue: numeric(item.revenue, `actuals.daily[${index}].revenue`, true),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
const slices = slicesSource.map((value, index) => {
|
|
106
|
+
const item = record(value, `actuals.slices[${index}]`);
|
|
107
|
+
return {
|
|
108
|
+
app_id: string(item.app_id, `actuals.slices[${index}].app_id`),
|
|
109
|
+
store: string(item.store, `actuals.slices[${index}].store`),
|
|
110
|
+
channel_group: string(item.channel_group, `actuals.slices[${index}].channel_group`),
|
|
111
|
+
spend: numeric(item.spend, `actuals.slices[${index}].spend`, true),
|
|
112
|
+
revenue: numeric(item.revenue, `actuals.slices[${index}].revenue`, true),
|
|
113
|
+
source_rows: count(item.source_rows, `actuals.slices[${index}].source_rows`),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
const dailyDates = new Set<string>();
|
|
117
|
+
for (const point of daily) {
|
|
118
|
+
if (dailyDates.has(point.date)) throw new Error(`actuals.daily contains duplicate date ${point.date}.`);
|
|
119
|
+
dailyDates.add(point.date);
|
|
120
|
+
}
|
|
121
|
+
const sliceKeys = new Set<string>();
|
|
122
|
+
for (const item of slices) {
|
|
123
|
+
const key = sliceKey(item);
|
|
124
|
+
if (sliceKeys.has(key)) throw new Error(`actuals.slices contains duplicate slice ${key.replaceAll("\u0000", " / ")}.`);
|
|
125
|
+
sliceKeys.add(key);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const reportingCurrency = string(source.reporting_currency, "actuals.reporting_currency").toUpperCase();
|
|
129
|
+
if (!/^[A-Z]{3}$/.test(reportingCurrency)) throw new Error("actuals.reporting_currency must be an ISO-4217 currency code.");
|
|
130
|
+
return {
|
|
131
|
+
period: period(source.period, "actuals.period"),
|
|
132
|
+
data_as_of: string(source.data_as_of, "actuals.data_as_of"),
|
|
133
|
+
reporting_currency: reportingCurrency,
|
|
134
|
+
coverage: {
|
|
135
|
+
date_min: coverage.date_min === null ? null : string(coverage.date_min, "actuals.coverage.date_min"),
|
|
136
|
+
date_max: coverage.date_max === null ? null : string(coverage.date_max, "actuals.coverage.date_max"),
|
|
137
|
+
source_rows: count(coverage.source_rows, "actuals.coverage.source_rows"),
|
|
138
|
+
},
|
|
139
|
+
current: aggregate(source.current, "actuals.current"),
|
|
140
|
+
comparison: source.comparison === null ? null : aggregate(source.comparison, "actuals.comparison"),
|
|
141
|
+
daily,
|
|
142
|
+
slices,
|
|
143
|
+
query_receipts: receiptsSource.map((value, index) => {
|
|
144
|
+
const item = record(value, `actuals.query_receipts[${index}]`);
|
|
145
|
+
return {
|
|
146
|
+
dataset: string(item.dataset, `actuals.query_receipts[${index}].dataset`),
|
|
147
|
+
query_fingerprint: string(item.query_fingerprint, `actuals.query_receipts[${index}].query_fingerprint`),
|
|
148
|
+
row_count: count(item.row_count, `actuals.query_receipts[${index}].row_count`),
|
|
149
|
+
};
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { resolveDashboardDir } from "./publisher.ts";
|
|
6
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
7
|
+
import { DASHBOARD_WIDGET_TYPES, validateDatasetForWidget, type DashboardWidgetType } from "./schema.ts";
|
|
8
|
+
|
|
9
|
+
const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\.json$/;
|
|
10
|
+
const GENERATION_RECEIPT_RE = /^build-receipt\.([a-f0-9]{12})\.([a-f0-9]{12})\.json$/;
|
|
11
|
+
|
|
12
|
+
export interface DashboardStatus {
|
|
13
|
+
dashboard_exists: boolean;
|
|
14
|
+
dashboard_dir: string;
|
|
15
|
+
generation_id?: string;
|
|
16
|
+
updated_at?: string;
|
|
17
|
+
widget_count: number;
|
|
18
|
+
diagnostics: Array<{ target: string; message: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readJson(path: string, maxBytes: number): Promise<unknown> {
|
|
22
|
+
const stat = await lstat(path);
|
|
23
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("must be a regular file, not a symlink");
|
|
24
|
+
if (stat.size > maxBytes) throw new Error(`exceeds the ${Math.round(maxBytes / 1024)}KB limit`);
|
|
25
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
|
|
29
|
+
const dashboardDir = await resolveDashboardDir(cwd);
|
|
30
|
+
const diagnostics: DashboardStatus["diagnostics"] = [];
|
|
31
|
+
let manifest: unknown;
|
|
32
|
+
try {
|
|
33
|
+
manifest = await readJson(join(dashboardDir, "manifest.json"), 256 * 1024);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
36
|
+
return { dashboard_exists: false, dashboard_dir: dashboardDir, widget_count: 0, diagnostics };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
dashboard_exists: false,
|
|
40
|
+
dashboard_dir: dashboardDir,
|
|
41
|
+
widget_count: 0,
|
|
42
|
+
diagnostics: [{ target: "manifest.json", message: error instanceof Error ? error.message : String(error) }],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
46
|
+
return { dashboard_exists: false, dashboard_dir: dashboardDir, widget_count: 0, diagnostics: [{ target: "manifest.json", message: "must contain an object" }] };
|
|
47
|
+
}
|
|
48
|
+
const source = manifest as Record<string, unknown>;
|
|
49
|
+
if (source.kind !== "fpa.dashboard" || source.schemaVersion !== 1 || typeof source.title !== "string" || source.title.trim() === "" || typeof source.updatedAt !== "string" || Number.isNaN(Date.parse(source.updatedAt)) || !Array.isArray(source.widgets) || source.widgets.length === 0 || source.widgets.length > 64) {
|
|
50
|
+
return { dashboard_exists: false, dashboard_dir: dashboardDir, widget_count: 0, diagnostics: [{ target: "manifest.json", message: "unsupported dashboard kind/schema or widgets shape" }] };
|
|
51
|
+
}
|
|
52
|
+
const receiptDatasets = new Map<string, string>();
|
|
53
|
+
const receiptFilename = typeof source.buildReceipt === "string" && DATASET_FILE_RE.test(source.buildReceipt)
|
|
54
|
+
? source.buildReceipt
|
|
55
|
+
: "build-receipt.json";
|
|
56
|
+
try {
|
|
57
|
+
const receipt = await readJson(join(dashboardDir, receiptFilename), 256 * 1024);
|
|
58
|
+
if (receipt === null || typeof receipt !== "object" || Array.isArray(receipt)) {
|
|
59
|
+
throw new Error("must contain an object");
|
|
60
|
+
}
|
|
61
|
+
const receiptSource = receipt as Record<string, unknown>;
|
|
62
|
+
if (receiptSource.kind !== "fpa.dashboard.build" || receiptSource.schema_version !== 1) {
|
|
63
|
+
throw new Error("has an unsupported build receipt kind or schema");
|
|
64
|
+
}
|
|
65
|
+
if (typeof source.generationId === "string" && receiptSource.generation_id !== source.generationId) {
|
|
66
|
+
diagnostics.push({ target: receiptFilename, message: "generation does not match manifest.json" });
|
|
67
|
+
}
|
|
68
|
+
if (receiptSource.published_at !== source.updatedAt) {
|
|
69
|
+
diagnostics.push({ target: receiptFilename, message: "published_at does not match manifest updatedAt" });
|
|
70
|
+
}
|
|
71
|
+
const receiptNameMatch = receiptFilename.match(GENERATION_RECEIPT_RE);
|
|
72
|
+
if (receiptNameMatch) {
|
|
73
|
+
if (typeof source.generationId !== "string" || receiptNameMatch[1] !== source.generationId.slice(0, 12)) {
|
|
74
|
+
diagnostics.push({ target: receiptFilename, message: "filename generation prefix does not match manifest.json" });
|
|
75
|
+
}
|
|
76
|
+
const receiptDigest = createHash("sha256").update(stableJson(receipt)).digest("hex");
|
|
77
|
+
if (receiptNameMatch[2] !== receiptDigest.slice(0, 12)) {
|
|
78
|
+
diagnostics.push({ target: receiptFilename, message: "receipt content digest does not match its filename" });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(receiptSource.datasets)) throw new Error("datasets must be an array");
|
|
82
|
+
for (const [index, raw] of receiptSource.datasets.entries()) {
|
|
83
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
84
|
+
diagnostics.push({ target: `${receiptFilename} datasets[${index}]`, message: "must be an object" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const item = raw as Record<string, unknown>;
|
|
88
|
+
if (typeof item.filename !== "string" || !DATASET_FILE_RE.test(item.filename) || typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256)) {
|
|
89
|
+
diagnostics.push({ target: `${receiptFilename} datasets[${index}]`, message: "has an invalid filename or sha256" });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
receiptDatasets.set(item.filename, item.sha256);
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
diagnostics.push({ target: receiptFilename, message: error instanceof Error ? error.message : String(error) });
|
|
96
|
+
}
|
|
97
|
+
const widgetIds = new Set<string>();
|
|
98
|
+
for (const [index, raw] of source.widgets.entries()) {
|
|
99
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
100
|
+
diagnostics.push({ target: `widgets[${index}]`, message: "must be an object" });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const widget = raw as Record<string, unknown>;
|
|
104
|
+
const target = typeof widget.id === "string" ? widget.id : `widgets[${index}]`;
|
|
105
|
+
if (typeof widget.id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(widget.id) || widgetIds.has(widget.id)) {
|
|
106
|
+
diagnostics.push({ target, message: "has an invalid or duplicate widget id" });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
widgetIds.add(widget.id);
|
|
110
|
+
if (typeof widget.type !== "string" || !DASHBOARD_WIDGET_TYPES.includes(widget.type as DashboardWidgetType)) {
|
|
111
|
+
diagnostics.push({ target, message: "has an unsupported widget type" });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (widget.span !== "quarter" && widget.span !== "half" && widget.span !== "full") {
|
|
115
|
+
diagnostics.push({ target, message: "has an unsupported widget span" });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (typeof widget.dataset !== "string" || !DATASET_FILE_RE.test(widget.dataset)) {
|
|
119
|
+
diagnostics.push({ target, message: "has an invalid dataset name" });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const datasetPath = resolve(dashboardDir, "datasets", widget.dataset);
|
|
123
|
+
if (dirname(datasetPath) !== resolve(dashboardDir, "datasets")) {
|
|
124
|
+
diagnostics.push({ target, message: "dataset escapes the dashboard directory" });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const dataset = await readJson(datasetPath, 2 * 1024 * 1024);
|
|
129
|
+
try {
|
|
130
|
+
validateDatasetForWidget(widget.type as DashboardWidgetType, dataset);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
diagnostics.push({ target, message: `dataset schema is invalid: ${error instanceof Error ? error.message : String(error)}` });
|
|
133
|
+
}
|
|
134
|
+
const actualSha256 = createHash("sha256").update(stableJson(dataset)).digest("hex");
|
|
135
|
+
const expectedSha256 = receiptDatasets.get(widget.dataset);
|
|
136
|
+
if (!expectedSha256) {
|
|
137
|
+
diagnostics.push({ target, message: `dataset has no sha256 entry in ${receiptFilename}` });
|
|
138
|
+
} else if (expectedSha256 !== actualSha256) {
|
|
139
|
+
diagnostics.push({ target, message: `dataset integrity sha256 does not match ${receiptFilename}` });
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
diagnostics.push({ target, message: error instanceof Error ? error.message : String(error) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
dashboard_exists: true,
|
|
148
|
+
dashboard_dir: dashboardDir,
|
|
149
|
+
...(typeof source.generationId === "string" ? { generation_id: source.generationId } : {}),
|
|
150
|
+
...(typeof source.updatedAt === "string" ? { updated_at: source.updatedAt } : {}),
|
|
151
|
+
widget_count: source.widgets.length,
|
|
152
|
+
diagnostics,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -16,23 +16,19 @@ import { Type } from "typebox";
|
|
|
16
16
|
import {
|
|
17
17
|
buildCohortRows,
|
|
18
18
|
comparePeriods,
|
|
19
|
-
computeDerived,
|
|
20
|
-
round,
|
|
21
19
|
runCalc,
|
|
22
|
-
toNumber,
|
|
23
20
|
type CalcExpression,
|
|
24
|
-
type NumericLike,
|
|
25
21
|
} from "./calc.ts";
|
|
26
22
|
import { CATALOG_CAVEATS, DATASETS, DATASET_IDS } from "./registry.ts";
|
|
27
23
|
import {
|
|
28
24
|
buildCohortQuery,
|
|
29
25
|
buildCoverageSql,
|
|
30
26
|
buildCohortSizeCoverageSql,
|
|
31
|
-
buildQuery,
|
|
32
27
|
MAX_LIMIT,
|
|
33
28
|
type QuerySpec,
|
|
34
29
|
} from "./sql.ts";
|
|
35
|
-
import {
|
|
30
|
+
import { runStructuredQuery } from "./runtime.ts";
|
|
31
|
+
import { runQuery } from "./supabase.ts";
|
|
36
32
|
|
|
37
33
|
const MAX_TOOL_TEXT_CHARS = 100_000;
|
|
38
34
|
const MAX_DISPLAY_ROWS = 200;
|
|
@@ -116,27 +112,6 @@ function toolResult(result: Record<string, unknown>, rowsKey = "rows") {
|
|
|
116
112
|
};
|
|
117
113
|
}
|
|
118
114
|
|
|
119
|
-
function shapeQueryRows(
|
|
120
|
-
rows: SqlRow[],
|
|
121
|
-
measures: string[],
|
|
122
|
-
derived: ReturnType<typeof buildQuery>["derived"],
|
|
123
|
-
): SqlRow[] {
|
|
124
|
-
return rows.map((row) => {
|
|
125
|
-
const shaped: SqlRow = { ...row };
|
|
126
|
-
for (const name of measures) {
|
|
127
|
-
shaped[name] = round(toNumber(row[name] as NumericLike));
|
|
128
|
-
}
|
|
129
|
-
shaped.source_rows = toNumber(row.source_rows as NumericLike);
|
|
130
|
-
return { ...shaped, ...computeDerived(row, derived) };
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async function runStructuredQuery(spec: QuerySpec, signal?: AbortSignal) {
|
|
135
|
-
const built = buildQuery(spec);
|
|
136
|
-
const rows = await runQuery(built.sql, { signal });
|
|
137
|
-
return { built, rows: shapeQueryRows(rows, built.measures, built.derived) };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
115
|
export default function fpaDataExtension(pi: ExtensionAPI): void {
|
|
141
116
|
pi.registerTool({
|
|
142
117
|
name: "fpa_data_catalog",
|
|
@@ -96,7 +96,7 @@ export const DATASETS: DatasetDef[] = [
|
|
|
96
96
|
table: "appsflyer_ua_campaign_daily",
|
|
97
97
|
dateColumn: "install_date",
|
|
98
98
|
description:
|
|
99
|
-
"AppsFlyer UA spend and acquisition funnel per campaign per install day (impressions, clicks, installs, cost).",
|
|
99
|
+
"AppsFlyer UA spend, attributed revenue, and acquisition funnel per campaign per install day (impressions, clicks, installs, cost, revenue).",
|
|
100
100
|
dimensions: [
|
|
101
101
|
{ name: "app_code", sql: "app_code", description: "Internal app code" },
|
|
102
102
|
{ name: "platform", sql: "platform", description: "ios or android" },
|
|
@@ -116,11 +116,13 @@ export const DATASETS: DatasetDef[] = [
|
|
|
116
116
|
],
|
|
117
117
|
measures: [
|
|
118
118
|
{ name: "spend", sql: "sum(cost_usd)", description: "UA cost in USD", unit: "usd" },
|
|
119
|
+
{ name: "revenue", sql: "sum(revenue_usd)", description: "Attributed revenue (install-date cohort LTV)", unit: "usd" },
|
|
119
120
|
{ name: "impressions", sql: "sum(impressions)", description: "Ad impressions", unit: "count" },
|
|
120
121
|
{ name: "clicks", sql: "sum(clicks)", description: "Ad clicks", unit: "count" },
|
|
121
122
|
{ name: "installs", sql: "sum(installs)", description: "Attributed installs", unit: "count" },
|
|
122
123
|
],
|
|
123
124
|
derived: [
|
|
125
|
+
{ name: "roas", description: "Return on ad spend = revenue / spend", numerator: "revenue", denominator: "spend", unit: "ratio" },
|
|
124
126
|
{ name: "cpi", description: "Cost per install = spend / installs", numerator: "spend", denominator: "installs", unit: "usd" },
|
|
125
127
|
{ name: "ctr", description: "Click-through rate = clicks / impressions", numerator: "clicks", denominator: "impressions", unit: "ratio" },
|
|
126
128
|
{ name: "cvr", description: "Click-to-install rate = installs / clicks", numerator: "installs", denominator: "clicks", unit: "ratio" },
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { computeDerived, round, toNumber, type NumericLike } from "./calc.ts";
|
|
2
|
+
import { buildQuery, type QuerySpec } from "./sql.ts";
|
|
3
|
+
import { runQuery, type SqlRow } from "./supabase.ts";
|
|
4
|
+
|
|
5
|
+
export function shapeQueryRows(
|
|
6
|
+
rows: SqlRow[],
|
|
7
|
+
measures: string[],
|
|
8
|
+
derived: ReturnType<typeof buildQuery>["derived"],
|
|
9
|
+
): SqlRow[] {
|
|
10
|
+
return rows.map((row) => {
|
|
11
|
+
const shaped: SqlRow = { ...row };
|
|
12
|
+
for (const name of measures) shaped[name] = round(toNumber(row[name] as NumericLike));
|
|
13
|
+
shaped.source_rows = toNumber(row.source_rows as NumericLike);
|
|
14
|
+
return { ...shaped, ...computeDerived(row, derived) };
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runStructuredQuery(spec: QuerySpec, signal?: AbortSignal) {
|
|
19
|
+
const built = buildQuery(spec);
|
|
20
|
+
const rows = await runQuery(built.sql, { signal });
|
|
21
|
+
return { built, rows: shapeQueryRows(rows, built.measures, built.derived) };
|
|
22
|
+
}
|
|
@@ -23,6 +23,10 @@ export interface QuerySpec {
|
|
|
23
23
|
dateFrom?: string;
|
|
24
24
|
dateTo?: string;
|
|
25
25
|
filters?: Record<string, string | number | Array<string | number>>;
|
|
26
|
+
/** Internal exact UA scope used by deterministic dashboard projections. */
|
|
27
|
+
exactUaScope?: Array<{ app_code: string; platform: string; media_source: string }>;
|
|
28
|
+
/** Internal exact App + Store portfolio scope used to discover unplanned paid channels. */
|
|
29
|
+
exactUaPortfolioScope?: Array<{ app_code: string; platform: string }>;
|
|
26
30
|
sort?: { by: string; direction?: "asc" | "desc" };
|
|
27
31
|
limit?: number;
|
|
28
32
|
}
|
|
@@ -201,6 +205,34 @@ export function buildQuery(spec: QuerySpec): BuiltQuery {
|
|
|
201
205
|
const values = normalizeFilterValues(raw).map(escapeLiteral);
|
|
202
206
|
where.push(values.length === 1 ? `${def.sql} = ${values[0]}` : `${def.sql} in (${values.join(", ")})`);
|
|
203
207
|
}
|
|
208
|
+
if (spec.exactUaScope !== undefined) {
|
|
209
|
+
if (dataset.id !== "ua_spend") throw new Error("exactUaScope is supported only for ua_spend.");
|
|
210
|
+
if (spec.exactUaScope.length === 0 || spec.exactUaScope.length > 500) throw new Error("exactUaScope must contain between 1 and 500 slices.");
|
|
211
|
+
const dimensionSql = Object.fromEntries(dataset.dimensions.map((dimension) => [dimension.name, dimension.sql]));
|
|
212
|
+
const predicates = spec.exactUaScope.map((scope, index) => {
|
|
213
|
+
for (const [name, value] of Object.entries(scope)) {
|
|
214
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`exactUaScope[${index}].${name} must be a non-empty string.`);
|
|
215
|
+
}
|
|
216
|
+
return `(${dimensionSql.app_code} = ${escapeLiteral(scope.app_code)} and ${dimensionSql.platform} = ${escapeLiteral(scope.platform)} and ${dimensionSql.media_source} = ${escapeLiteral(scope.media_source)})`;
|
|
217
|
+
});
|
|
218
|
+
where.push(`(${predicates.join(" or ")})`);
|
|
219
|
+
filterKeys.push("app_code", "platform", "media_source");
|
|
220
|
+
notes.push("Applied exact App + Store + Channel dashboard scope.");
|
|
221
|
+
}
|
|
222
|
+
if (spec.exactUaPortfolioScope !== undefined) {
|
|
223
|
+
if (dataset.id !== "ua_spend") throw new Error("exactUaPortfolioScope is supported only for ua_spend.");
|
|
224
|
+
if (spec.exactUaPortfolioScope.length === 0 || spec.exactUaPortfolioScope.length > 500) throw new Error("exactUaPortfolioScope must contain between 1 and 500 App + Store pairs.");
|
|
225
|
+
const dimensionSql = Object.fromEntries(dataset.dimensions.map((dimension) => [dimension.name, dimension.sql]));
|
|
226
|
+
const predicates = spec.exactUaPortfolioScope.map((scope, index) => {
|
|
227
|
+
for (const [name, value] of Object.entries(scope)) {
|
|
228
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`exactUaPortfolioScope[${index}].${name} must be a non-empty string.`);
|
|
229
|
+
}
|
|
230
|
+
return `(${dimensionSql.app_code} = ${escapeLiteral(scope.app_code)} and ${dimensionSql.platform} = ${escapeLiteral(scope.platform)})`;
|
|
231
|
+
});
|
|
232
|
+
where.push(`(${predicates.join(" or ")})`);
|
|
233
|
+
filterKeys.push("app_code", "platform");
|
|
234
|
+
notes.push("Applied exact App + Store portfolio scope.");
|
|
235
|
+
}
|
|
204
236
|
|
|
205
237
|
const breakdown = resolveBreakdown(dataset, dimensionNames, filterKeys);
|
|
206
238
|
if (breakdown.predicate) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viccydev/pi-fpa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"extensions"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
26
|
+
"test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
27
27
|
"test:structure": "node tests/package-structure.test.mjs",
|
|
28
|
-
"test:unit": "node tests/extension-unit.test.mjs",
|
|
28
|
+
"test:unit": "node tests/extension-unit.test.mjs && node --test tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
|
|
29
29
|
"test:loader": "node tests/pi-loader-smoke.mjs",
|
|
30
30
|
"test:live": "node tests/live-smoke.mjs",
|
|
31
31
|
"pack:check": "npm pack --dry-run"
|
|
@@ -48,7 +48,9 @@
|
|
|
48
48
|
"./skills"
|
|
49
49
|
],
|
|
50
50
|
"extensions": [
|
|
51
|
-
"./extensions/fpa-data/index.ts"
|
|
51
|
+
"./extensions/fpa-data/index.ts",
|
|
52
|
+
"./extensions/fpa-artifacts/index.ts",
|
|
53
|
+
"./extensions/fpa-dashboard/index.ts"
|
|
52
54
|
]
|
|
53
55
|
}
|
|
54
56
|
}
|
|
@@ -29,7 +29,7 @@ If any requirement is missing, return `blocked` and make no external call. Never
|
|
|
29
29
|
4. Present or record the dry-run result when the adapter supports it.
|
|
30
30
|
5. Execute only the approved mutation set.
|
|
31
31
|
6. Read back the resulting state and reconcile it with the requested state.
|
|
32
|
-
7.
|
|
32
|
+
7. Build the canonical `execution_receipt` using [artifact-contract.md](references/artifact-contract.md), then call `fpa_artifact_commit`.
|
|
33
33
|
|
|
34
34
|
## Stop conditions
|
|
35
35
|
|
|
@@ -37,4 +37,4 @@ Stop before or during execution on version mismatch, target ambiguity, budget mi
|
|
|
37
37
|
|
|
38
38
|
## Boundary
|
|
39
39
|
|
|
40
|
-
Execution ends after reconciliation and receipt. Do not continuously monitor performance. Performance review begins only when the next cycle's Actuals arrive and uses `$fpa-review-cycle`.
|
|
40
|
+
Execution ends after reconciliation and a successful canonical receipt commit. A Markdown report is not an execution receipt. Do not continuously monitor performance. Performance review begins only when the next cycle's Actuals arrive and uses `$fpa-review-cycle`.
|
|
@@ -3,12 +3,17 @@
|
|
|
3
3
|
```yaml
|
|
4
4
|
artifact_type: execution_receipt
|
|
5
5
|
status: complete | complete_with_limits | blocked
|
|
6
|
+
forecast_version: string
|
|
6
7
|
strategy_version: string
|
|
7
8
|
human_approval_id: string
|
|
8
9
|
execution_request_id: string
|
|
10
|
+
execution_mode: manual | adapter
|
|
11
|
+
verification_status: reported | verified | failed
|
|
9
12
|
adapter: string
|
|
10
13
|
target_accounts: []
|
|
11
14
|
idempotency_key: string
|
|
15
|
+
target_period: {start_inclusive: timestamp, end_exclusive: timestamp, timezone: string}
|
|
16
|
+
reporting_currency: string
|
|
12
17
|
preflight:
|
|
13
18
|
result: pass | fail
|
|
14
19
|
observed_state_fingerprint: string
|
|
@@ -23,7 +28,15 @@ reconciliation:
|
|
|
23
28
|
resulting_state_fingerprint: string | null
|
|
24
29
|
external_receipt_ids: []
|
|
25
30
|
executed_at: timestamp | null
|
|
31
|
+
slices:
|
|
32
|
+
- app_id: string
|
|
33
|
+
store: string
|
|
34
|
+
channel_group: string
|
|
35
|
+
action: string
|
|
36
|
+
planned_spend: number
|
|
37
|
+
applied_spend: number | null
|
|
38
|
+
evidence_ids: []
|
|
26
39
|
blockers: []
|
|
27
40
|
```
|
|
28
41
|
|
|
29
|
-
Only adapter responses may populate `applied_mutations`, external receipt IDs, and resulting-state evidence. A blocked receipt must have no applied mutations.
|
|
42
|
+
Only adapter responses or independently verified external evidence may populate `applied_mutations`, applied spend, external receipt IDs, and resulting-state evidence. `verification_status: verified` requires a passing reconciliation, resulting-state fingerprint, `executed_at`, and evidence plus applied spend for every receipt slice. A manual report remains `reported` until independently verified, and therefore cannot use `status: complete`. A blocked receipt must have no applied mutations, applied spend, or `executed_at`. Do not include `immutable_fingerprint`; `fpa_artifact_commit` supplies it after strict validation and durable storage.
|