@viccydev/pi-fpa 0.5.0 → 0.6.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.
@@ -0,0 +1,252 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+
5
+ import { stableJson } from "../fpa-artifacts/store.ts";
6
+ import type { CycleOperatingProjection } from "./cycle-operating-projection.ts";
7
+ import type { DashboardWidget } from "./projector.ts";
8
+ import { resolveDashboardDir } from "./publisher.ts";
9
+ import { validateActualsSnapshot, type DashboardActualsSnapshot } from "./source.ts";
10
+
11
+ const MAX_FILE_BYTES = 256 * 1024;
12
+ const GENERATION_RE = /^[a-f0-9]{64}\.json$/;
13
+ const RECEIPT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\.json$/;
14
+
15
+ export interface ForecastAccuracyProjection {
16
+ kind: "fpa.forecast-accuracy";
17
+ schema_version: 1;
18
+ status: "available" | "insufficient_history";
19
+ sample_size: number;
20
+ mean_absolute_percentage_error: number | null;
21
+ mean_percentage_bias: number | null;
22
+ mean_absolute_error: number | null;
23
+ weighted_absolute_percentage_error: number | null;
24
+ mean_absolute_scaled_error: number | null;
25
+ cycles: Array<{
26
+ cycle_id: string;
27
+ forecast_version: string;
28
+ data_as_of: string;
29
+ forecast_revenue: number;
30
+ actual_revenue: number;
31
+ revenue_delta: number;
32
+ revenue_delta_pct: number;
33
+ }>;
34
+ warnings: string[];
35
+ }
36
+
37
+ function round(value: number): number {
38
+ return Math.round(value * 1_000_000) / 1_000_000;
39
+ }
40
+
41
+ function closedCycle(projection: CycleOperatingProjection): ForecastAccuracyProjection["cycles"][number] | null {
42
+ if (!projection || typeof projection !== "object") return null;
43
+ const current = projection.current_cycle;
44
+ if (!current || typeof current !== "object") return null;
45
+ const variance = current.variance;
46
+ const forecast = current.forecast?.revenue?.base;
47
+ const actual = current.actual_to_date?.revenue;
48
+ if (
49
+ variance?.status !== "closed_comparable"
50
+ || typeof forecast !== "number"
51
+ || !Number.isFinite(forecast)
52
+ || typeof actual !== "number"
53
+ || !Number.isFinite(actual)
54
+ || typeof variance.revenue_delta !== "number"
55
+ || !Number.isFinite(variance.revenue_delta)
56
+ || typeof variance.revenue_delta_pct !== "number"
57
+ || !Number.isFinite(variance.revenue_delta_pct)
58
+ || typeof projection.cycle_id !== "string"
59
+ || typeof current.forecast_version !== "string"
60
+ || typeof projection.data_as_of !== "string"
61
+ ) return null;
62
+ return {
63
+ cycle_id: projection.cycle_id,
64
+ forecast_version: projection.current_cycle.forecast_version,
65
+ data_as_of: projection.data_as_of,
66
+ forecast_revenue: forecast,
67
+ actual_revenue: actual,
68
+ revenue_delta: variance.revenue_delta,
69
+ revenue_delta_pct: variance.revenue_delta_pct,
70
+ };
71
+ }
72
+
73
+ function sha256(value: string): string {
74
+ return createHash("sha256").update(value).digest("hex");
75
+ }
76
+
77
+ function receiptDigestMatches(filename: string, receipt: Record<string, unknown>): boolean {
78
+ const match = /^build-receipt\.[a-f0-9]{12}\.([a-f0-9]{12})\.json$/.exec(filename);
79
+ return match !== null && sha256(stableJson(receipt)).startsWith(match[1]);
80
+ }
81
+
82
+ function boundedString(value: unknown, max = 256): value is string {
83
+ return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
84
+ }
85
+
86
+ function finite(value: unknown): value is number {
87
+ return typeof value === "number" && Number.isFinite(value);
88
+ }
89
+
90
+ /** Validate every field consumed by the accuracy projection before casting. */
91
+ function historicalClosedProjection(value: unknown, scopeId: string, actuals: DashboardActualsSnapshot): CycleOperatingProjection | null {
92
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
93
+ const source = value as Record<string, unknown>;
94
+ if (source.kind !== "fpa.cycle-operating-projection" || source.schema_version !== 1 || source.scope_id !== scopeId) return null;
95
+ if (!boundedString(source.cycle_id) || !boundedString(source.data_as_of) || Number.isNaN(Date.parse(`${source.data_as_of}T00:00:00Z`))) return null;
96
+ if (!boundedString(source.reporting_currency, 3) || !/^[A-Z]{3}$/.test(source.reporting_currency)) return null;
97
+ if (!source.current_cycle || typeof source.current_cycle !== "object" || Array.isArray(source.current_cycle)) return null;
98
+ const current = source.current_cycle as Record<string, unknown>;
99
+ if (!boundedString(current.forecast_version)) return null;
100
+ if (!current.variance || typeof current.variance !== "object" || Array.isArray(current.variance)) return null;
101
+ if (!current.forecast || typeof current.forecast !== "object" || Array.isArray(current.forecast)) return null;
102
+ if (!current.actual_to_date || typeof current.actual_to_date !== "object" || Array.isArray(current.actual_to_date)) return null;
103
+ const variance = current.variance as Record<string, unknown>;
104
+ const forecast = current.forecast as Record<string, unknown>;
105
+ const actual = current.actual_to_date as Record<string, unknown>;
106
+ if (variance.status !== "closed_comparable") return null;
107
+ if (!forecast.revenue || typeof forecast.revenue !== "object" || Array.isArray(forecast.revenue)) return null;
108
+ if (!finite((forecast.revenue as Record<string, unknown>).base) || !finite(actual.revenue) || !finite(variance.revenue_delta) || !finite(variance.revenue_delta_pct)) return null;
109
+ if (source.data_as_of !== actuals.data_as_of || actual.revenue !== actuals.current.revenue) return null;
110
+ if (actuals.snapshot_evidence.consistency !== "single_statement" || !actuals.snapshot_evidence.period_complete || !actuals.snapshot_evidence.reconciled) return null;
111
+ if (!current.target_period || typeof current.target_period !== "object" || Array.isArray(current.target_period)) return null;
112
+ const target = current.target_period as Record<string, unknown>;
113
+ if (target.start_inclusive !== actuals.period.start_inclusive || target.end_exclusive !== actuals.period.end_exclusive || target.timezone !== actuals.period.timezone) return null;
114
+ if (!Array.isArray(source.warnings) || source.warnings.length > 128 || source.warnings.some((warning) => !boundedString(warning, 4096))) return null;
115
+ return value as CycleOperatingProjection;
116
+ }
117
+
118
+ export function projectForecastAccuracy(projections: CycleOperatingProjection[]): ForecastAccuracyProjection {
119
+ const byCycle = new Map<string, ForecastAccuracyProjection["cycles"][number]>();
120
+ for (const projection of projections) {
121
+ const cycle = closedCycle(projection);
122
+ if (cycle) byCycle.set(cycle.cycle_id, cycle);
123
+ }
124
+ const cycles = [...byCycle.values()].sort((left, right) => left.data_as_of.localeCompare(right.data_as_of)).slice(-24);
125
+ const sampleSize = cycles.length;
126
+ const mape = sampleSize === 0 ? null : round(cycles.reduce((sum, cycle) => sum + Math.abs(cycle.revenue_delta_pct), 0) / sampleSize);
127
+ const bias = sampleSize === 0 ? null : round(cycles.reduce((sum, cycle) => sum + cycle.revenue_delta_pct, 0) / sampleSize);
128
+ const mae = sampleSize === 0 ? null : round(cycles.reduce((sum, cycle) => sum + Math.abs(cycle.revenue_delta), 0) / sampleSize);
129
+ const actualTotal = cycles.reduce((sum, cycle) => sum + Math.abs(cycle.actual_revenue), 0);
130
+ const wape = sampleSize === 0 || actualTotal === 0 ? null : round(cycles.reduce((sum, cycle) => sum + Math.abs(cycle.revenue_delta), 0) / actualTotal);
131
+ const naiveErrors = cycles.slice(1).map((cycle, index) => Math.abs(cycle.actual_revenue - cycles[index].actual_revenue));
132
+ const naiveMae = naiveErrors.length === 0 ? null : naiveErrors.reduce((sum, value) => sum + value, 0) / naiveErrors.length;
133
+ const mase = mae === null || naiveMae === null || naiveMae === 0 ? null : round(mae / naiveMae);
134
+ const warnings = sampleSize < 3 ? ["Forecast accuracy has fewer than three closed, comparable original-forecast cycles; trend conclusions are not yet reliable."] : [];
135
+ return {
136
+ kind: "fpa.forecast-accuracy",
137
+ schema_version: 1,
138
+ status: sampleSize >= 3 ? "available" : "insufficient_history",
139
+ sample_size: sampleSize,
140
+ mean_absolute_percentage_error: mape,
141
+ mean_percentage_bias: bias,
142
+ mean_absolute_error: mae,
143
+ weighted_absolute_percentage_error: wape,
144
+ mean_absolute_scaled_error: mase,
145
+ cycles,
146
+ warnings,
147
+ };
148
+ }
149
+
150
+ async function boundedJson(path: string, maxBytes = MAX_FILE_BYTES): Promise<Record<string, unknown> | null> {
151
+ try {
152
+ const file = await lstat(path);
153
+ if (file.isSymbolicLink() || !file.isFile() || file.size > maxBytes) return null;
154
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
155
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ export async function loadHistoricalOperatingProjections(cwd: string, scopeId: string): Promise<CycleOperatingProjection[]> {
162
+ const dashboardDir = await resolveDashboardDir(cwd);
163
+ const generationsDir = join(dashboardDir, "generations");
164
+ let names: string[];
165
+ try {
166
+ names = (await readdir(generationsDir)).filter((name) => GENERATION_RE.test(name)).slice(0, 500);
167
+ } catch (error) {
168
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
169
+ throw error;
170
+ }
171
+ const result: Array<{ publishedAt: string; projection: CycleOperatingProjection }> = [];
172
+ for (const name of names) {
173
+ const generation = await boundedJson(join(generationsDir, name));
174
+ const generationId = basename(name, ".json");
175
+ if (
176
+ !generation
177
+ || generation.kind !== "fpa.dashboard.generation"
178
+ || generation.schema_version !== 1
179
+ || generation.generation_id !== generationId
180
+ || typeof generation.published_at !== "string"
181
+ || Number.isNaN(Date.parse(generation.published_at))
182
+ || typeof generation.build_receipt !== "string"
183
+ || !RECEIPT_RE.test(generation.build_receipt)
184
+ ) continue;
185
+ const receipt = await boundedJson(join(dashboardDir, generation.build_receipt));
186
+ if (
187
+ !receipt
188
+ || receipt.generation_id !== generationId
189
+ || receipt.published_at !== generation.published_at
190
+ || !receiptDigestMatches(generation.build_receipt, receipt)
191
+ || !receipt.source
192
+ || typeof receipt.source !== "object"
193
+ || (receipt.source as Record<string, unknown>).scope_id !== scopeId
194
+ ) continue;
195
+ const actualsRef = (receipt.source as Record<string, unknown>).actuals_snapshot_ref;
196
+ if (typeof actualsRef !== "string" || !/^[a-f0-9]{64}$/.test(actualsRef)) continue;
197
+ const actualsObject = await boundedJson(join(dashboardDir, "actuals", `${actualsRef}.json`), 2 * 1024 * 1024);
198
+ if (!actualsObject) continue;
199
+ let actuals: DashboardActualsSnapshot;
200
+ try {
201
+ actuals = validateActualsSnapshot(actualsObject);
202
+ if (sha256(stableJson(actuals)) !== actualsRef) continue;
203
+ } catch {
204
+ continue;
205
+ }
206
+ const candidate = historicalClosedProjection(receipt.operating_projection, scopeId, actuals);
207
+ if (!candidate) continue;
208
+ result.push({ publishedAt: generation.published_at, projection: candidate });
209
+ }
210
+ return result.sort((left, right) => left.publishedAt.localeCompare(right.publishedAt)).map((item) => item.projection);
211
+ }
212
+
213
+ function percent(value: number | null, locale: string): string | null {
214
+ return value === null ? null : new Intl.NumberFormat(locale, { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1, signDisplay: "always" }).format(value);
215
+ }
216
+
217
+ function number(value: number | null, locale: string): string {
218
+ return value === null ? "—" : new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
219
+ }
220
+
221
+ export function projectForecastAccuracyWidgets(projection: ForecastAccuracyProjection, locale = "zh-CN"): DashboardWidget[] {
222
+ const zh = locale.toLowerCase().startsWith("zh");
223
+ return [
224
+ {
225
+ id: "forecast-accuracy-mape",
226
+ type: "stat",
227
+ span: "quarter",
228
+ dataset: "forecast-accuracy-mape.json",
229
+ data: {
230
+ label: zh ? "历史预测误差 MAPE" : "Historical forecast MAPE",
231
+ value: percent(projection.mean_absolute_percentage_error, locale),
232
+ ...(projection.sample_size === 0 ? { missingReason: zh ? "尚无可比较的已关闭周期" : "No comparable closed cycles are available." } : {}),
233
+ description: zh
234
+ ? `${projection.sample_size} 个 original Forecast 样本 · WAPE ${percent(projection.weighted_absolute_percentage_error, locale) ?? "—"} · MAE ${number(projection.mean_absolute_error, locale)} · MASE ${number(projection.mean_absolute_scaled_error, locale)}`
235
+ : `${projection.sample_size} original-forecast cycle(s) · WAPE ${percent(projection.weighted_absolute_percentage_error, locale) ?? "—"} · MAE ${number(projection.mean_absolute_error, locale)} · MASE ${number(projection.mean_absolute_scaled_error, locale)}`,
236
+ },
237
+ },
238
+ {
239
+ id: "forecast-accuracy-history",
240
+ type: "timeseries",
241
+ span: "full",
242
+ dataset: "forecast-accuracy-history.json",
243
+ data: {
244
+ label: zh ? "冻结预测与最终实际收入" : "Frozen forecast vs final Actual revenue",
245
+ series: [
246
+ { name: zh ? "原始预测" : "Original forecast", points: projection.cycles.map((cycle) => ({ x: cycle.cycle_id, y: cycle.forecast_revenue })) },
247
+ { name: "Actual", points: projection.cycles.map((cycle) => ({ x: cycle.cycle_id, y: cycle.actual_revenue })) },
248
+ ],
249
+ },
250
+ },
251
+ ];
252
+ }
@@ -0,0 +1,142 @@
1
+ import { validateArtifact, type ApprovedCycleForecastInput, type ScenarioMetric } from "../fpa-artifacts/contracts.ts";
2
+ import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
3
+ import type { DashboardWidget, TableCell } from "./projector.ts";
4
+
5
+ interface ForwardForecastInput {
6
+ ref: ArtifactRefV2;
7
+ forecast: unknown;
8
+ }
9
+
10
+ export interface ForwardOutlookProjection {
11
+ kind: "fpa.forward-outlook";
12
+ schema_version: 1;
13
+ reporting_currency: string;
14
+ status: "six_months_ready" | "partial" | "unavailable";
15
+ months: Array<{
16
+ artifact_ref: ArtifactRefV2;
17
+ forecast_version: string;
18
+ target_period: ApprovedCycleForecastInput["target_period"];
19
+ expected_spend: { downside: number | null; base: number | null; upside: number | null };
20
+ expected_revenue: { downside: number | null; base: number | null; upside: number | null };
21
+ expected_roas: { downside: number | null; base: number | null; upside: number | null };
22
+ }>;
23
+ warnings: string[];
24
+ }
25
+
26
+ function approvedForecast(value: unknown, path: string): ApprovedCycleForecastInput {
27
+ const artifact = validateArtifact(value);
28
+ if (artifact.artifact_type !== "approved_cycle_forecast") throw new Error(`${path} must be an approved_cycle_forecast.`);
29
+ if (artifact.status !== "complete" || !artifact.approval_conditions_satisfied) throw new Error(`${path} must be complete and approved.`);
30
+ return artifact;
31
+ }
32
+
33
+ function values(metric: ScenarioMetric | undefined, path: string) {
34
+ if (!metric) throw new Error(`${path} is required.`);
35
+ return { downside: metric.downside, base: metric.base, upside: metric.upside };
36
+ }
37
+
38
+ export function projectForwardOutlook(input: {
39
+ current_forecast: unknown;
40
+ forward_forecasts: ForwardForecastInput[];
41
+ }): ForwardOutlookProjection {
42
+ const current = approvedForecast(input.current_forecast, "current_forecast");
43
+ if (!Array.isArray(input.forward_forecasts) || input.forward_forecasts.length > 6) throw new Error("forward_forecasts must contain at most six approved forecasts.");
44
+ const months: ForwardOutlookProjection["months"] = [];
45
+ let expectedStart = current.target_period.end_exclusive;
46
+ for (const [index, item] of input.forward_forecasts.entries()) {
47
+ const forecast = approvedForecast(item.forecast, `forward_forecasts[${index}].forecast`);
48
+ if (item.ref.artifact_type !== "approved_cycle_forecast" || item.ref.body_fingerprint === "" || item.ref.cycle_id === "") throw new Error(`forward_forecasts[${index}].ref is invalid.`);
49
+ if (forecast.reporting_currency !== current.reporting_currency) throw new Error(`forward_forecasts[${index}] reporting currency does not match the current forecast.`);
50
+ if (forecast.target_period.timezone !== current.target_period.timezone || Date.parse(forecast.target_period.start_inclusive) !== Date.parse(expectedStart)) {
51
+ throw new Error(`forward_forecasts[${index}] must be the exact consecutive successor in the same timezone.`);
52
+ }
53
+ expectedStart = forecast.target_period.end_exclusive;
54
+ months.push({
55
+ artifact_ref: item.ref,
56
+ forecast_version: forecast.forecast_version,
57
+ target_period: forecast.target_period,
58
+ expected_spend: values(forecast.consolidated_forecast.spend, `forward_forecasts[${index}].spend`),
59
+ expected_revenue: values(forecast.consolidated_forecast.revenue, `forward_forecasts[${index}].revenue`),
60
+ expected_roas: values(forecast.consolidated_forecast.roas, `forward_forecasts[${index}].roas`),
61
+ });
62
+ }
63
+ const warnings: string[] = [];
64
+ if (months.length < 6) warnings.push(`Only ${months.length} of 6 forward monthly forecasts are approved and linked.`);
65
+ if (months.some((month) => month.expected_revenue.base === null || month.expected_spend.base === null || month.expected_roas.base === null)) {
66
+ warnings.push("At least one forward month has unsupported base metrics; its expectation remains unavailable.");
67
+ }
68
+ return {
69
+ kind: "fpa.forward-outlook",
70
+ schema_version: 1,
71
+ reporting_currency: current.reporting_currency,
72
+ status: months.length === 0 ? "unavailable" : months.length === 6 ? "six_months_ready" : "partial",
73
+ months,
74
+ warnings,
75
+ };
76
+ }
77
+
78
+ function currency(value: number | null, code: string, locale: string): string | null {
79
+ return value === null ? null : new Intl.NumberFormat(locale, { style: "currency", currency: code, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(value);
80
+ }
81
+
82
+ function number(value: number | null): string | null {
83
+ return value === null ? null : value.toFixed(2);
84
+ }
85
+
86
+ function neutral(value: string | null): TableCell {
87
+ return { value, tone: "neutral" };
88
+ }
89
+
90
+ export function projectForwardOutlookWidgets(projection: ForwardOutlookProjection, locale = "zh-CN"): DashboardWidget[] {
91
+ const zh = locale.toLowerCase().startsWith("zh");
92
+ if (projection.status === "unavailable") return [{
93
+ id: "forward-outlook-readiness",
94
+ type: "stat",
95
+ span: "quarter",
96
+ dataset: "forward-outlook-readiness.json",
97
+ data: {
98
+ label: zh ? "未来六个月预测" : "Six-month outlook",
99
+ value: null,
100
+ missingReason: zh ? "尚无连续的未来周期批准预测" : "No consecutive approved future forecasts are linked.",
101
+ },
102
+ }];
103
+ const monthLabel = (month: ForwardOutlookProjection["months"][number]) => month.target_period.start_inclusive.slice(0, 7);
104
+ return [
105
+ {
106
+ id: "forward-outlook-revenue",
107
+ type: "timeseries",
108
+ span: "full",
109
+ dataset: "forward-outlook-revenue.json",
110
+ data: {
111
+ label: zh ? "未来六个月收入情景" : "Six-month revenue scenarios",
112
+ description: zh ? `已批准 ${projection.months.length}/6 个连续月份` : `${projection.months.length}/6 consecutive months approved`,
113
+ series: [
114
+ { name: zh ? "下行" : "Downside", points: projection.months.map((month) => ({ x: monthLabel(month), y: month.expected_revenue.downside })) },
115
+ { name: zh ? "基准" : "Base", points: projection.months.map((month) => ({ x: monthLabel(month), y: month.expected_revenue.base })) },
116
+ { name: zh ? "上行" : "Upside", points: projection.months.map((month) => ({ x: monthLabel(month), y: month.expected_revenue.upside })) },
117
+ ],
118
+ },
119
+ },
120
+ {
121
+ id: "forward-outlook-table",
122
+ type: "table",
123
+ span: "full",
124
+ dataset: "forward-outlook-table.json",
125
+ data: {
126
+ label: zh ? "未来周期批准预测" : "Approved forward forecasts",
127
+ columns: [
128
+ { key: "period", label: zh ? "周期" : "Period" },
129
+ { key: "spend", label: zh ? "预算" : "Spend", align: "right" },
130
+ { key: "revenue", label: zh ? "预期收入" : "Expected revenue", align: "right" },
131
+ { key: "roas", label: "ROAS", align: "right" },
132
+ ],
133
+ rows: projection.months.map((month) => ({
134
+ period: monthLabel(month),
135
+ spend: currency(month.expected_spend.base, projection.reporting_currency, locale),
136
+ revenue: currency(month.expected_revenue.base, projection.reporting_currency, locale),
137
+ roas: neutral(number(month.expected_roas.base)),
138
+ })),
139
+ },
140
+ },
141
+ ];
142
+ }
@@ -2,22 +2,14 @@ import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
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 { detectSliceKeyMismatch, loadDashboardActuals } from "./actuals.ts";
19
- import { projectDashboard, type DashboardBuild } from "./projector.ts";
5
+ import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
20
6
  import { dashboardBuildFingerprint, publishDashboard } from "./publisher.ts";
7
+ import {
8
+ enqueueDashboardRefresh,
9
+ inspectDashboardRefreshQueue,
10
+ processDashboardRefreshQueue,
11
+ } from "./coordinator.ts";
12
+ import { buildDashboardProjection } from "./service.ts";
21
13
  import { inspectDashboard } from "./status.ts";
22
14
 
23
15
  function toolResult(value: Record<string, unknown>) {
@@ -27,68 +19,14 @@ function toolResult(value: Record<string, unknown>) {
27
19
  };
28
20
  }
29
21
 
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
- sliceKeyMismatch: string | null;
45
- }> {
46
- const forecastRead = await readCommittedArtifact(cwd, "approved_cycle_forecast");
47
- if (forecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Committed approved forecast has the wrong artifact type.");
48
- const forecast = forecastRead.artifact as ApprovedCycleForecast;
49
- const committedExecutionRead = await readOptionalCommittedArtifact(cwd, "execution_receipt");
50
- const executionRead = committedExecutionRead?.artifact.artifact_type === "execution_receipt" && isExecutionReceiptForForecast(committedExecutionRead.artifact, forecast)
51
- ? committedExecutionRead
52
- : null;
53
- const execution = executionRead?.artifact.artifact_type === "execution_receipt"
54
- ? executionRead.artifact as ExecutionReceipt
55
- : null;
56
- const actuals = await loadDashboardActuals(
57
- forecast.target_period,
58
- {
59
- slices: forecast.approved_allocation,
60
- },
61
- signal,
62
- );
63
- const build = projectDashboard({
64
- forecast: withoutFingerprint(forecast) as ApprovedCycleForecastInput,
65
- actuals,
66
- ...(execution ? { execution: withoutFingerprint(execution) as ExecutionReceiptInput } : {}),
67
- locale,
68
- });
69
- build.source.forecast_fingerprint = forecastRead.fingerprint;
70
- if (executionRead) build.source.execution_fingerprint = executionRead.fingerprint;
71
-
72
- const plannedKeys = new Set(forecast.approved_allocation.map(sliceKey));
73
- const actualKeys = new Set(actuals.slices.map(sliceKey));
74
- const unplanned = actuals.slices.filter((slice) => !plannedKeys.has(sliceKey(slice)) && ((slice.spend ?? 0) > 0 || (slice.revenue ?? 0) !== 0));
75
- const missing = forecast.approved_allocation.filter((slice) => !actualKeys.has(sliceKey(slice)));
76
- 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.`);
77
- if (missing.length > 0) build.warnings.push(`${missing.length} approved slices have no current-period Actuals row; their values remain null.`);
78
- if (committedExecutionRead && !executionRead) build.warnings.push(`Ignored stale execution receipt for forecast ${committedExecutionRead.artifact.forecast_version}; current forecast is ${forecast.forecast_version}.`);
79
- 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.");
80
- if (actuals.data_as_of === "unavailable") build.warnings.push("No current-period UA Actuals are available.");
81
-
82
- // Every approved slice missing while the window demonstrably holds paid rows is
83
- // not an early cycle — it is a forecast keyed on values ua_spend never uses, and
84
- // the dashboard it produces looks complete while being entirely empty. Publishing
85
- // that is worse than publishing nothing, so it is named here and refused below.
86
- const sliceKeyMismatch = detectSliceKeyMismatch(forecast.approved_allocation, actuals);
87
- if (sliceKeyMismatch) build.warnings.push(sliceKeyMismatch);
88
- return { build, forecast, forecastRead, executionRead, sliceKeyMismatch };
89
- }
90
-
91
22
  export default function fpaDashboardExtension(pi: ExtensionAPI): void {
23
+ const artifactRefSchema = Type.Object({
24
+ scope_id: Type.String({ minLength: 1, maxLength: 256 }),
25
+ cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
26
+ artifact_type: Type.Union([Type.Literal("approved_cycle_forecast"), Type.Literal("execution_receipt")]),
27
+ entry_id: Type.String({ pattern: "^[a-f0-9]{64}$" }),
28
+ body_fingerprint: Type.String({ pattern: "^[a-f0-9]{64}$" }),
29
+ }, { additionalProperties: false });
92
30
  pi.registerTool({
93
31
  name: "fpa_dashboard_status",
94
32
  label: "FP&A Dashboard Status",
@@ -118,13 +56,32 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
118
56
  mode: StringEnum(["preview", "publish"] as const),
119
57
  locale: Type.Optional(StringEnum(["zh-CN", "en-US"] as const)),
120
58
  expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
59
+ scope_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
60
+ cycle_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
61
+ forecast_ref: Type.Optional(artifactRefSchema),
62
+ execution_ref: Type.Optional(artifactRefSchema),
63
+ next_forecast_ref: Type.Optional(artifactRefSchema),
64
+ forward_forecast_refs: Type.Optional(Type.Array(artifactRefSchema, { maxItems: 6 })),
121
65
  },
122
66
  { additionalProperties: false },
123
67
  ),
124
68
  executionMode: "sequential",
125
69
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
126
- const { build, forecast, sliceKeyMismatch } = await buildDashboard(ctx.cwd, params.locale ?? "zh-CN", signal);
127
- const previewFingerprint = dashboardBuildFingerprint(build);
70
+ const exactFields = [params.scope_id, params.cycle_id, params.forecast_ref];
71
+ const hasAnyExactField = exactFields.some((value) => value !== undefined) || params.execution_ref !== undefined || params.next_forecast_ref !== undefined || params.forward_forecast_refs !== undefined;
72
+ if (hasAnyExactField && exactFields.some((value) => value === undefined)) {
73
+ throw new Error("scope_id, cycle_id, and forecast_ref must be supplied together; exact refresh never falls back to current artifacts.");
74
+ }
75
+ const exact = hasAnyExactField ? {
76
+ scopeId: params.scope_id as string,
77
+ cycleId: params.cycle_id as string,
78
+ forecastRef: params.forecast_ref as ArtifactRefV2,
79
+ ...(params.execution_ref ? { executionRef: params.execution_ref as ArtifactRefV2 } : {}),
80
+ ...(params.next_forecast_ref ? { nextForecastRef: params.next_forecast_ref as ArtifactRefV2 } : {}),
81
+ ...(params.forward_forecast_refs ? { forwardForecastRefs: params.forward_forecast_refs as ArtifactRefV2[] } : {}),
82
+ } : undefined;
83
+ const { build, forecast, actuals, sliceKeyMismatch, projector, runtime } = await buildDashboardProjection(ctx.cwd, params.preset, params.locale ?? "zh-CN", exact, signal);
84
+ const previewFingerprint = dashboardBuildFingerprint(build, projector);
128
85
  const summary = {
129
86
  preset: params.preset,
130
87
  preview_fingerprint: previewFingerprint,
@@ -136,6 +93,19 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
136
93
  widgets: build.widgets.map((widget) => ({ id: widget.id, type: widget.type, dataset: widget.dataset })),
137
94
  warnings: build.warnings,
138
95
  query_receipts: build.source.query_receipts,
96
+ projector,
97
+ runtime,
98
+ ...(build.operating_projection ? { operating_projection: build.operating_projection } : {}),
99
+ ...(build.forward_outlook ? { forward_outlook: build.forward_outlook } : {}),
100
+ ...(build.forecast_accuracy ? { forecast_accuracy: build.forecast_accuracy } : {}),
101
+ ...(exact ? {
102
+ scope_id: exact.scopeId,
103
+ cycle_id: exact.cycleId,
104
+ forecast_ref: exact.forecastRef,
105
+ ...(exact.executionRef ? { execution_ref: exact.executionRef } : {}),
106
+ ...(exact.nextForecastRef ? { next_forecast_ref: exact.nextForecastRef } : {}),
107
+ ...(exact.forwardForecastRefs ? { forward_forecast_refs: exact.forwardForecastRefs } : {}),
108
+ } : {}),
139
109
  };
140
110
  if (params.mode === "preview") {
141
111
  return toolResult({
@@ -153,8 +123,47 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
153
123
  if (params.expected_preview_fingerprint !== previewFingerprint) {
154
124
  throw new Error("Dashboard inputs changed after preview; run preview again before publishing.");
155
125
  }
156
- const published = await publishDashboard({ cwd: ctx.cwd, build });
126
+ const published = await publishDashboard({ cwd: ctx.cwd, build, projector, runtime, actualsSnapshot: actuals });
157
127
  return toolResult({ status: "published", ...summary, dashboard_dir: published.dashboardDir, published_datasets: published.publishedDatasets });
158
128
  },
159
129
  });
130
+
131
+ pi.registerTool({
132
+ name: "fpa_dashboard_refresh_queue",
133
+ label: "FP&A Dashboard Refresh Queue",
134
+ description: "Inspect or drain the durable dashboard refresh queue, or enqueue an Actuals-watermark refresh against exact immutable artifact refs.",
135
+ promptSnippet: "Inspect or process durable FP&A dashboard refresh work",
136
+ parameters: Type.Object({
137
+ action: StringEnum(["status", "drain", "enqueue_actuals"] as const),
138
+ locale: Type.Optional(StringEnum(["zh-CN", "en-US"] as const)),
139
+ scope_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
140
+ cycle_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
141
+ forecast_ref: Type.Optional(artifactRefSchema),
142
+ execution_ref: Type.Optional(artifactRefSchema),
143
+ next_forecast_ref: Type.Optional(artifactRefSchema),
144
+ forward_forecast_refs: Type.Optional(Type.Array(artifactRefSchema, { maxItems: 6 })),
145
+ actuals_watermark: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
146
+ limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })),
147
+ }, { additionalProperties: false }),
148
+ executionMode: "sequential",
149
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
150
+ if (params.action === "status") return toolResult(await inspectDashboardRefreshQueue(ctx.cwd) as unknown as Record<string, unknown>);
151
+ if (params.action === "drain") return toolResult(await processDashboardRefreshQueue(ctx.cwd, { limit: params.limit, signal }) as unknown as Record<string, unknown>);
152
+ if (!params.scope_id || !params.cycle_id || !params.forecast_ref || !params.actuals_watermark) {
153
+ throw new Error("enqueue_actuals requires scope_id, cycle_id, forecast_ref, and actuals_watermark.");
154
+ }
155
+ return toolResult(await enqueueDashboardRefresh(ctx.cwd, {
156
+ preset: "forecast-closed-loop-v1",
157
+ locale: params.locale ?? "zh-CN",
158
+ scope_id: params.scope_id,
159
+ cycle_id: params.cycle_id,
160
+ forecast_ref: params.forecast_ref as ArtifactRefV2,
161
+ ...(params.execution_ref ? { execution_ref: params.execution_ref as ArtifactRefV2 } : {}),
162
+ ...(params.next_forecast_ref ? { next_forecast_ref: params.next_forecast_ref as ArtifactRefV2 } : {}),
163
+ ...(params.forward_forecast_refs ? { forward_forecast_refs: params.forward_forecast_refs as ArtifactRefV2[] } : {}),
164
+ actuals_watermark: params.actuals_watermark,
165
+ reason: "actuals_watermark",
166
+ }) as unknown as Record<string, unknown>);
167
+ },
168
+ });
160
169
  }
@@ -8,6 +8,9 @@ import {
8
8
  type ForecastSlice,
9
9
  } from "../fpa-artifacts/contracts.ts";
10
10
  import { validateActualsSnapshot, ACTUALS_DATA_AS_OF_UNAVAILABLE, type DashboardActualsSnapshot, type DashboardActualSlice } from "./source.ts";
11
+ import type { CycleOperatingProjection } from "./cycle-operating-projection.ts";
12
+ import type { ForwardOutlookProjection } from "./forward-outlook.ts";
13
+ import type { ForecastAccuracyProjection } from "./forecast-accuracy.ts";
11
14
 
12
15
  export type Tone = "positive" | "negative" | "warning" | "neutral";
13
16
  export type TableCell = string | null | { value: string | null; tone?: Tone };
@@ -58,14 +61,30 @@ export interface DashboardBuild {
58
61
  title: string;
59
62
  widgets: DashboardWidget[];
60
63
  source: {
64
+ project_name?: string;
65
+ scope_id?: string;
66
+ cycle_id?: string;
67
+ forecast_ref?: string;
68
+ next_forecast_ref?: string;
69
+ execution_ref?: string;
70
+ forecast_role?: "original" | "eac" | "next_plan";
61
71
  forecast_version: string;
62
72
  forecast_fingerprint?: string;
73
+ next_forecast_fingerprint?: string;
74
+ forward_forecast_refs?: string[];
75
+ forward_forecast_fingerprints?: string[];
63
76
  execution_fingerprint?: string;
64
77
  data_as_of: string;
78
+ actuals_watermark?: string;
79
+ actuals_snapshot_ref?: string;
80
+ actuals_snapshot_evidence?: DashboardActualsSnapshot["snapshot_evidence"];
65
81
  actuals_scope: { slice_keys: string[] };
66
82
  query_receipts: DashboardActualsSnapshot["query_receipts"];
67
83
  };
68
84
  warnings: string[];
85
+ operating_projection?: CycleOperatingProjection;
86
+ forward_outlook?: ForwardOutlookProjection;
87
+ forecast_accuracy?: ForecastAccuracyProjection;
69
88
  }
70
89
 
71
90
  interface ProjectionInput {