@viccydev/pi-fpa 0.2.1 → 0.3.1
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 +238 -0
- package/extensions/fpa-dashboard/index.ts +145 -0
- package/extensions/fpa-dashboard/projector.ts +515 -0
- package/extensions/fpa-dashboard/publisher.ts +170 -0
- package/extensions/fpa-dashboard/schema.ts +115 -0
- package/extensions/fpa-dashboard/source.ts +169 -0
- package/extensions/fpa-dashboard/status.ts +154 -0
- package/extensions/fpa-data/index.ts +2 -27
- package/extensions/fpa-data/runtime.ts +22 -0
- package/extensions/fpa-data/sql.ts +52 -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,42 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import { commitArtifact } from "./store.ts";
|
|
5
|
+
|
|
6
|
+
function toolResult(value: Record<string, unknown>) {
|
|
7
|
+
return {
|
|
8
|
+
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
|
|
9
|
+
details: value,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
14
|
+
pi.registerTool({
|
|
15
|
+
name: "fpa_artifact_commit",
|
|
16
|
+
label: "Commit FP&A Artifact",
|
|
17
|
+
description:
|
|
18
|
+
"Validate, reconcile, fingerprint, and atomically commit a canonical FP&A artifact under the current project's artifacts directory. " +
|
|
19
|
+
"Use this instead of claiming a Markdown report is frozen. Supported v1 artifacts are approved_cycle_forecast and execution_receipt.",
|
|
20
|
+
promptSnippet: "Validate and durably freeze a canonical FP&A artifact",
|
|
21
|
+
promptGuidelines: [
|
|
22
|
+
"Use fpa_artifact_commit before calling an approved forecast immutable or using it to publish a dashboard.",
|
|
23
|
+
"Do not supply immutable_fingerprint; the tool calculates it after validation and reconciliation.",
|
|
24
|
+
],
|
|
25
|
+
parameters: Type.Object(
|
|
26
|
+
{
|
|
27
|
+
artifact: Type.Unknown({ description: "Canonical artifact object matching the referenced FP&A artifact contract" }),
|
|
28
|
+
},
|
|
29
|
+
{ additionalProperties: false },
|
|
30
|
+
),
|
|
31
|
+
executionMode: "sequential",
|
|
32
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
33
|
+
const committed = await commitArtifact(ctx.cwd, params.artifact);
|
|
34
|
+
return toolResult({
|
|
35
|
+
status: "committed",
|
|
36
|
+
artifact_type: committed.artifactType,
|
|
37
|
+
immutable_fingerprint: committed.fingerprint,
|
|
38
|
+
path: committed.path,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
validateArtifact,
|
|
7
|
+
validateExecutionAgainstForecast,
|
|
8
|
+
type ArtifactType,
|
|
9
|
+
type CanonicalArtifact,
|
|
10
|
+
type CanonicalArtifactInput,
|
|
11
|
+
} from "./contracts.ts";
|
|
12
|
+
|
|
13
|
+
export interface CommitArtifactResult {
|
|
14
|
+
artifactType: ArtifactType;
|
|
15
|
+
fingerprint: string;
|
|
16
|
+
path: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReadArtifactResult {
|
|
20
|
+
artifact: CanonicalArtifact;
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
path: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stableJson(value: unknown): string {
|
|
26
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
27
|
+
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
|
28
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
29
|
+
if (typeof value !== "object") throw new Error("stableJson accepts only JSON-compatible values.");
|
|
30
|
+
const source = value as Record<string, unknown>;
|
|
31
|
+
return `{${Object.keys(source).sort().map((key) => `${JSON.stringify(key)}:${stableJson(source[key])}`).join(",")}}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function artifactFingerprint(artifact: CanonicalArtifactInput): string {
|
|
35
|
+
return createHash("sha256").update(stableJson(artifact)).digest("hex");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function ensureArtifactDirectory(projectRoot: string): Promise<string> {
|
|
39
|
+
const root = await realpath(projectRoot);
|
|
40
|
+
const artifactsDir = join(root, "artifacts");
|
|
41
|
+
try {
|
|
42
|
+
const stat = await lstat(artifactsDir);
|
|
43
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("Project artifacts path must be a regular directory, not a symlink.");
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
46
|
+
await mkdir(artifactsDir, { mode: 0o700 });
|
|
47
|
+
const stat = await lstat(artifactsDir);
|
|
48
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("Project artifacts path must be a regular directory, not a symlink.");
|
|
49
|
+
}
|
|
50
|
+
return artifactsDir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function existingArtifactDirectory(projectRoot: string): Promise<string> {
|
|
54
|
+
const root = await realpath(projectRoot);
|
|
55
|
+
const artifactsDir = join(root, "artifacts");
|
|
56
|
+
const stat = await lstat(artifactsDir);
|
|
57
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("Project artifacts path must be a regular directory, not a symlink.");
|
|
58
|
+
return artifactsDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function commitArtifact(projectRoot: string, input: unknown): Promise<CommitArtifactResult> {
|
|
62
|
+
const artifact = validateArtifact(input);
|
|
63
|
+
if (artifact.artifact_type === "execution_receipt") {
|
|
64
|
+
const forecastRead = await readCommittedArtifact(projectRoot, "approved_cycle_forecast");
|
|
65
|
+
if (forecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Committed approved forecast has the wrong artifact type.");
|
|
66
|
+
validateExecutionAgainstForecast(artifact, forecastRead.artifact);
|
|
67
|
+
}
|
|
68
|
+
const fingerprint = artifactFingerprint(artifact);
|
|
69
|
+
const committed = { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact;
|
|
70
|
+
const artifactsDir = await ensureArtifactDirectory(projectRoot);
|
|
71
|
+
const destination = join(artifactsDir, `${artifact.artifact_type}.json`);
|
|
72
|
+
const temporary = join(artifactsDir, `.${artifact.artifact_type}.${randomUUID()}.tmp`);
|
|
73
|
+
|
|
74
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
75
|
+
let closed = false;
|
|
76
|
+
try {
|
|
77
|
+
await handle.writeFile(`${JSON.stringify(committed, null, 2)}\n`, "utf8");
|
|
78
|
+
await handle.sync();
|
|
79
|
+
await handle.close();
|
|
80
|
+
closed = true;
|
|
81
|
+
await rename(temporary, destination);
|
|
82
|
+
const directoryHandle = await open(artifactsDir, "r");
|
|
83
|
+
try {
|
|
84
|
+
await directoryHandle.sync();
|
|
85
|
+
} finally {
|
|
86
|
+
await directoryHandle.close();
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (!closed) await handle.close().catch(() => undefined);
|
|
90
|
+
await unlink(temporary).catch(() => undefined);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { artifactType: artifact.artifact_type, fingerprint, path: destination };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function readCommittedArtifact(projectRoot: string, artifactType: ArtifactType): Promise<ReadArtifactResult> {
|
|
98
|
+
const path = join(await existingArtifactDirectory(projectRoot), `${artifactType}.json`);
|
|
99
|
+
const stat = await lstat(path);
|
|
100
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${artifactType} must be a regular file, not a symlink.`);
|
|
101
|
+
if (stat.size > 2 * 1024 * 1024) throw new Error(`${artifactType} exceeds the 2MB artifact limit.`);
|
|
102
|
+
let parsed: unknown;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new Error(`${artifactType} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
+
}
|
|
108
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${artifactType} must contain a JSON object.`);
|
|
109
|
+
const source = { ...(parsed as Record<string, unknown>) };
|
|
110
|
+
const fingerprint = source.immutable_fingerprint;
|
|
111
|
+
delete source.immutable_fingerprint;
|
|
112
|
+
if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint)) {
|
|
113
|
+
throw new Error(`${artifactType} has no valid immutable_fingerprint.`);
|
|
114
|
+
}
|
|
115
|
+
const artifact = validateArtifact(source);
|
|
116
|
+
if (artifact.artifact_type !== artifactType) throw new Error(`${path} contains ${artifact.artifact_type}, expected ${artifactType}.`);
|
|
117
|
+
const expected = artifactFingerprint(artifact);
|
|
118
|
+
if (expected !== fingerprint) throw new Error(`${artifactType} fingerprint mismatch: the committed artifact was modified after freezing.`);
|
|
119
|
+
return { artifact: { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact, fingerprint, path };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function readOptionalCommittedArtifact(projectRoot: string, artifactType: ArtifactType): Promise<ReadArtifactResult | null> {
|
|
123
|
+
try {
|
|
124
|
+
return await readCommittedArtifact(projectRoot, artifactType);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { Period } from "../fpa-artifacts/contracts.ts";
|
|
4
|
+
import { toNumber, type NumericLike } from "../fpa-data/calc.ts";
|
|
5
|
+
import { runStructuredQuery } from "../fpa-data/runtime.ts";
|
|
6
|
+
import type { SqlRow } from "../fpa-data/supabase.ts";
|
|
7
|
+
import type { DashboardActualsSnapshot, DashboardDailyPoint, DashboardScopeMode, QueryReceipt } from "./source.ts";
|
|
8
|
+
import { ACTUALS_DATA_AS_OF_UNAVAILABLE } from "./source.ts";
|
|
9
|
+
|
|
10
|
+
function dateInTimezone(timestamp: string, timezone: string, label: string): string {
|
|
11
|
+
const instant = new Date(timestamp);
|
|
12
|
+
if (Number.isNaN(instant.getTime())) throw new Error(`${label} is not a valid timestamp.`);
|
|
13
|
+
let parts: Intl.DateTimeFormatPart[];
|
|
14
|
+
try {
|
|
15
|
+
parts = new Intl.DateTimeFormat("en-US", {
|
|
16
|
+
timeZone: timezone,
|
|
17
|
+
year: "numeric",
|
|
18
|
+
month: "2-digit",
|
|
19
|
+
day: "2-digit",
|
|
20
|
+
}).formatToParts(instant);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error(`target_period.timezone "${timezone}" is not supported.`);
|
|
23
|
+
}
|
|
24
|
+
const byType = new Map(parts.map((part) => [part.type, part.value]));
|
|
25
|
+
return `${byType.get("year")}-${byType.get("month")}-${byType.get("day")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function addCalendarDays(value: string, days: number): string {
|
|
29
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
30
|
+
if (Number.isNaN(date.getTime())) throw new Error(`Invalid calendar date "${value}".`);
|
|
31
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
32
|
+
return date.toISOString().slice(0, 10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function calendarDayDistance(from: string, to: string): number {
|
|
36
|
+
const start = new Date(`${from}T00:00:00Z`).getTime();
|
|
37
|
+
const end = new Date(`${to}T00:00:00Z`).getTime();
|
|
38
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) throw new Error("Calendar dates must use YYYY-MM-DD.");
|
|
39
|
+
return Math.round((end - start) / 86_400_000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function queryFingerprint(sql: string): string {
|
|
43
|
+
return createHash("sha256").update(sql).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function value(row: SqlRow | undefined, name: string): number | null {
|
|
47
|
+
return toNumber(row?.[name] as NumericLike);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function receipt(dataset: string, sql: string, rowCount: number): QueryReceipt {
|
|
51
|
+
return { dataset, query_fingerprint: queryFingerprint(sql), row_count: rowCount };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function calendarQueryDates(period: Period): {
|
|
55
|
+
currentFrom: string;
|
|
56
|
+
currentTo: string;
|
|
57
|
+
} {
|
|
58
|
+
const currentFrom = dateInTimezone(period.start_inclusive, period.timezone, "target_period.start_inclusive");
|
|
59
|
+
const endExclusive = dateInTimezone(period.end_exclusive, period.timezone, "target_period.end_exclusive");
|
|
60
|
+
const days = calendarDayDistance(currentFrom, endExclusive);
|
|
61
|
+
if (days < 1) throw new Error("Dashboard target period must contain at least one day.");
|
|
62
|
+
return {
|
|
63
|
+
currentFrom,
|
|
64
|
+
currentTo: addCalendarDays(endExclusive, -1),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function comparisonDatesForCoverage(currentFrom: string, coverageMax: string): {
|
|
69
|
+
comparisonFrom: string;
|
|
70
|
+
comparisonTo: string;
|
|
71
|
+
} {
|
|
72
|
+
const elapsedDays = calendarDayDistance(currentFrom, coverageMax) + 1;
|
|
73
|
+
if (elapsedDays < 1) throw new Error("Actuals coverage cannot end before the target period starts.");
|
|
74
|
+
const comparisonTo = addCalendarDays(currentFrom, -1);
|
|
75
|
+
return {
|
|
76
|
+
comparisonFrom: addCalendarDays(comparisonTo, -(elapsedDays - 1)),
|
|
77
|
+
comparisonTo,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function completeDailySeries(
|
|
82
|
+
rows: SqlRow[],
|
|
83
|
+
from: string,
|
|
84
|
+
coverageMax: string | null,
|
|
85
|
+
): DashboardDailyPoint[] {
|
|
86
|
+
if (!coverageMax) return [];
|
|
87
|
+
const byDate = new Map(rows.map((row) => [String(row.period).slice(0, 10), row]));
|
|
88
|
+
const result: DashboardDailyPoint[] = [];
|
|
89
|
+
for (let date = from; date <= coverageMax; date = addCalendarDays(date, 1)) {
|
|
90
|
+
const row = byDate.get(date);
|
|
91
|
+
result.push({ date, spend: value(row, "spend"), revenue: value(row, "revenue") });
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DashboardActualsScope {
|
|
97
|
+
slices: Array<{ app_id: string; store: string; channel_group: string }>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Rows carry app_code only in "app" scope mode. In "portfolio" mode the App axis was
|
|
102
|
+
* collapsed onto a placeholder before the query ran, so the caller supplies it back.
|
|
103
|
+
*/
|
|
104
|
+
export function mapDashboardSlices(rows: SqlRow[], portfolioAppId?: string) {
|
|
105
|
+
return rows
|
|
106
|
+
.filter((row) => row.platform != null && row.media_source != null)
|
|
107
|
+
.filter((row) => portfolioAppId !== undefined || row.app_code != null)
|
|
108
|
+
.map((row) => ({
|
|
109
|
+
app_id: portfolioAppId ?? String(row.app_code),
|
|
110
|
+
store: String(row.platform),
|
|
111
|
+
channel_group: String(row.media_source),
|
|
112
|
+
spend: value(row, "spend"),
|
|
113
|
+
revenue: value(row, "revenue"),
|
|
114
|
+
source_rows: value(row, "source_rows") ?? 0,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The App axis carries no information when every approved slice shares one app_id.
|
|
120
|
+
* That is the only case where dropping the app_code predicate can be considered.
|
|
121
|
+
*/
|
|
122
|
+
export function collapsedAppId(slices: DashboardActualsScope["slices"]): string | null {
|
|
123
|
+
const appIds = new Set(slices.map((slice) => slice.app_id));
|
|
124
|
+
return appIds.size === 1 ? (slices[0]?.app_id ?? null) : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** True when the value is never used as an app_code in ua_spend, i.e. it is a placeholder. */
|
|
128
|
+
async function isPlaceholderAppId(appId: string, signal?: AbortSignal): Promise<boolean> {
|
|
129
|
+
const probe = await runStructuredQuery(
|
|
130
|
+
{ dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code"], filters: { app_code: appId }, limit: 1 },
|
|
131
|
+
signal,
|
|
132
|
+
);
|
|
133
|
+
return probe.rows.length === 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function resolveScopeMode(
|
|
137
|
+
slices: DashboardActualsScope["slices"],
|
|
138
|
+
signal?: AbortSignal,
|
|
139
|
+
): Promise<{ mode: DashboardScopeMode; portfolioAppId: string | null }> {
|
|
140
|
+
const collapsed = collapsedAppId(slices);
|
|
141
|
+
if (collapsed === null) return { mode: "app", portfolioAppId: null };
|
|
142
|
+
// A single real app still scopes on app_code; only a value absent from the mart is a placeholder.
|
|
143
|
+
if (!(await isPlaceholderAppId(collapsed, signal))) return { mode: "app", portfolioAppId: null };
|
|
144
|
+
return { mode: "portfolio", portfolioAppId: collapsed };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function loadDashboardActuals(
|
|
148
|
+
period: Period,
|
|
149
|
+
scope: DashboardActualsScope,
|
|
150
|
+
signal?: AbortSignal,
|
|
151
|
+
): Promise<DashboardActualsSnapshot> {
|
|
152
|
+
if (scope.slices.length === 0) throw new Error("Dashboard Actuals scope requires at least one approved slice.");
|
|
153
|
+
const dates = calendarQueryDates(period);
|
|
154
|
+
const uniqueSlices = [...new Map(scope.slices.map((slice) => [`${slice.app_id}\u0000${slice.store}\u0000${slice.channel_group}`, slice])).values()];
|
|
155
|
+
const common = {
|
|
156
|
+
dataset: "ua_spend" as const,
|
|
157
|
+
metrics: ["spend", "revenue", "roas"],
|
|
158
|
+
};
|
|
159
|
+
const exactUaScope = uniqueSlices.map((slice) => ({ app_code: slice.app_id, platform: slice.store, media_source: slice.channel_group }));
|
|
160
|
+
const exactUaPortfolioScope = [...new Map(uniqueSlices.map((slice) => [`${slice.app_id}\u0000${slice.store}`, { app_code: slice.app_id, platform: slice.store }])).values()];
|
|
161
|
+
const { mode, portfolioAppId } = await resolveScopeMode(uniqueSlices, signal);
|
|
162
|
+
const portfolio = mode === "portfolio";
|
|
163
|
+
// In portfolio mode the App axis is a placeholder absent from ua_spend, so scoping on
|
|
164
|
+
// app_code would match nothing. Drop it and aggregate every app at platform x media_source.
|
|
165
|
+
const sliceScope = portfolio
|
|
166
|
+
? { exactUaChannelScope: [...new Map(uniqueSlices.map((slice) => [`${slice.store}\u0000${slice.channel_group}`, { platform: slice.store, media_source: slice.channel_group }])).values()] }
|
|
167
|
+
: { exactUaScope };
|
|
168
|
+
const discoveryScope = portfolio
|
|
169
|
+
? { filters: { platform: [...new Set(uniqueSlices.map((slice) => slice.store))] } }
|
|
170
|
+
: { exactUaPortfolioScope };
|
|
171
|
+
const sliceDimensions = portfolio ? ["platform", "media_source"] : ["app_code", "platform", "media_source"];
|
|
172
|
+
const current = await runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 }, signal);
|
|
173
|
+
const currentRow = current.rows[0];
|
|
174
|
+
const coverageMin = currentRow?.date_min == null ? null : String(currentRow.date_min);
|
|
175
|
+
const coverageMax = currentRow?.date_max == null ? null : String(currentRow.date_max);
|
|
176
|
+
const comparisonDates = coverageMax ? comparisonDatesForCoverage(dates.currentFrom, coverageMax) : null;
|
|
177
|
+
const [comparison, daily, approvedSlices, discoveredSlices] = await Promise.all([
|
|
178
|
+
comparisonDates
|
|
179
|
+
? runStructuredQuery({ ...common, ...sliceScope, dateFrom: comparisonDates.comparisonFrom, dateTo: comparisonDates.comparisonTo, limit: 1 }, signal)
|
|
180
|
+
: Promise.resolve(null),
|
|
181
|
+
runStructuredQuery({ ...common, ...sliceScope, dateFrom: dates.currentFrom, dateTo: dates.currentTo, timeGrain: "day", limit: 1000 }, signal),
|
|
182
|
+
runStructuredQuery({
|
|
183
|
+
...common,
|
|
184
|
+
dateFrom: dates.currentFrom,
|
|
185
|
+
dateTo: dates.currentTo,
|
|
186
|
+
...sliceScope,
|
|
187
|
+
dimensions: sliceDimensions,
|
|
188
|
+
limit: 500,
|
|
189
|
+
}, signal),
|
|
190
|
+
runStructuredQuery({
|
|
191
|
+
...common,
|
|
192
|
+
dateFrom: dates.currentFrom,
|
|
193
|
+
dateTo: dates.currentTo,
|
|
194
|
+
...discoveryScope,
|
|
195
|
+
dimensions: sliceDimensions,
|
|
196
|
+
limit: 1000,
|
|
197
|
+
}, signal),
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
const comparisonRow = comparison?.rows[0];
|
|
201
|
+
const queryReceipts = [
|
|
202
|
+
receipt("ua_spend.current", current.built.sql, current.rows.length),
|
|
203
|
+
...(comparison ? [receipt("ua_spend.comparison", comparison.built.sql, comparison.rows.length)] : []),
|
|
204
|
+
receipt("ua_spend.daily", daily.built.sql, daily.rows.length),
|
|
205
|
+
receipt("ua_spend.slices.approved", approvedSlices.built.sql, approvedSlices.rows.length),
|
|
206
|
+
receipt("ua_spend.slices.discovery", discoveredSlices.built.sql, discoveredSlices.rows.length),
|
|
207
|
+
];
|
|
208
|
+
const approvedKeys = new Set(
|
|
209
|
+
portfolio
|
|
210
|
+
? uniqueSlices.map((slice) => `${slice.store}\u0000${slice.channel_group}`)
|
|
211
|
+
: exactUaScope.map((scope) => `${scope.app_code}\u0000${scope.platform}\u0000${scope.media_source}`),
|
|
212
|
+
);
|
|
213
|
+
const rowKey = (row: SqlRow): string =>
|
|
214
|
+
portfolio ? `${row.platform}\u0000${row.media_source}` : `${row.app_code}\u0000${row.platform}\u0000${row.media_source}`;
|
|
215
|
+
const sliceRows = [
|
|
216
|
+
...approvedSlices.rows,
|
|
217
|
+
...discoveredSlices.rows.filter((row) => !approvedKeys.has(rowKey(row))),
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
period,
|
|
222
|
+
data_as_of: coverageMax ?? ACTUALS_DATA_AS_OF_UNAVAILABLE,
|
|
223
|
+
reporting_currency: "USD",
|
|
224
|
+
scope_mode: mode,
|
|
225
|
+
coverage: {
|
|
226
|
+
date_min: coverageMin,
|
|
227
|
+
date_max: coverageMax,
|
|
228
|
+
source_rows: value(currentRow, "source_rows") ?? 0,
|
|
229
|
+
},
|
|
230
|
+
current: { spend: value(currentRow, "spend"), revenue: value(currentRow, "revenue") },
|
|
231
|
+
comparison: comparisonRow
|
|
232
|
+
? { spend: value(comparisonRow, "spend"), revenue: value(comparisonRow, "revenue") }
|
|
233
|
+
: null,
|
|
234
|
+
daily: completeDailySeries(daily.rows, dates.currentFrom, coverageMax),
|
|
235
|
+
slices: mapDashboardSlices(sliceRows, portfolioAppId ?? undefined),
|
|
236
|
+
query_receipts: queryReceipts,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
readCommittedArtifact,
|
|
7
|
+
readOptionalCommittedArtifact,
|
|
8
|
+
type ReadArtifactResult,
|
|
9
|
+
} from "../fpa-artifacts/store.ts";
|
|
10
|
+
import type {
|
|
11
|
+
ApprovedCycleForecast,
|
|
12
|
+
ApprovedCycleForecastInput,
|
|
13
|
+
CanonicalArtifact,
|
|
14
|
+
ExecutionReceipt,
|
|
15
|
+
ExecutionReceiptInput,
|
|
16
|
+
} from "../fpa-artifacts/contracts.ts";
|
|
17
|
+
import { isExecutionReceiptForForecast, sliceKey } from "../fpa-artifacts/contracts.ts";
|
|
18
|
+
import { loadDashboardActuals } from "./actuals.ts";
|
|
19
|
+
import { projectDashboard, type DashboardBuild } from "./projector.ts";
|
|
20
|
+
import { dashboardBuildFingerprint, publishDashboard } from "./publisher.ts";
|
|
21
|
+
import { inspectDashboard } from "./status.ts";
|
|
22
|
+
|
|
23
|
+
function toolResult(value: Record<string, unknown>) {
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
|
|
26
|
+
details: value,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function withoutFingerprint<T extends CanonicalArtifact>(artifact: T): Omit<T, "immutable_fingerprint"> {
|
|
31
|
+
const { immutable_fingerprint: _fingerprint, ...input } = artifact;
|
|
32
|
+
return input;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function buildDashboard(
|
|
36
|
+
cwd: string,
|
|
37
|
+
locale: string,
|
|
38
|
+
signal?: AbortSignal,
|
|
39
|
+
): Promise<{
|
|
40
|
+
build: DashboardBuild;
|
|
41
|
+
forecast: ApprovedCycleForecast;
|
|
42
|
+
forecastRead: ReadArtifactResult;
|
|
43
|
+
executionRead: ReadArtifactResult | null;
|
|
44
|
+
}> {
|
|
45
|
+
const forecastRead = await readCommittedArtifact(cwd, "approved_cycle_forecast");
|
|
46
|
+
if (forecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Committed approved forecast has the wrong artifact type.");
|
|
47
|
+
const forecast = forecastRead.artifact as ApprovedCycleForecast;
|
|
48
|
+
const committedExecutionRead = await readOptionalCommittedArtifact(cwd, "execution_receipt");
|
|
49
|
+
const executionRead = committedExecutionRead?.artifact.artifact_type === "execution_receipt" && isExecutionReceiptForForecast(committedExecutionRead.artifact, forecast)
|
|
50
|
+
? committedExecutionRead
|
|
51
|
+
: null;
|
|
52
|
+
const execution = executionRead?.artifact.artifact_type === "execution_receipt"
|
|
53
|
+
? executionRead.artifact as ExecutionReceipt
|
|
54
|
+
: null;
|
|
55
|
+
const actuals = await loadDashboardActuals(
|
|
56
|
+
forecast.target_period,
|
|
57
|
+
{
|
|
58
|
+
slices: forecast.approved_allocation,
|
|
59
|
+
},
|
|
60
|
+
signal,
|
|
61
|
+
);
|
|
62
|
+
const build = projectDashboard({
|
|
63
|
+
forecast: withoutFingerprint(forecast) as ApprovedCycleForecastInput,
|
|
64
|
+
actuals,
|
|
65
|
+
...(execution ? { execution: withoutFingerprint(execution) as ExecutionReceiptInput } : {}),
|
|
66
|
+
locale,
|
|
67
|
+
});
|
|
68
|
+
build.source.forecast_fingerprint = forecastRead.fingerprint;
|
|
69
|
+
if (executionRead) build.source.execution_fingerprint = executionRead.fingerprint;
|
|
70
|
+
|
|
71
|
+
const plannedKeys = new Set(forecast.approved_allocation.map(sliceKey));
|
|
72
|
+
const actualKeys = new Set(actuals.slices.map(sliceKey));
|
|
73
|
+
const unplanned = actuals.slices.filter((slice) => !plannedKeys.has(sliceKey(slice)) && ((slice.spend ?? 0) > 0 || (slice.revenue ?? 0) !== 0));
|
|
74
|
+
const missing = forecast.approved_allocation.filter((slice) => !actualKeys.has(sliceKey(slice)));
|
|
75
|
+
if (unplanned.length > 0) build.warnings.push(`${unplanned.length} paid Actuals slices are outside the approved allocation and were excluded from like-for-like forecast totals and the strategy table.`);
|
|
76
|
+
if (missing.length > 0) build.warnings.push(`${missing.length} approved slices have no current-period Actuals row; their values remain null.`);
|
|
77
|
+
if (committedExecutionRead && !executionRead) build.warnings.push(`Ignored stale execution receipt for forecast ${committedExecutionRead.artifact.forecast_version}; current forecast is ${forecast.forecast_version}.`);
|
|
78
|
+
if (actuals.query_receipts.some((receipt) => receipt.dataset === "ua_spend.slices.discovery" && receipt.row_count >= 1000)) build.warnings.push("Unplanned-slice discovery reached its 1000-row safety limit; approved slices and like-for-like totals remain complete, but additional warnings may be omitted.");
|
|
79
|
+
if (actuals.data_as_of === "unavailable") build.warnings.push("No current-period UA Actuals are available.");
|
|
80
|
+
return { build, forecast, forecastRead, executionRead };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
84
|
+
pi.registerTool({
|
|
85
|
+
name: "fpa_dashboard_status",
|
|
86
|
+
label: "FP&A Dashboard Status",
|
|
87
|
+
description: "Inspect the current tenant dashboard generation, datasets, build receipt, and diagnostics without changing files.",
|
|
88
|
+
promptSnippet: "Inspect the current FP&A dashboard generation and diagnostics",
|
|
89
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
90
|
+
executionMode: "parallel",
|
|
91
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
92
|
+
return toolResult(await inspectDashboard(ctx.cwd) as unknown as Record<string, unknown>);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
pi.registerTool({
|
|
97
|
+
name: "fpa_dashboard_refresh",
|
|
98
|
+
label: "Refresh FP&A Dashboard",
|
|
99
|
+
description:
|
|
100
|
+
"Build the forecast closed-loop dashboard from a committed approved forecast, optional committed execution receipt, and current read-only FP&A Actuals. " +
|
|
101
|
+
"Preview computes and validates without publishing; publish requires the exact preview fingerprint and atomically promotes a content-addressed generation.",
|
|
102
|
+
promptSnippet: "Preview or atomically publish the forecast closed-loop FP&A dashboard",
|
|
103
|
+
promptGuidelines: [
|
|
104
|
+
"Always call fpa_dashboard_refresh with mode=preview before mode=publish and carry forward the exact preview_fingerprint.",
|
|
105
|
+
"Never write .fpa-dashboard files with generic file or shell tools; use fpa_dashboard_refresh so calculations, lineage, and atomic publication stay consistent.",
|
|
106
|
+
],
|
|
107
|
+
parameters: Type.Object(
|
|
108
|
+
{
|
|
109
|
+
preset: StringEnum(["forecast-closed-loop-v1"] as const),
|
|
110
|
+
mode: StringEnum(["preview", "publish"] as const),
|
|
111
|
+
locale: Type.Optional(StringEnum(["zh-CN", "en-US"] as const)),
|
|
112
|
+
expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
|
|
113
|
+
},
|
|
114
|
+
{ additionalProperties: false },
|
|
115
|
+
),
|
|
116
|
+
executionMode: "sequential",
|
|
117
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
118
|
+
const { build, forecast } = await buildDashboard(ctx.cwd, params.locale ?? "zh-CN", signal);
|
|
119
|
+
const previewFingerprint = dashboardBuildFingerprint(build);
|
|
120
|
+
const summary = {
|
|
121
|
+
preset: params.preset,
|
|
122
|
+
preview_fingerprint: previewFingerprint,
|
|
123
|
+
forecast_version: forecast.forecast_version,
|
|
124
|
+
forecast_fingerprint: build.source.forecast_fingerprint,
|
|
125
|
+
execution_fingerprint: build.source.execution_fingerprint,
|
|
126
|
+
data_as_of: build.source.data_as_of,
|
|
127
|
+
widget_count: build.widgets.length,
|
|
128
|
+
widgets: build.widgets.map((widget) => ({ id: widget.id, type: widget.type, dataset: widget.dataset })),
|
|
129
|
+
warnings: build.warnings,
|
|
130
|
+
query_receipts: build.source.query_receipts,
|
|
131
|
+
};
|
|
132
|
+
if (params.mode === "preview") return toolResult({ status: build.warnings.length > 0 ? "ready_with_limits" : "ready", ...summary });
|
|
133
|
+
|
|
134
|
+
if (forecast.status !== "complete" || !forecast.approval_conditions_satisfied) {
|
|
135
|
+
throw new Error("Dashboard publish requires a complete approved forecast with all approval conditions satisfied.");
|
|
136
|
+
}
|
|
137
|
+
if (!params.expected_preview_fingerprint) throw new Error("Publish requires expected_preview_fingerprint from a preceding preview.");
|
|
138
|
+
if (params.expected_preview_fingerprint !== previewFingerprint) {
|
|
139
|
+
throw new Error("Dashboard inputs changed after preview; run preview again before publishing.");
|
|
140
|
+
}
|
|
141
|
+
const published = await publishDashboard({ cwd: ctx.cwd, build });
|
|
142
|
+
return toolResult({ status: "published", ...summary, dashboard_dir: published.dashboardDir, published_datasets: published.publishedDatasets });
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|