@viccydev/pi-fpa 0.7.2 → 0.8.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 +2 -2
- package/extensions/fpa-dashboard/compat-publisher.ts +45 -0
- package/extensions/fpa-dashboard/coordinator.ts +3 -2
- package/extensions/fpa-dashboard/index.ts +228 -3
- package/extensions/fpa-dashboard/module-publisher.ts +390 -0
- package/extensions/fpa-dashboard/stage-projector.ts +241 -0
- package/extensions/fpa-dashboard/strategy-decision.ts +192 -0
- package/extensions/fpa-routing-guard/index.ts +39 -5
- package/graphs/fpa-forecast-freeze.json +11 -0
- package/graphs/fpa-period-analysis.json +10 -0
- package/graphs/fpa-strategy-recommendation.json +11 -0
- package/package.json +4 -3
- package/prompts/fpa-plan-cycle.md +19 -6
- package/skills/fpa-apply-core-rules/SKILL.md +7 -4
- package/skills/fpa-apply-core-rules/references/core-rules.md +21 -8
- package/skills/fpa-forecast-approved-strategy/SKILL.md +11 -8
- package/skills/fpa-refresh-dashboard/SKILL.md +44 -4
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { link, lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
6
|
+
import type { DashboardWidget } from "./projector.ts";
|
|
7
|
+
import { validateDatasetForWidget } from "./schema.ts";
|
|
8
|
+
|
|
9
|
+
export type DashboardModuleId = "period-review" | "next-strategy" | "next-forecast" | "execution-evidence";
|
|
10
|
+
export type DashboardModuleStatus = "published" | "awaiting_decision" | "approved" | "changes_requested" | "generating" | "superseded";
|
|
11
|
+
|
|
12
|
+
export interface DashboardModuleInteraction {
|
|
13
|
+
kind: "strategy-decision";
|
|
14
|
+
action_id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DashboardModuleBuild {
|
|
18
|
+
id: DashboardModuleId;
|
|
19
|
+
title: string;
|
|
20
|
+
status: DashboardModuleStatus;
|
|
21
|
+
source: Record<string, unknown>;
|
|
22
|
+
widgets: DashboardWidget[];
|
|
23
|
+
interaction?: DashboardModuleInteraction;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PublishDashboardModuleOptions {
|
|
27
|
+
cwd: string;
|
|
28
|
+
module: DashboardModuleBuild;
|
|
29
|
+
dashboardTitle?: string;
|
|
30
|
+
expectedDashboardRevision?: string | null;
|
|
31
|
+
publishedAt?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PublishDashboardModuleResult {
|
|
35
|
+
dashboardDir: string;
|
|
36
|
+
dashboardRevision: string;
|
|
37
|
+
moduleRevision: string;
|
|
38
|
+
publishedDatasets: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TransitionStrategyModuleResult {
|
|
42
|
+
dashboardRevision: string;
|
|
43
|
+
moduleRevision: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface DashboardModuleManifestEntry {
|
|
47
|
+
id: DashboardModuleId;
|
|
48
|
+
title: string;
|
|
49
|
+
status: DashboardModuleStatus;
|
|
50
|
+
updatedAt: string;
|
|
51
|
+
revision: string;
|
|
52
|
+
buildReceipt: string;
|
|
53
|
+
widgets: Array<{ id: string; type: DashboardWidget["type"]; span: DashboardWidget["span"]; dataset: string }>;
|
|
54
|
+
interaction?: DashboardModuleInteraction;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DashboardManifestV2 {
|
|
58
|
+
kind: "fpa.dashboard";
|
|
59
|
+
schemaVersion: 2;
|
|
60
|
+
title: string;
|
|
61
|
+
updatedAt: string;
|
|
62
|
+
dashboardRevision: string;
|
|
63
|
+
moduleOrder: DashboardModuleId[];
|
|
64
|
+
modules: Partial<Record<DashboardModuleId, DashboardModuleManifestEntry>>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const MODULE_IDS: DashboardModuleId[] = ["period-review", "next-strategy", "next-forecast", "execution-evidence"];
|
|
68
|
+
const MODULE_ID_SET = new Set<string>(MODULE_IDS);
|
|
69
|
+
const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,191}\.json$/;
|
|
70
|
+
|
|
71
|
+
function sha256(value: string): string {
|
|
72
|
+
return createHash("sha256").update(value).digest("hex");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function pathKind(path: string): Promise<"missing" | "directory" | "symlink" | "other"> {
|
|
76
|
+
try {
|
|
77
|
+
const stat = await lstat(path);
|
|
78
|
+
if (stat.isSymbolicLink()) return "symlink";
|
|
79
|
+
if (stat.isDirectory()) return "directory";
|
|
80
|
+
return "other";
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function ensureOwnedDirectory(path: string, label: string): Promise<void> {
|
|
88
|
+
const kind = await pathKind(path);
|
|
89
|
+
if (kind === "symlink") throw new Error(`${label} must not be a symlink.`);
|
|
90
|
+
if (kind === "other") throw new Error(`${label} must be a directory.`);
|
|
91
|
+
if (kind === "missing") await mkdir(path, { recursive: true, mode: 0o700 });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function atomicWrite(destination: string, contents: string): Promise<void> {
|
|
95
|
+
const temporary = join(dirname(destination), `.${basename(destination)}.${randomUUID()}.tmp`);
|
|
96
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
97
|
+
let closed = false;
|
|
98
|
+
try {
|
|
99
|
+
await handle.writeFile(contents, "utf8");
|
|
100
|
+
await handle.sync();
|
|
101
|
+
await handle.close();
|
|
102
|
+
closed = true;
|
|
103
|
+
await rename(temporary, destination);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!closed) await handle.close().catch(() => undefined);
|
|
106
|
+
await unlink(temporary).catch(() => undefined);
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function appendOnlyWrite(directory: string, destination: string, contents: string): Promise<void> {
|
|
112
|
+
const temporary = join(directory, `.${basename(destination)}.${randomUUID()}.tmp`);
|
|
113
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
114
|
+
try {
|
|
115
|
+
await handle.writeFile(contents, "utf8");
|
|
116
|
+
await handle.sync();
|
|
117
|
+
} finally {
|
|
118
|
+
await handle.close();
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await link(temporary, destination);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
124
|
+
if (await readFile(destination, "utf8") !== contents) throw new Error(`Append-only dashboard object ${basename(destination)} already exists with different content.`);
|
|
125
|
+
} finally {
|
|
126
|
+
await unlink(temporary).catch(() => undefined);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function resolveDashboardDir(cwd: string): Promise<string> {
|
|
131
|
+
const projectRoot = await realpath(cwd);
|
|
132
|
+
return join(dirname(projectRoot), ".fpa-dashboard");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function assertModule(module: DashboardModuleBuild): void {
|
|
136
|
+
if (!MODULE_ID_SET.has(module.id)) throw new Error(`Unsupported dashboard module id "${module.id}".`);
|
|
137
|
+
if (!module.title?.trim()) throw new Error("Dashboard module title is required.");
|
|
138
|
+
if (!Array.isArray(module.widgets) || module.widgets.length === 0 || module.widgets.length > 64) throw new Error("Dashboard module must contain between 1 and 64 widgets.");
|
|
139
|
+
if (module.interaction && (module.id !== "next-strategy" || module.status !== "awaiting_decision")) {
|
|
140
|
+
throw new Error("Strategy decisions are available only on an awaiting next-strategy module.");
|
|
141
|
+
}
|
|
142
|
+
const widgetIds = new Set<string>();
|
|
143
|
+
for (const widget of module.widgets) {
|
|
144
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(widget.id) || widgetIds.has(widget.id)) throw new Error(`Invalid or duplicate dashboard widget id "${widget.id}".`);
|
|
145
|
+
widgetIds.add(widget.id);
|
|
146
|
+
if (!DATASET_FILE_RE.test(widget.dataset)) throw new Error(`Invalid logical dataset name "${widget.dataset}".`);
|
|
147
|
+
validateDatasetForWidget(widget.type, widget.data);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function dashboardModuleBuildFingerprint(module: DashboardModuleBuild): string {
|
|
152
|
+
assertModule(module);
|
|
153
|
+
return sha256(stableJson(module));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function dashboardRevision(title: string, moduleOrder: DashboardModuleId[], modules: DashboardManifestV2["modules"]): string {
|
|
157
|
+
return sha256(stableJson({ title, moduleOrder, modules }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function readManifest(path: string): Promise<DashboardManifestV2 | null> {
|
|
161
|
+
try {
|
|
162
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
|
|
163
|
+
if (parsed.kind !== "fpa.dashboard") throw new Error("Existing dashboard manifest kind is invalid.");
|
|
164
|
+
if (parsed.schemaVersion === 1) {
|
|
165
|
+
if (!Array.isArray(parsed.widgets) || typeof parsed.generationId !== "string" || typeof parsed.buildReceipt !== "string") {
|
|
166
|
+
throw new Error("Legacy dashboard manifest is invalid.");
|
|
167
|
+
}
|
|
168
|
+
const updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date(0).toISOString();
|
|
169
|
+
const legacyEntry: DashboardModuleManifestEntry = {
|
|
170
|
+
id: "execution-evidence",
|
|
171
|
+
title: "历史经营闭环",
|
|
172
|
+
status: "published",
|
|
173
|
+
updatedAt,
|
|
174
|
+
revision: parsed.generationId,
|
|
175
|
+
buildReceipt: parsed.buildReceipt,
|
|
176
|
+
widgets: parsed.widgets as DashboardModuleManifestEntry["widgets"],
|
|
177
|
+
};
|
|
178
|
+
const title = typeof parsed.title === "string" ? parsed.title : "FP&A Dashboard";
|
|
179
|
+
const modules = { "execution-evidence": legacyEntry };
|
|
180
|
+
const moduleOrder: DashboardModuleId[] = ["execution-evidence"];
|
|
181
|
+
return {
|
|
182
|
+
kind: "fpa.dashboard",
|
|
183
|
+
schemaVersion: 2,
|
|
184
|
+
title,
|
|
185
|
+
updatedAt,
|
|
186
|
+
dashboardRevision: dashboardRevision(title, moduleOrder, modules),
|
|
187
|
+
moduleOrder,
|
|
188
|
+
modules,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (parsed.schemaVersion !== 2 || !parsed.modules || !Array.isArray(parsed.moduleOrder)) throw new Error("Dashboard manifest schema is unsupported.");
|
|
192
|
+
return parsed as unknown as DashboardManifestV2;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function normalizeLegacyModuleReceipt(
|
|
200
|
+
dashboardDir: string,
|
|
201
|
+
modulesDir: string,
|
|
202
|
+
current: DashboardManifestV2 | null,
|
|
203
|
+
): Promise<DashboardManifestV2 | null> {
|
|
204
|
+
const legacy = current?.modules["execution-evidence"];
|
|
205
|
+
if (!current || !legacy || legacy.buildReceipt.startsWith("modules/")) return current;
|
|
206
|
+
if (!DATASET_FILE_RE.test(legacy.buildReceipt)) throw new Error("Legacy dashboard build receipt path is invalid.");
|
|
207
|
+
const sourcePath = join(dashboardDir, legacy.buildReceipt);
|
|
208
|
+
const stat = await lstat(sourcePath);
|
|
209
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Legacy dashboard build receipt must be a regular file.");
|
|
210
|
+
const old = JSON.parse(await readFile(sourcePath, "utf8")) as { source?: unknown; warnings?: unknown; datasets?: unknown };
|
|
211
|
+
if (!Array.isArray(old.datasets)) throw new Error("Legacy dashboard build receipt datasets are invalid.");
|
|
212
|
+
const receipt = {
|
|
213
|
+
kind: "fpa.dashboard.module.build",
|
|
214
|
+
schema_version: 1,
|
|
215
|
+
module_id: "execution-evidence",
|
|
216
|
+
module_revision: legacy.revision,
|
|
217
|
+
published_at: legacy.updatedAt,
|
|
218
|
+
status: legacy.status,
|
|
219
|
+
source: { ...(old.source && typeof old.source === "object" ? old.source : {}), warnings: old.warnings ?? [], legacy_generation_id: legacy.revision },
|
|
220
|
+
datasets: old.datasets,
|
|
221
|
+
};
|
|
222
|
+
const contents = `${JSON.stringify(receipt, null, 2)}\n`;
|
|
223
|
+
const filename = `modules/execution-evidence.${legacy.revision}.${sha256(stableJson(receipt))}.json`;
|
|
224
|
+
await appendOnlyWrite(modulesDir, join(dashboardDir, filename), contents);
|
|
225
|
+
return { ...current, modules: { ...current.modules, "execution-evidence": { ...legacy, buildReceipt: filename } } };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function readDashboardModuleManifest(cwd: string): Promise<DashboardManifestV2 | null> {
|
|
229
|
+
return readManifest(join(await resolveDashboardDir(cwd), "manifest.json"));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function readDashboardModuleSource(cwd: string, moduleId: DashboardModuleId): Promise<Record<string, unknown> | null> {
|
|
233
|
+
const dashboardDir = await resolveDashboardDir(cwd);
|
|
234
|
+
const entry = (await readManifest(join(dashboardDir, "manifest.json")))?.modules[moduleId];
|
|
235
|
+
if (!entry) return null;
|
|
236
|
+
if (!entry.buildReceipt.startsWith("modules/") || !DATASET_FILE_RE.test(basename(entry.buildReceipt))) throw new Error("Dashboard module build receipt path is invalid.");
|
|
237
|
+
const path = join(dashboardDir, entry.buildReceipt);
|
|
238
|
+
const stat = await lstat(path);
|
|
239
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Dashboard module build receipt must be a regular file.");
|
|
240
|
+
const receipt = JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
|
|
241
|
+
if (receipt.kind !== "fpa.dashboard.module.build" || receipt.module_id !== moduleId || receipt.module_revision !== entry.revision || receipt.status !== entry.status) {
|
|
242
|
+
throw new Error("Dashboard module build receipt does not match its manifest entry.");
|
|
243
|
+
}
|
|
244
|
+
return receipt.source && typeof receipt.source === "object" ? receipt.source as Record<string, unknown> : {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function publishDashboardModule(options: PublishDashboardModuleOptions): Promise<PublishDashboardModuleResult> {
|
|
248
|
+
assertModule(options.module);
|
|
249
|
+
const dashboardDir = await resolveDashboardDir(options.cwd);
|
|
250
|
+
await ensureOwnedDirectory(dashboardDir, "Dashboard directory");
|
|
251
|
+
const datasetsDir = join(dashboardDir, "datasets");
|
|
252
|
+
await ensureOwnedDirectory(datasetsDir, "Dashboard datasets directory");
|
|
253
|
+
const modulesDir = join(dashboardDir, "modules");
|
|
254
|
+
await ensureOwnedDirectory(modulesDir, "Dashboard modules directory");
|
|
255
|
+
|
|
256
|
+
const lockPath = join(dashboardDir, ".module-publish.lock");
|
|
257
|
+
let lock;
|
|
258
|
+
try {
|
|
259
|
+
lock = await open(lockPath, "wx", 0o600);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("Another dashboard module publication is in progress; retry after reading the current dashboard revision.");
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const manifestPath = join(dashboardDir, "manifest.json");
|
|
267
|
+
const current = await normalizeLegacyModuleReceipt(dashboardDir, modulesDir, await readManifest(manifestPath));
|
|
268
|
+
const currentRevision = current?.dashboardRevision;
|
|
269
|
+
if (options.expectedDashboardRevision !== undefined && (options.expectedDashboardRevision ?? undefined) !== currentRevision) {
|
|
270
|
+
throw new Error(`Dashboard revision changed: expected ${options.expectedDashboardRevision}, current ${currentRevision ?? "none"}.`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const publishedAt = options.publishedAt ?? new Date().toISOString();
|
|
274
|
+
const revision = dashboardModuleBuildFingerprint(options.module);
|
|
275
|
+
const datasets = options.module.widgets.map((widget) => {
|
|
276
|
+
const contents = `${JSON.stringify(widget.data, null, 2)}\n`;
|
|
277
|
+
const digest = sha256(stableJson(widget.data));
|
|
278
|
+
const logicalBase = widget.dataset.slice(0, -".json".length);
|
|
279
|
+
const filename = `${options.module.id}.${logicalBase}.${digest}.json`;
|
|
280
|
+
if (!DATASET_FILE_RE.test(filename)) throw new Error(`Content-addressed dataset name "${filename}" exceeds the dashboard contract.`);
|
|
281
|
+
return { widget, filename, digest, contents };
|
|
282
|
+
});
|
|
283
|
+
for (const dataset of datasets) await appendOnlyWrite(datasetsDir, join(datasetsDir, dataset.filename), dataset.contents);
|
|
284
|
+
|
|
285
|
+
const receipt = {
|
|
286
|
+
kind: "fpa.dashboard.module.build",
|
|
287
|
+
schema_version: 1,
|
|
288
|
+
module_id: options.module.id,
|
|
289
|
+
module_revision: revision,
|
|
290
|
+
published_at: publishedAt,
|
|
291
|
+
status: options.module.status,
|
|
292
|
+
source: options.module.source,
|
|
293
|
+
datasets: datasets.map(({ widget, filename, digest }) => ({ logical: widget.dataset, filename, sha256: digest })),
|
|
294
|
+
};
|
|
295
|
+
const receiptContents = `${JSON.stringify(receipt, null, 2)}\n`;
|
|
296
|
+
const receiptDigest = sha256(stableJson(receipt));
|
|
297
|
+
const receiptFilename = `modules/${options.module.id}.${revision}.${receiptDigest}.json`;
|
|
298
|
+
await appendOnlyWrite(modulesDir, join(dashboardDir, receiptFilename), receiptContents);
|
|
299
|
+
|
|
300
|
+
const entry: DashboardModuleManifestEntry = {
|
|
301
|
+
id: options.module.id,
|
|
302
|
+
title: options.module.title,
|
|
303
|
+
status: options.module.status,
|
|
304
|
+
updatedAt: publishedAt,
|
|
305
|
+
revision,
|
|
306
|
+
buildReceipt: receiptFilename,
|
|
307
|
+
widgets: datasets.map(({ widget, filename }) => ({ id: widget.id, type: widget.type, span: widget.span, dataset: filename })),
|
|
308
|
+
...(options.module.interaction ? { interaction: options.module.interaction } : {}),
|
|
309
|
+
};
|
|
310
|
+
const title = options.dashboardTitle ?? current?.title ?? "FP&A Dashboard";
|
|
311
|
+
const modules = { ...(current?.modules ?? {}), [options.module.id]: entry };
|
|
312
|
+
const moduleOrder = current?.moduleOrder.includes(options.module.id)
|
|
313
|
+
? current.moduleOrder
|
|
314
|
+
: [...(current?.moduleOrder ?? []), options.module.id];
|
|
315
|
+
const nextRevision = dashboardRevision(title, moduleOrder, modules);
|
|
316
|
+
const nextManifest: DashboardManifestV2 = {
|
|
317
|
+
kind: "fpa.dashboard",
|
|
318
|
+
schemaVersion: 2,
|
|
319
|
+
title,
|
|
320
|
+
updatedAt: publishedAt,
|
|
321
|
+
dashboardRevision: nextRevision,
|
|
322
|
+
moduleOrder,
|
|
323
|
+
modules,
|
|
324
|
+
};
|
|
325
|
+
await atomicWrite(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`);
|
|
326
|
+
return { dashboardDir, dashboardRevision: nextRevision, moduleRevision: revision, publishedDatasets: datasets.length };
|
|
327
|
+
} finally {
|
|
328
|
+
await lock.close().catch(() => undefined);
|
|
329
|
+
await unlink(lockPath).catch(() => undefined);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function transitionStrategyModule(
|
|
334
|
+
cwd: string,
|
|
335
|
+
input: { actionId: string; status: "approved" | "changes_requested"; transitionedAt?: string },
|
|
336
|
+
): Promise<TransitionStrategyModuleResult> {
|
|
337
|
+
const dashboardDir = await resolveDashboardDir(cwd);
|
|
338
|
+
const lockPath = join(dashboardDir, ".module-publish.lock");
|
|
339
|
+
let lock;
|
|
340
|
+
try {
|
|
341
|
+
lock = await open(lockPath, "wx", 0o600);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("Another dashboard module publication is in progress; retry the strategy decision transition.");
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const manifestPath = join(dashboardDir, "manifest.json");
|
|
348
|
+
const current = await readManifest(manifestPath);
|
|
349
|
+
if (!current) throw new Error("Dashboard manifest does not exist.");
|
|
350
|
+
const strategy = current.modules["next-strategy"];
|
|
351
|
+
if (!strategy || strategy.interaction?.kind !== "strategy-decision" || strategy.interaction.action_id !== input.actionId) {
|
|
352
|
+
if (strategy?.status === input.status && !strategy.interaction) return { dashboardRevision: current.dashboardRevision, moduleRevision: strategy.revision };
|
|
353
|
+
throw new Error("Dashboard strategy module does not match this decision action.");
|
|
354
|
+
}
|
|
355
|
+
const transitionedAt = input.transitionedAt ?? new Date().toISOString();
|
|
356
|
+
const revision = sha256(stableJson({ previous_revision: strategy.revision, status: input.status }));
|
|
357
|
+
if (!strategy.buildReceipt.startsWith("modules/") || !DATASET_FILE_RE.test(basename(strategy.buildReceipt))) throw new Error("Strategy module build receipt path is invalid.");
|
|
358
|
+
const oldReceiptPath = join(dashboardDir, strategy.buildReceipt);
|
|
359
|
+
const oldReceiptStat = await lstat(oldReceiptPath);
|
|
360
|
+
if (oldReceiptStat.isSymbolicLink() || !oldReceiptStat.isFile()) throw new Error("Strategy module build receipt must be a regular file.");
|
|
361
|
+
const oldReceipt = JSON.parse(await readFile(oldReceiptPath, "utf8")) as Record<string, unknown>;
|
|
362
|
+
if (oldReceipt.kind !== "fpa.dashboard.module.build" || oldReceipt.module_id !== "next-strategy") throw new Error("Strategy module build receipt is invalid.");
|
|
363
|
+
const transitionedReceipt = { ...oldReceipt, module_revision: revision, published_at: transitionedAt, status: input.status };
|
|
364
|
+
const receiptContents = `${JSON.stringify(transitionedReceipt, null, 2)}\n`;
|
|
365
|
+
const receiptFilename = `modules/next-strategy.${revision}.${sha256(stableJson(transitionedReceipt))}.json`;
|
|
366
|
+
const modulesDir = join(dashboardDir, "modules");
|
|
367
|
+
await appendOnlyWrite(modulesDir, join(dashboardDir, receiptFilename), receiptContents);
|
|
368
|
+
const nextStrategy: DashboardModuleManifestEntry = {
|
|
369
|
+
...strategy,
|
|
370
|
+
status: input.status,
|
|
371
|
+
updatedAt: transitionedAt,
|
|
372
|
+
revision,
|
|
373
|
+
buildReceipt: receiptFilename,
|
|
374
|
+
};
|
|
375
|
+
delete nextStrategy.interaction;
|
|
376
|
+
const modules = { ...current.modules, "next-strategy": nextStrategy };
|
|
377
|
+
const nextDashboardRevision = dashboardRevision(current.title, current.moduleOrder, modules);
|
|
378
|
+
const next: DashboardManifestV2 = {
|
|
379
|
+
...current,
|
|
380
|
+
updatedAt: transitionedAt,
|
|
381
|
+
dashboardRevision: nextDashboardRevision,
|
|
382
|
+
modules,
|
|
383
|
+
};
|
|
384
|
+
await atomicWrite(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
385
|
+
return { dashboardRevision: nextDashboardRevision, moduleRevision: revision };
|
|
386
|
+
} finally {
|
|
387
|
+
await lock.close().catch(() => undefined);
|
|
388
|
+
await unlink(lockPath).catch(() => undefined);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
4
|
+
import type { DashboardModuleBuild } from "./module-publisher.ts";
|
|
5
|
+
import type { TableCell } from "./projector.ts";
|
|
6
|
+
import type { ApprovedCycleForecast } from "../fpa-artifacts/contracts.ts";
|
|
7
|
+
import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
|
|
8
|
+
|
|
9
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
11
|
+
return value as Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function requiredString(value: unknown, label: string): string {
|
|
15
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string.`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function array(value: unknown, label: string): unknown[] {
|
|
20
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fingerprint(value: unknown): string {
|
|
25
|
+
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cell(value: unknown): TableCell {
|
|
29
|
+
if (value === null || value === undefined) return null;
|
|
30
|
+
if (typeof value === "string") return value;
|
|
31
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
32
|
+
return JSON.stringify(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lines(value: unknown): string {
|
|
36
|
+
if (!Array.isArray(value) || value.length === 0) return "—";
|
|
37
|
+
return value.map((item) => typeof item === "string" ? item : JSON.stringify(item)).join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function projectReviewModule(input: unknown): DashboardModuleBuild {
|
|
41
|
+
const analysis = record(input, "driver_analysis");
|
|
42
|
+
if (analysis.artifact_type !== "driver_analysis") throw new Error("Review publication requires artifact_type=driver_analysis.");
|
|
43
|
+
const headline = record(analysis.headline_results ?? {}, "driver_analysis.headline_results");
|
|
44
|
+
const drivers = array(analysis.drivers ?? [], "driver_analysis.drivers");
|
|
45
|
+
return {
|
|
46
|
+
id: "period-review",
|
|
47
|
+
title: "上周期复盘",
|
|
48
|
+
status: "published",
|
|
49
|
+
source: {
|
|
50
|
+
artifact_type: "driver_analysis",
|
|
51
|
+
artifact_fingerprint: fingerprint(analysis),
|
|
52
|
+
...(typeof analysis.analysis_version === "string" ? { analysis_version: analysis.analysis_version } : {}),
|
|
53
|
+
...(typeof analysis.status === "string" ? { artifact_status: analysis.status } : {}),
|
|
54
|
+
},
|
|
55
|
+
widgets: [
|
|
56
|
+
{
|
|
57
|
+
id: "review-headline",
|
|
58
|
+
type: "table",
|
|
59
|
+
span: "half",
|
|
60
|
+
dataset: "review-headline.json",
|
|
61
|
+
data: {
|
|
62
|
+
label: "核心结果",
|
|
63
|
+
columns: [{ key: "metric", label: "指标" }, { key: "value", label: "结果", align: "right" }],
|
|
64
|
+
rows: Object.entries(headline).map(([metric, value]) => ({ metric, value: cell(value) })),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "review-drivers",
|
|
69
|
+
type: "table",
|
|
70
|
+
span: "half",
|
|
71
|
+
dataset: "review-drivers.json",
|
|
72
|
+
data: {
|
|
73
|
+
label: "关键驱动",
|
|
74
|
+
columns: [{ key: "driver", label: "驱动" }, { key: "impact", label: "影响" }, { key: "evidence", label: "证据" }],
|
|
75
|
+
rows: drivers.map((item, index) => {
|
|
76
|
+
const driver = record(item, `driver_analysis.drivers[${index}]`);
|
|
77
|
+
return { driver: cell(driver.driver ?? driver.name ?? `Driver ${index + 1}`), impact: cell(driver.impact ?? driver.effect), evidence: cell(driver.evidence ?? driver.evidence_ids) };
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "review-limitations",
|
|
83
|
+
type: "table",
|
|
84
|
+
span: "full",
|
|
85
|
+
dataset: "review-limitations.json",
|
|
86
|
+
data: {
|
|
87
|
+
label: "限制与风险",
|
|
88
|
+
columns: [{ key: "item", label: "说明" }],
|
|
89
|
+
rows: array(analysis.limitations ?? analysis.risks ?? [], "driver_analysis.limitations").map((item) => ({ item: cell(item) })),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function projectStrategyModule(proposalInput: unknown, handoffInput: unknown): DashboardModuleBuild {
|
|
97
|
+
const proposal = record(proposalInput, "strategy_proposal");
|
|
98
|
+
const handoff = record(handoffInput, "reviewed_strategy_handoff");
|
|
99
|
+
if (proposal.artifact_type !== "strategy_proposal") throw new Error("Strategy publication requires artifact_type=strategy_proposal.");
|
|
100
|
+
if (handoff.kind !== "fpa.reviewed-strategy-handoff") throw new Error("Strategy publication requires kind=fpa.reviewed-strategy-handoff.");
|
|
101
|
+
const strategyVersion = requiredString(proposal.strategy_version, "strategy_proposal.strategy_version");
|
|
102
|
+
if (handoff.strategy_version !== strategyVersion || handoff.reviewed_strategy_version !== strategyVersion) {
|
|
103
|
+
throw new Error("Reviewed handoff version does not match the strategy proposal version.");
|
|
104
|
+
}
|
|
105
|
+
if (handoff.status !== "ready") throw new Error("Reviewed strategy handoff is not ready for a human decision.");
|
|
106
|
+
const allocations = array(proposal.allocation, "strategy_proposal.allocation");
|
|
107
|
+
const outcomes = record(proposal.expected_outcomes, "strategy_proposal.expected_outcomes");
|
|
108
|
+
const base = record(outcomes.base ?? {}, "strategy_proposal.expected_outcomes.base");
|
|
109
|
+
const totalSpend = allocations.reduce((sum, item, index) => {
|
|
110
|
+
const allocation = record(item, `strategy_proposal.allocation[${index}]`);
|
|
111
|
+
if (typeof allocation.spend !== "number" || !Number.isFinite(allocation.spend)) throw new Error(`strategy_proposal.allocation[${index}].spend must be a finite number.`);
|
|
112
|
+
return sum + allocation.spend;
|
|
113
|
+
}, 0);
|
|
114
|
+
return {
|
|
115
|
+
id: "next-strategy",
|
|
116
|
+
title: "下周期执行策略",
|
|
117
|
+
status: "awaiting_decision",
|
|
118
|
+
source: {
|
|
119
|
+
artifact_type: "reviewed_strategy",
|
|
120
|
+
artifact_fingerprint: fingerprint({ proposal, handoff }),
|
|
121
|
+
proposal_fingerprint: fingerprint(proposal),
|
|
122
|
+
handoff_fingerprint: fingerprint(handoff),
|
|
123
|
+
strategy_version: strategyVersion,
|
|
124
|
+
review_opinion: handoff.review_opinion,
|
|
125
|
+
},
|
|
126
|
+
widgets: [
|
|
127
|
+
{
|
|
128
|
+
id: "strategy-summary",
|
|
129
|
+
type: "table",
|
|
130
|
+
span: "full",
|
|
131
|
+
dataset: "strategy-summary.json",
|
|
132
|
+
data: {
|
|
133
|
+
label: "策略摘要",
|
|
134
|
+
columns: [{ key: "metric", label: "项目" }, { key: "value", label: "值" }],
|
|
135
|
+
rows: [
|
|
136
|
+
{ metric: "策略版本", value: strategyVersion },
|
|
137
|
+
{ metric: "总预算", value: totalSpend.toFixed(2) },
|
|
138
|
+
{ metric: "Base 收入", value: cell(base.revenue) },
|
|
139
|
+
{ metric: "Base ROAS", value: cell(base.roas) },
|
|
140
|
+
{ metric: "决策理由", value: lines(proposal.decision_rationale) },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "strategy-allocation",
|
|
146
|
+
type: "table",
|
|
147
|
+
span: "full",
|
|
148
|
+
dataset: "strategy-allocation.json",
|
|
149
|
+
data: {
|
|
150
|
+
label: "预算分配",
|
|
151
|
+
columns: [
|
|
152
|
+
{ key: "app", label: "App" }, { key: "store", label: "商店" }, { key: "channel", label: "渠道" },
|
|
153
|
+
{ key: "spend", label: "预算", align: "right" }, { key: "change", label: "较基线变化", align: "right" },
|
|
154
|
+
],
|
|
155
|
+
rows: allocations.map((item, index) => {
|
|
156
|
+
const allocation = record(item, `strategy_proposal.allocation[${index}]`);
|
|
157
|
+
return {
|
|
158
|
+
app: cell(allocation.app_id), store: cell(allocation.store), channel: cell(allocation.channel_group),
|
|
159
|
+
spend: (allocation.spend as number).toFixed(2), change: cell(allocation.change_from_baseline),
|
|
160
|
+
};
|
|
161
|
+
}),
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "strategy-governance",
|
|
166
|
+
type: "table",
|
|
167
|
+
span: "full",
|
|
168
|
+
dataset: "strategy-governance.json",
|
|
169
|
+
data: {
|
|
170
|
+
label: "审批条件与风险",
|
|
171
|
+
columns: [{ key: "category", label: "类别" }, { key: "details", label: "内容" }],
|
|
172
|
+
rows: [
|
|
173
|
+
{ category: "审批条件", details: lines(handoff.review_conditions) },
|
|
174
|
+
{ category: "剩余风险", details: lines(handoff.review_residual_risks ?? proposal.risks) },
|
|
175
|
+
{ category: "待确认事项", details: lines(proposal.required_human_decisions) },
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function projectForecastModule(forecast: ApprovedCycleForecast, artifactRef: ArtifactRefV2): DashboardModuleBuild {
|
|
184
|
+
const allocationByKey = new Map(forecast.approved_allocation.map((item) => [`${item.app_id}\u0000${item.store}\u0000${item.channel_group}`, item]));
|
|
185
|
+
return {
|
|
186
|
+
id: "next-forecast",
|
|
187
|
+
title: "下周期数据预测",
|
|
188
|
+
status: "published",
|
|
189
|
+
source: {
|
|
190
|
+
artifact_type: "approved_cycle_forecast",
|
|
191
|
+
artifact_ref: artifactRef,
|
|
192
|
+
artifact_fingerprint: forecast.immutable_fingerprint,
|
|
193
|
+
forecast_version: forecast.forecast_version,
|
|
194
|
+
strategy_version: forecast.strategy_version,
|
|
195
|
+
target_period: forecast.target_period,
|
|
196
|
+
data_as_of: forecast.data_as_of,
|
|
197
|
+
},
|
|
198
|
+
widgets: [
|
|
199
|
+
{
|
|
200
|
+
id: "forecast-summary",
|
|
201
|
+
type: "table",
|
|
202
|
+
span: "full",
|
|
203
|
+
dataset: "forecast-summary.json",
|
|
204
|
+
data: {
|
|
205
|
+
label: "预测摘要",
|
|
206
|
+
columns: [{ key: "metric", label: "指标" }, { key: "downside", label: "Downside", align: "right" }, { key: "base", label: "Base", align: "right" }, { key: "upside", label: "Upside", align: "right" }],
|
|
207
|
+
rows: Object.entries(forecast.consolidated_forecast).map(([metric, scenario]) => ({
|
|
208
|
+
metric,
|
|
209
|
+
downside: cell(scenario.downside),
|
|
210
|
+
base: cell(scenario.base),
|
|
211
|
+
upside: cell(scenario.upside),
|
|
212
|
+
})),
|
|
213
|
+
description: `${forecast.target_period.start_inclusive} → ${forecast.target_period.end_exclusive}`,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "forecast-by-slice",
|
|
218
|
+
type: "table",
|
|
219
|
+
span: "full",
|
|
220
|
+
dataset: "forecast-by-slice.json",
|
|
221
|
+
data: {
|
|
222
|
+
label: "分片预测",
|
|
223
|
+
columns: [
|
|
224
|
+
{ key: "slice", label: "App / 商店 / 渠道" }, { key: "spend", label: "预算", align: "right" },
|
|
225
|
+
{ key: "revenue", label: "Base 收入", align: "right" }, { key: "roas", label: "Base ROAS", align: "right" },
|
|
226
|
+
],
|
|
227
|
+
rows: forecast.forecast_by_slice.map((slice) => {
|
|
228
|
+
const key = `${slice.app_id}\u0000${slice.store}\u0000${slice.channel_group}`;
|
|
229
|
+
const allocation = allocationByKey.get(key);
|
|
230
|
+
return {
|
|
231
|
+
slice: `${slice.app_id} · ${slice.store} · ${slice.channel_group}`,
|
|
232
|
+
spend: cell(allocation?.approved_spend),
|
|
233
|
+
revenue: cell(slice.metrics.revenue?.base),
|
|
234
|
+
roas: cell(slice.metrics.roas?.base),
|
|
235
|
+
};
|
|
236
|
+
}),
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|