@viccydev/pi-fpa 0.7.2 → 0.8.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 +15 -4
- package/extensions/fpa-artifacts/index.ts +22 -0
- package/extensions/fpa-dashboard/compat-publisher.ts +45 -0
- package/extensions/fpa-dashboard/coordinator.ts +3 -2
- package/extensions/fpa-dashboard/index.ts +237 -3
- package/extensions/fpa-dashboard/module-publisher.ts +390 -0
- package/extensions/fpa-dashboard/stage-projector.ts +304 -0
- package/extensions/fpa-dashboard/strategy-decision.ts +192 -0
- package/extensions/fpa-routing-guard/graph-installer.ts +234 -0
- package/extensions/fpa-routing-guard/index.ts +131 -11
- package/graphs/fpa-forecast-freeze.json +95 -0
- package/graphs/fpa-strategy-planning.json +159 -0
- package/package.json +4 -3
- package/prompts/fpa-plan-cycle.md +18 -5
- package/skills/fpa-apply-core-rules/SKILL.md +9 -6
- package/skills/fpa-apply-core-rules/references/core-rules.md +21 -10
- package/skills/fpa-forecast-approved-strategy/SKILL.md +11 -8
- package/skills/fpa-recommend-strategy/SKILL.md +1 -1
- package/skills/fpa-recommend-strategy/references/artifact-contract.md +2 -1
- package/skills/fpa-refresh-dashboard/SKILL.md +47 -4
- package/skills/fpa-review-strategy/references/artifact-contract.md +22 -0
|
@@ -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
|
+
}
|