@viccydev/pi-fpa 0.4.1 → 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.
- package/README.md +21 -4
- package/bin/fpa-dashboard-worker.mjs +98 -0
- package/extensions/fpa-artifacts/compose.ts +417 -0
- package/extensions/fpa-artifacts/contracts.ts +9 -1
- package/extensions/fpa-artifacts/index.ts +119 -4
- package/extensions/fpa-artifacts/store.ts +376 -50
- package/extensions/fpa-dashboard/actuals.ts +243 -40
- package/extensions/fpa-dashboard/coordinator.ts +831 -0
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +559 -0
- package/extensions/fpa-dashboard/forecast-accuracy.ts +252 -0
- package/extensions/fpa-dashboard/forward-outlook.ts +142 -0
- package/extensions/fpa-dashboard/index.ts +96 -72
- package/extensions/fpa-dashboard/projector.ts +19 -0
- package/extensions/fpa-dashboard/provenance.ts +147 -0
- package/extensions/fpa-dashboard/publisher.ts +99 -9
- package/extensions/fpa-dashboard/service.ts +179 -0
- package/extensions/fpa-dashboard/source.ts +136 -1
- package/extensions/fpa-dashboard/status.ts +56 -5
- package/package.json +14 -4
- package/skills/fpa-execute-approved-strategy/SKILL.md +1 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +22 -12
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +55 -4
- package/skills/fpa-refresh-dashboard/SKILL.md +20 -1
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { readArtifactByRef, stableJson, validateArtifactRef, type ArtifactRefV2 } from "../fpa-artifacts/store.ts";
|
|
6
|
+
import { dashboardBuildFingerprint, publishDashboard, resolveDashboardDir } from "./publisher.ts";
|
|
7
|
+
import { buildDashboardProjection } from "./service.ts";
|
|
8
|
+
|
|
9
|
+
const EVENT_MAX_BYTES = 64 * 1024;
|
|
10
|
+
const MAX_ATTEMPTS = 3;
|
|
11
|
+
const LOCK_STALE_MS = 5 * 60 * 1000;
|
|
12
|
+
const ACTUALS_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
|
13
|
+
const COORDINATOR_HEARTBEAT_TTL_MS = 90 * 1000;
|
|
14
|
+
const REASON_VALUES = new Set(["forecast_committed", "execution_committed", "actuals_watermark", "generation_identity", "manual"]);
|
|
15
|
+
|
|
16
|
+
export interface DashboardRefreshRequest {
|
|
17
|
+
preset: "forecast-closed-loop-v1";
|
|
18
|
+
locale?: "zh-CN" | "en-US";
|
|
19
|
+
scope_id: string;
|
|
20
|
+
cycle_id: string;
|
|
21
|
+
forecast_ref: ArtifactRefV2;
|
|
22
|
+
forecast_role?: "original" | "eac" | "next_plan";
|
|
23
|
+
execution_ref?: ArtifactRefV2;
|
|
24
|
+
next_forecast_ref?: ArtifactRefV2;
|
|
25
|
+
forward_forecast_refs?: ArtifactRefV2[];
|
|
26
|
+
reason: "forecast_committed" | "execution_committed" | "actuals_watermark" | "generation_identity" | "manual";
|
|
27
|
+
actuals_watermark?: string;
|
|
28
|
+
desired_generation_id?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DashboardRefreshEvent extends DashboardRefreshRequest {
|
|
32
|
+
kind: "fpa.dashboard.refresh-event";
|
|
33
|
+
schema_version: 1;
|
|
34
|
+
event_id: string;
|
|
35
|
+
project_name: string;
|
|
36
|
+
enqueued_at: string;
|
|
37
|
+
attempt: number;
|
|
38
|
+
not_before?: string;
|
|
39
|
+
last_error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DashboardRefreshWorkResult {
|
|
43
|
+
generation_id: string;
|
|
44
|
+
data_as_of: string;
|
|
45
|
+
published: boolean;
|
|
46
|
+
actuals_watermark?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface EnqueueDashboardRefreshResult {
|
|
50
|
+
event_id: string;
|
|
51
|
+
status: "enqueued" | "already_pending" | "already_processing" | "already_completed" | "already_dead";
|
|
52
|
+
queue_dir: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DashboardRefreshQueueStatus {
|
|
56
|
+
queue_dir: string;
|
|
57
|
+
pending: number;
|
|
58
|
+
processing: number;
|
|
59
|
+
completed: number;
|
|
60
|
+
dead: number;
|
|
61
|
+
busy: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ProcessDashboardRefreshQueueOptions {
|
|
65
|
+
limit?: number;
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
now?: () => Date;
|
|
68
|
+
worker?: (event: DashboardRefreshEvent, projectRoot: string, signal?: AbortSignal) => Promise<DashboardRefreshWorkResult>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ProcessDashboardRefreshQueueResult extends DashboardRefreshQueueStatus {
|
|
72
|
+
status: "processed" | "busy";
|
|
73
|
+
processed: number;
|
|
74
|
+
succeeded: number;
|
|
75
|
+
superseded: number;
|
|
76
|
+
retried: number;
|
|
77
|
+
failed: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class ObsoleteDashboardRefreshError extends Error {
|
|
81
|
+
constructor(message: string) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "ObsoleteDashboardRefreshError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DashboardCoordinatorStatus {
|
|
88
|
+
kind: "fpa.dashboard.coordinator-status";
|
|
89
|
+
schema_version: 1;
|
|
90
|
+
state: "idle" | "healthy" | "refreshing" | "degraded" | "failed";
|
|
91
|
+
heartbeat_at: string;
|
|
92
|
+
heartbeat_expires_at: string;
|
|
93
|
+
last_success_at: string | null;
|
|
94
|
+
last_actuals_watermark: string | null;
|
|
95
|
+
next_poll_at: string | null;
|
|
96
|
+
project_name: string | null;
|
|
97
|
+
scope_id: string | null;
|
|
98
|
+
cycle_id: string | null;
|
|
99
|
+
generation_id: string | null;
|
|
100
|
+
pending: number;
|
|
101
|
+
processing: number;
|
|
102
|
+
dead: number;
|
|
103
|
+
last_error: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface QueueDirectories {
|
|
107
|
+
root: string;
|
|
108
|
+
pending: string;
|
|
109
|
+
processing: string;
|
|
110
|
+
completed: string;
|
|
111
|
+
dead: string;
|
|
112
|
+
subscriptions: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface DashboardRefreshSubscription {
|
|
116
|
+
kind: "fpa.dashboard.refresh-subscription";
|
|
117
|
+
schema_version: 1;
|
|
118
|
+
subscription_id: string;
|
|
119
|
+
project_name: string;
|
|
120
|
+
preset: "forecast-closed-loop-v1";
|
|
121
|
+
locale: "zh-CN" | "en-US";
|
|
122
|
+
scope_id: string;
|
|
123
|
+
cycle_id: string;
|
|
124
|
+
forecast_ref: ArtifactRefV2;
|
|
125
|
+
forecast_role?: "original" | "eac" | "next_plan";
|
|
126
|
+
execution_ref?: ArtifactRefV2;
|
|
127
|
+
next_forecast_ref?: ArtifactRefV2;
|
|
128
|
+
forward_forecast_refs?: ArtifactRefV2[];
|
|
129
|
+
updated_at: string;
|
|
130
|
+
next_poll_at?: string;
|
|
131
|
+
last_generation_id?: string;
|
|
132
|
+
desired_generation_id?: string;
|
|
133
|
+
last_actuals_watermark?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sha256(value: string): string {
|
|
137
|
+
return createHash("sha256").update(value).digest("hex");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function boundedId(value: unknown, path: string): string {
|
|
141
|
+
if (typeof value !== "string" || value.trim() === "" || value.length > 256 || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
142
|
+
throw new Error(`${path} must be a non-empty string of at most 256 characters without control characters.`);
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function boundedOptional(value: unknown, path: string): string | undefined {
|
|
148
|
+
if (value === undefined) return undefined;
|
|
149
|
+
return boundedId(value, path);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateRequest(value: DashboardRefreshRequest): DashboardRefreshRequest {
|
|
153
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("refresh request must be an object.");
|
|
154
|
+
const source = value as unknown as Record<string, unknown>;
|
|
155
|
+
const allowed = new Set(["preset", "locale", "scope_id", "cycle_id", "forecast_ref", "forecast_role", "execution_ref", "next_forecast_ref", "forward_forecast_refs", "reason", "actuals_watermark", "desired_generation_id"]);
|
|
156
|
+
for (const key of Object.keys(source)) if (!allowed.has(key)) throw new Error(`refresh request.${key} is not allowed.`);
|
|
157
|
+
if (source.preset !== "forecast-closed-loop-v1") throw new Error("refresh request.preset is unsupported.");
|
|
158
|
+
if (source.locale !== undefined && source.locale !== "zh-CN" && source.locale !== "en-US") throw new Error("refresh request.locale is unsupported.");
|
|
159
|
+
if (typeof source.reason !== "string" || !REASON_VALUES.has(source.reason)) throw new Error("refresh request.reason is unsupported.");
|
|
160
|
+
const scopeId = boundedId(source.scope_id, "refresh request.scope_id");
|
|
161
|
+
const cycleId = boundedId(source.cycle_id, "refresh request.cycle_id");
|
|
162
|
+
const forecastRef = validateArtifactRef(source.forecast_ref, "refresh request.forecast_ref");
|
|
163
|
+
if (source.forecast_role !== undefined && source.forecast_role !== "original" && source.forecast_role !== "eac" && source.forecast_role !== "next_plan") {
|
|
164
|
+
throw new Error("refresh request.forecast_role is unsupported.");
|
|
165
|
+
}
|
|
166
|
+
if (forecastRef.artifact_type !== "approved_cycle_forecast" || forecastRef.scope_id !== scopeId || forecastRef.cycle_id !== cycleId) {
|
|
167
|
+
throw new Error("refresh request.forecast_ref must identify the requested scope and cycle forecast.");
|
|
168
|
+
}
|
|
169
|
+
const executionRef = source.execution_ref === undefined ? undefined : validateArtifactRef(source.execution_ref, "refresh request.execution_ref");
|
|
170
|
+
if (executionRef && (executionRef.artifact_type !== "execution_receipt" || executionRef.scope_id !== scopeId || executionRef.cycle_id !== cycleId)) {
|
|
171
|
+
throw new Error("refresh request.execution_ref must identify the requested scope and cycle execution receipt.");
|
|
172
|
+
}
|
|
173
|
+
const nextForecastRef = source.next_forecast_ref === undefined ? undefined : validateArtifactRef(source.next_forecast_ref, "refresh request.next_forecast_ref");
|
|
174
|
+
if (nextForecastRef && (nextForecastRef.artifact_type !== "approved_cycle_forecast" || nextForecastRef.scope_id !== scopeId || nextForecastRef.entry_id === forecastRef.entry_id)) {
|
|
175
|
+
throw new Error("refresh request.next_forecast_ref must identify a different forecast in the same scope.");
|
|
176
|
+
}
|
|
177
|
+
if (source.forward_forecast_refs !== undefined && (!Array.isArray(source.forward_forecast_refs) || source.forward_forecast_refs.length > 6)) {
|
|
178
|
+
throw new Error("refresh request.forward_forecast_refs must contain at most six refs.");
|
|
179
|
+
}
|
|
180
|
+
const forwardForecastRefs = (source.forward_forecast_refs as unknown[] | undefined)?.map((ref, index) => validateArtifactRef(ref, `refresh request.forward_forecast_refs[${index}]`)) ?? [];
|
|
181
|
+
if (new Set(forwardForecastRefs.map((ref) => ref.entry_id)).size !== forwardForecastRefs.length) throw new Error("refresh request.forward_forecast_refs must not contain duplicates.");
|
|
182
|
+
if (forwardForecastRefs.some((ref) => ref.artifact_type !== "approved_cycle_forecast" || ref.scope_id !== scopeId)) throw new Error("refresh request.forward_forecast_refs must contain forecasts in the requested scope.");
|
|
183
|
+
if (nextForecastRef && forwardForecastRefs.length > 0 && nextForecastRef.entry_id !== forwardForecastRefs[0].entry_id) throw new Error("refresh request.next_forecast_ref must be the first forward forecast ref.");
|
|
184
|
+
const desiredGenerationId = source.desired_generation_id === undefined
|
|
185
|
+
? undefined
|
|
186
|
+
: boundedId(source.desired_generation_id, "refresh request.desired_generation_id");
|
|
187
|
+
if (desiredGenerationId !== undefined && !/^[a-f0-9]{64}$/.test(desiredGenerationId)) {
|
|
188
|
+
throw new Error("refresh request.desired_generation_id must be a SHA-256 digest.");
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
preset: "forecast-closed-loop-v1",
|
|
192
|
+
...(source.locale ? { locale: source.locale as "zh-CN" | "en-US" } : {}),
|
|
193
|
+
scope_id: scopeId,
|
|
194
|
+
cycle_id: cycleId,
|
|
195
|
+
forecast_ref: forecastRef,
|
|
196
|
+
...(source.forecast_role ? { forecast_role: source.forecast_role as DashboardRefreshRequest["forecast_role"] } : {}),
|
|
197
|
+
...(executionRef ? { execution_ref: executionRef } : {}),
|
|
198
|
+
...(nextForecastRef ? { next_forecast_ref: nextForecastRef } : {}),
|
|
199
|
+
...(forwardForecastRefs.length > 0 ? { forward_forecast_refs: forwardForecastRefs } : {}),
|
|
200
|
+
reason: source.reason as DashboardRefreshRequest["reason"],
|
|
201
|
+
...(boundedOptional(source.actuals_watermark, "refresh request.actuals_watermark") ? { actuals_watermark: source.actuals_watermark as string } : {}),
|
|
202
|
+
...(desiredGenerationId ? { desired_generation_id: desiredGenerationId } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function pathKind(path: string): Promise<"missing" | "directory" | "symlink" | "other"> {
|
|
207
|
+
try {
|
|
208
|
+
const value = await lstat(path);
|
|
209
|
+
if (value.isSymbolicLink()) return "symlink";
|
|
210
|
+
if (value.isDirectory()) return "directory";
|
|
211
|
+
return "other";
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function ensureDirectory(path: string, label: string): Promise<void> {
|
|
219
|
+
const kind = await pathKind(path);
|
|
220
|
+
if (kind === "symlink" || kind === "other") throw new Error(`${label} must be a regular directory, not a symlink.`);
|
|
221
|
+
if (kind === "missing") await mkdir(path, { recursive: true, mode: 0o700 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function queueDirectories(cwd: string): Promise<QueueDirectories> {
|
|
225
|
+
const dashboardDir = await resolveDashboardDir(cwd);
|
|
226
|
+
await ensureDirectory(dashboardDir, "Dashboard directory");
|
|
227
|
+
const root = join(dashboardDir, "refresh-queue");
|
|
228
|
+
await ensureDirectory(root, "Dashboard refresh queue");
|
|
229
|
+
const result = {
|
|
230
|
+
root,
|
|
231
|
+
pending: join(root, "pending"),
|
|
232
|
+
processing: join(root, "processing"),
|
|
233
|
+
completed: join(root, "completed"),
|
|
234
|
+
dead: join(root, "dead"),
|
|
235
|
+
subscriptions: join(root, "subscriptions"),
|
|
236
|
+
};
|
|
237
|
+
for (const [path, label] of [[result.pending, "pending"], [result.processing, "processing"], [result.completed, "completed"], [result.dead, "dead"], [result.subscriptions, "subscriptions"]] as const) {
|
|
238
|
+
await ensureDirectory(path, `Dashboard refresh ${label} queue`);
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function writeExclusive(path: string, contents: string): Promise<boolean> {
|
|
244
|
+
try {
|
|
245
|
+
const handle = await open(path, "wx", 0o600);
|
|
246
|
+
try {
|
|
247
|
+
await handle.writeFile(contents, "utf8");
|
|
248
|
+
await handle.sync();
|
|
249
|
+
} finally {
|
|
250
|
+
await handle.close();
|
|
251
|
+
}
|
|
252
|
+
return true;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function atomicReplace(path: string, contents: string): Promise<void> {
|
|
260
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
261
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
262
|
+
try {
|
|
263
|
+
await handle.writeFile(contents, "utf8");
|
|
264
|
+
await handle.sync();
|
|
265
|
+
} finally {
|
|
266
|
+
await handle.close();
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
await rename(temporary, path);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
await unlink(temporary).catch(() => undefined);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function readCoordinatorStatus(cwd: string): Promise<DashboardCoordinatorStatus | null> {
|
|
277
|
+
const path = join(await resolveDashboardDir(cwd), "coordinator-status.json");
|
|
278
|
+
try {
|
|
279
|
+
const metadata = await lstat(path);
|
|
280
|
+
if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > EVENT_MAX_BYTES) return null;
|
|
281
|
+
const value = JSON.parse(await readFile(path, "utf8")) as DashboardCoordinatorStatus;
|
|
282
|
+
return value.kind === "fpa.dashboard.coordinator-status" && value.schema_version === 1 ? value : null;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function writeCoordinatorStatus(
|
|
290
|
+
cwd: string,
|
|
291
|
+
options: {
|
|
292
|
+
now: Date;
|
|
293
|
+
queue?: DashboardRefreshQueueStatus;
|
|
294
|
+
state?: DashboardCoordinatorStatus["state"];
|
|
295
|
+
lastSuccess?: boolean;
|
|
296
|
+
lastActualsWatermark?: string | null;
|
|
297
|
+
nextPollAt?: string | null;
|
|
298
|
+
subscription?: DashboardRefreshSubscription | null;
|
|
299
|
+
lastError?: string | null;
|
|
300
|
+
},
|
|
301
|
+
): Promise<DashboardCoordinatorStatus> {
|
|
302
|
+
const dashboardDir = await resolveDashboardDir(cwd);
|
|
303
|
+
await ensureDirectory(dashboardDir, "Dashboard directory");
|
|
304
|
+
const previous = await readCoordinatorStatus(cwd);
|
|
305
|
+
const queue = options.queue ?? await inspectDashboardRefreshQueue(cwd);
|
|
306
|
+
const status: DashboardCoordinatorStatus = {
|
|
307
|
+
kind: "fpa.dashboard.coordinator-status",
|
|
308
|
+
schema_version: 1,
|
|
309
|
+
state: options.state ?? (queue.dead > 0 ? "degraded" : queue.pending + queue.processing > 0 ? "refreshing" : "healthy"),
|
|
310
|
+
heartbeat_at: options.now.toISOString(),
|
|
311
|
+
heartbeat_expires_at: new Date(options.now.getTime() + COORDINATOR_HEARTBEAT_TTL_MS).toISOString(),
|
|
312
|
+
last_success_at: options.lastSuccess ? options.now.toISOString() : previous?.last_success_at ?? null,
|
|
313
|
+
last_actuals_watermark: options.lastActualsWatermark === undefined ? previous?.last_actuals_watermark ?? null : options.lastActualsWatermark,
|
|
314
|
+
next_poll_at: options.nextPollAt === undefined ? previous?.next_poll_at ?? null : options.nextPollAt,
|
|
315
|
+
project_name: options.subscription?.project_name ?? null,
|
|
316
|
+
scope_id: options.subscription?.scope_id ?? null,
|
|
317
|
+
cycle_id: options.subscription?.cycle_id ?? null,
|
|
318
|
+
generation_id: options.subscription?.last_generation_id ?? null,
|
|
319
|
+
pending: queue.pending,
|
|
320
|
+
processing: queue.processing,
|
|
321
|
+
dead: queue.dead,
|
|
322
|
+
last_error: options.lastError === undefined ? previous?.last_error ?? null : options.lastError,
|
|
323
|
+
};
|
|
324
|
+
await atomicReplace(join(dashboardDir, "coordinator-status.json"), `${JSON.stringify(status, null, 2)}\n`);
|
|
325
|
+
return status;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function readEvent(path: string): Promise<DashboardRefreshEvent> {
|
|
329
|
+
const file = await lstat(path);
|
|
330
|
+
if (file.isSymbolicLink() || !file.isFile() || file.size > EVENT_MAX_BYTES) throw new Error("Refresh event must be a bounded regular file.");
|
|
331
|
+
const source = JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
|
|
332
|
+
if (source.kind !== "fpa.dashboard.refresh-event" || source.schema_version !== 1) throw new Error("Refresh event has an unsupported contract.");
|
|
333
|
+
const request = validateRequest({
|
|
334
|
+
preset: source.preset,
|
|
335
|
+
locale: source.locale,
|
|
336
|
+
scope_id: source.scope_id,
|
|
337
|
+
cycle_id: source.cycle_id,
|
|
338
|
+
forecast_ref: source.forecast_ref,
|
|
339
|
+
forecast_role: source.forecast_role,
|
|
340
|
+
execution_ref: source.execution_ref,
|
|
341
|
+
next_forecast_ref: source.next_forecast_ref,
|
|
342
|
+
forward_forecast_refs: source.forward_forecast_refs,
|
|
343
|
+
reason: source.reason,
|
|
344
|
+
actuals_watermark: source.actuals_watermark,
|
|
345
|
+
desired_generation_id: source.desired_generation_id,
|
|
346
|
+
} as DashboardRefreshRequest);
|
|
347
|
+
if (typeof source.event_id !== "string" || !/^[a-f0-9]{64}$/.test(source.event_id)) throw new Error("Refresh event has an invalid event_id.");
|
|
348
|
+
if (typeof source.project_name !== "string" || basename(source.project_name) !== source.project_name || source.project_name === "." || source.project_name === "..") throw new Error("Refresh event has an invalid project_name.");
|
|
349
|
+
if (typeof source.enqueued_at !== "string" || Number.isNaN(Date.parse(source.enqueued_at))) throw new Error("Refresh event has an invalid enqueued_at.");
|
|
350
|
+
if (!Number.isInteger(source.attempt) || (source.attempt as number) < 0 || (source.attempt as number) > MAX_ATTEMPTS) throw new Error("Refresh event has an invalid attempt.");
|
|
351
|
+
if (source.not_before !== undefined && (typeof source.not_before !== "string" || Number.isNaN(Date.parse(source.not_before)))) throw new Error("Refresh event has an invalid not_before.");
|
|
352
|
+
if (source.last_error !== undefined && (typeof source.last_error !== "string" || source.last_error.length > 2048)) throw new Error("Refresh event has an invalid last_error.");
|
|
353
|
+
const expectedId = eventIdentity(source.project_name, request);
|
|
354
|
+
if (expectedId !== source.event_id) throw new Error("Refresh event identity does not match its immutable request.");
|
|
355
|
+
return {
|
|
356
|
+
kind: "fpa.dashboard.refresh-event",
|
|
357
|
+
schema_version: 1,
|
|
358
|
+
event_id: source.event_id,
|
|
359
|
+
project_name: source.project_name,
|
|
360
|
+
enqueued_at: source.enqueued_at,
|
|
361
|
+
attempt: source.attempt as number,
|
|
362
|
+
...(source.not_before ? { not_before: source.not_before as string } : {}),
|
|
363
|
+
...(source.last_error ? { last_error: source.last_error as string } : {}),
|
|
364
|
+
...request,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function eventIdentity(projectName: string, request: DashboardRefreshRequest): string {
|
|
369
|
+
return sha256(stableJson({ project_name: projectName, request }));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function eventState(directories: QueueDirectories, filename: string): Promise<EnqueueDashboardRefreshResult["status"] | null> {
|
|
373
|
+
for (const [directory, status] of [
|
|
374
|
+
[directories.pending, "already_pending"],
|
|
375
|
+
[directories.processing, "already_processing"],
|
|
376
|
+
[directories.completed, "already_completed"],
|
|
377
|
+
[directories.dead, "already_dead"],
|
|
378
|
+
] as const) {
|
|
379
|
+
if (await pathKind(join(directory, filename)) !== "missing") return status;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function subscriptionIdentity(projectName: string, scopeId: string, cycleId: string): string {
|
|
385
|
+
return sha256(stableJson({ project_name: projectName, scope_id: scopeId, cycle_id: cycleId }));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function readSubscription(path: string): Promise<DashboardRefreshSubscription> {
|
|
389
|
+
const file = await lstat(path);
|
|
390
|
+
if (file.isSymbolicLink() || !file.isFile() || file.size > EVENT_MAX_BYTES) throw new Error("Dashboard refresh subscription must be a bounded regular file.");
|
|
391
|
+
const source = JSON.parse(await readFile(path, "utf8")) as DashboardRefreshSubscription;
|
|
392
|
+
if (source.kind !== "fpa.dashboard.refresh-subscription" || source.schema_version !== 1) throw new Error("Dashboard refresh subscription has an unsupported contract.");
|
|
393
|
+
const request = validateRequest({
|
|
394
|
+
preset: source.preset,
|
|
395
|
+
locale: source.locale,
|
|
396
|
+
scope_id: source.scope_id,
|
|
397
|
+
cycle_id: source.cycle_id,
|
|
398
|
+
forecast_ref: source.forecast_ref,
|
|
399
|
+
forecast_role: source.forecast_role,
|
|
400
|
+
execution_ref: source.execution_ref,
|
|
401
|
+
next_forecast_ref: source.next_forecast_ref,
|
|
402
|
+
forward_forecast_refs: source.forward_forecast_refs,
|
|
403
|
+
desired_generation_id: source.desired_generation_id,
|
|
404
|
+
reason: "manual",
|
|
405
|
+
});
|
|
406
|
+
const expected = subscriptionIdentity(source.project_name, request.scope_id, request.cycle_id);
|
|
407
|
+
if (source.subscription_id !== expected || basename(source.project_name) !== source.project_name) throw new Error("Dashboard refresh subscription identity is invalid.");
|
|
408
|
+
return source;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function readOptionalSubscription(path: string): Promise<DashboardRefreshSubscription | null> {
|
|
412
|
+
try {
|
|
413
|
+
return await readSubscription(path);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function activeSubscription(directories: QueueDirectories): Promise<DashboardRefreshSubscription | null> {
|
|
421
|
+
try {
|
|
422
|
+
const pointerPath = join(directories.root, "active-subscription.json");
|
|
423
|
+
const file = await lstat(pointerPath);
|
|
424
|
+
if (file.isSymbolicLink() || !file.isFile() || file.size > EVENT_MAX_BYTES) throw new Error("Active dashboard subscription pointer is invalid.");
|
|
425
|
+
const pointer = JSON.parse(await readFile(pointerPath, "utf8")) as { subscription_id?: unknown };
|
|
426
|
+
if (typeof pointer.subscription_id !== "string" || !/^[a-f0-9]{64}$/.test(pointer.subscription_id)) throw new Error("Active dashboard subscription pointer has no valid id.");
|
|
427
|
+
return await readSubscription(join(directories.subscriptions, `${pointer.subscription_id}.json`));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function persistSubscription(
|
|
435
|
+
directories: QueueDirectories,
|
|
436
|
+
projectName: string,
|
|
437
|
+
request: DashboardRefreshRequest,
|
|
438
|
+
now: string,
|
|
439
|
+
): Promise<DashboardRefreshSubscription> {
|
|
440
|
+
const subscriptionId = subscriptionIdentity(projectName, request.scope_id, request.cycle_id);
|
|
441
|
+
const path = join(directories.subscriptions, `${subscriptionId}.json`);
|
|
442
|
+
const existing = await readOptionalSubscription(path);
|
|
443
|
+
const sameForecast = existing?.forecast_ref.entry_id === request.forecast_ref.entry_id;
|
|
444
|
+
const subscription: DashboardRefreshSubscription = {
|
|
445
|
+
kind: "fpa.dashboard.refresh-subscription",
|
|
446
|
+
schema_version: 1,
|
|
447
|
+
subscription_id: subscriptionId,
|
|
448
|
+
project_name: projectName,
|
|
449
|
+
preset: request.preset,
|
|
450
|
+
locale: request.locale ?? existing?.locale ?? "zh-CN",
|
|
451
|
+
scope_id: request.scope_id,
|
|
452
|
+
cycle_id: request.cycle_id,
|
|
453
|
+
forecast_ref: request.forecast_ref,
|
|
454
|
+
...(request.forecast_role ? { forecast_role: request.forecast_role } : existing?.forecast_role ? { forecast_role: existing.forecast_role } : {}),
|
|
455
|
+
...(request.execution_ref ? { execution_ref: request.execution_ref } : sameForecast && existing?.execution_ref ? { execution_ref: existing.execution_ref } : {}),
|
|
456
|
+
...(request.next_forecast_ref ? { next_forecast_ref: request.next_forecast_ref } : sameForecast && existing?.next_forecast_ref ? { next_forecast_ref: existing.next_forecast_ref } : {}),
|
|
457
|
+
...(request.forward_forecast_refs ? { forward_forecast_refs: request.forward_forecast_refs } : sameForecast && existing?.forward_forecast_refs ? { forward_forecast_refs: existing.forward_forecast_refs } : {}),
|
|
458
|
+
updated_at: now,
|
|
459
|
+
...(sameForecast && existing?.next_poll_at ? { next_poll_at: existing.next_poll_at } : {}),
|
|
460
|
+
...(sameForecast && existing?.last_generation_id ? { last_generation_id: existing.last_generation_id } : {}),
|
|
461
|
+
...(request.desired_generation_id
|
|
462
|
+
? { desired_generation_id: request.desired_generation_id }
|
|
463
|
+
: sameForecast && existing?.desired_generation_id
|
|
464
|
+
? { desired_generation_id: existing.desired_generation_id }
|
|
465
|
+
: {}),
|
|
466
|
+
...(sameForecast && existing?.last_actuals_watermark ? { last_actuals_watermark: existing.last_actuals_watermark } : {}),
|
|
467
|
+
};
|
|
468
|
+
await atomicReplace(path, `${JSON.stringify(subscription, null, 2)}\n`);
|
|
469
|
+
return subscription;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function enqueueEventOnly(
|
|
473
|
+
directories: QueueDirectories,
|
|
474
|
+
projectName: string,
|
|
475
|
+
request: DashboardRefreshRequest,
|
|
476
|
+
now: string,
|
|
477
|
+
): Promise<EnqueueDashboardRefreshResult> {
|
|
478
|
+
const eventId = eventIdentity(projectName, request);
|
|
479
|
+
const filename = `${eventId}.json`;
|
|
480
|
+
const existing = await eventState(directories, filename);
|
|
481
|
+
if (existing) return { event_id: eventId, status: existing, queue_dir: directories.root };
|
|
482
|
+
const event: DashboardRefreshEvent = {
|
|
483
|
+
kind: "fpa.dashboard.refresh-event",
|
|
484
|
+
schema_version: 1,
|
|
485
|
+
event_id: eventId,
|
|
486
|
+
project_name: projectName,
|
|
487
|
+
enqueued_at: now,
|
|
488
|
+
attempt: 0,
|
|
489
|
+
...request,
|
|
490
|
+
};
|
|
491
|
+
const created = await writeExclusive(join(directories.pending, filename), `${JSON.stringify(event, null, 2)}\n`);
|
|
492
|
+
return { event_id: eventId, status: created ? "enqueued" : "already_pending", queue_dir: directories.root };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function sortForwardForecastRefs(projectRoot: string, refs: ArtifactRefV2[]): Promise<ArtifactRefV2[]> {
|
|
496
|
+
const dated = await Promise.all(refs.map(async (ref) => {
|
|
497
|
+
const read = await readArtifactByRef(projectRoot, ref);
|
|
498
|
+
if (read.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("A forward forecast ref resolved to the wrong artifact type.");
|
|
499
|
+
return { ref, start: Date.parse(read.artifact.target_period.start_inclusive) };
|
|
500
|
+
}));
|
|
501
|
+
return dated.sort((left, right) => left.start - right.start).map((item) => item.ref).slice(0, 6);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export async function enqueueDashboardRefresh(cwd: string, value: DashboardRefreshRequest): Promise<EnqueueDashboardRefreshResult> {
|
|
505
|
+
const request = validateRequest(value);
|
|
506
|
+
const projectRoot = await realpath(cwd);
|
|
507
|
+
const projectName = basename(projectRoot);
|
|
508
|
+
const directories = await queueDirectories(projectRoot);
|
|
509
|
+
const now = new Date().toISOString();
|
|
510
|
+
const subscription = await persistSubscription(directories, projectName, request, now);
|
|
511
|
+
if (request.forecast_role === "next_plan") {
|
|
512
|
+
const active = await activeSubscription(directories);
|
|
513
|
+
if (active && active.project_name === projectName && active.scope_id === request.scope_id && active.subscription_id !== subscription.subscription_id) {
|
|
514
|
+
const unsortedForwardRefs = [...(active.forward_forecast_refs ?? (active.next_forecast_ref ? [active.next_forecast_ref] : [])), request.forecast_ref]
|
|
515
|
+
.filter((ref, index, refs) => refs.findIndex((candidate) => candidate.entry_id === ref.entry_id) === index)
|
|
516
|
+
.slice(0, 6);
|
|
517
|
+
const forwardRefs = await sortForwardForecastRefs(projectRoot, unsortedForwardRefs);
|
|
518
|
+
const linked = { ...active, next_forecast_ref: forwardRefs[0], forward_forecast_refs: forwardRefs, updated_at: now };
|
|
519
|
+
await atomicReplace(join(directories.subscriptions, `${active.subscription_id}.json`), `${JSON.stringify(linked, null, 2)}\n`);
|
|
520
|
+
return enqueueEventOnly(directories, projectName, validateRequest({
|
|
521
|
+
preset: active.preset,
|
|
522
|
+
locale: active.locale,
|
|
523
|
+
scope_id: active.scope_id,
|
|
524
|
+
cycle_id: active.cycle_id,
|
|
525
|
+
forecast_ref: active.forecast_ref,
|
|
526
|
+
forecast_role: active.forecast_role,
|
|
527
|
+
execution_ref: active.execution_ref,
|
|
528
|
+
next_forecast_ref: request.forecast_ref,
|
|
529
|
+
forward_forecast_refs: forwardRefs,
|
|
530
|
+
reason: "forecast_committed",
|
|
531
|
+
}), now);
|
|
532
|
+
}
|
|
533
|
+
} else if (request.reason === "forecast_committed" || request.reason === "execution_committed") {
|
|
534
|
+
await atomicReplace(join(directories.root, "active-subscription.json"), `${JSON.stringify({
|
|
535
|
+
kind: "fpa.dashboard.active-subscription",
|
|
536
|
+
schema_version: 1,
|
|
537
|
+
subscription_id: subscription.subscription_id,
|
|
538
|
+
updated_at: now,
|
|
539
|
+
}, null, 2)}\n`);
|
|
540
|
+
}
|
|
541
|
+
return enqueueEventOnly(directories, projectName, request, now);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function countJsonFiles(path: string): Promise<number> {
|
|
545
|
+
return (await readdir(path, { withFileTypes: true })).filter((entry) => entry.isFile() && /^[a-f0-9]{64}\.json$/.test(entry.name)).length;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export async function inspectDashboardRefreshQueue(cwd: string): Promise<DashboardRefreshQueueStatus> {
|
|
549
|
+
const directories = await queueDirectories(cwd);
|
|
550
|
+
const [pending, processing, completed, dead, lockKind] = await Promise.all([
|
|
551
|
+
countJsonFiles(directories.pending),
|
|
552
|
+
countJsonFiles(directories.processing),
|
|
553
|
+
countJsonFiles(directories.completed),
|
|
554
|
+
countJsonFiles(directories.dead),
|
|
555
|
+
pathKind(join(directories.root, "coordinator.lock")),
|
|
556
|
+
]);
|
|
557
|
+
return { queue_dir: directories.root, pending, processing, completed, dead, busy: lockKind !== "missing" };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function acquireLock(directories: QueueDirectories, now: Date): Promise<(() => Promise<void>) | null> {
|
|
561
|
+
const lockPath = join(directories.root, "coordinator.lock");
|
|
562
|
+
if (!await writeExclusive(lockPath, `${JSON.stringify({ acquired_at: now.toISOString(), pid: process.pid })}\n`)) {
|
|
563
|
+
try {
|
|
564
|
+
const lockStat = await stat(lockPath);
|
|
565
|
+
if (now.getTime() - lockStat.mtimeMs <= LOCK_STALE_MS) return null;
|
|
566
|
+
await unlink(lockPath);
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
569
|
+
}
|
|
570
|
+
if (!await writeExclusive(lockPath, `${JSON.stringify({ acquired_at: now.toISOString(), pid: process.pid })}\n`)) return null;
|
|
571
|
+
}
|
|
572
|
+
return async () => { await unlink(lockPath).catch(() => undefined); };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function resolveEventProject(workspaceRoot: string, projectName: string): Promise<string> {
|
|
576
|
+
const candidate = resolve(workspaceRoot, projectName);
|
|
577
|
+
const relation = relative(workspaceRoot, candidate);
|
|
578
|
+
if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) throw new Error("Refresh event project escapes the workspace.");
|
|
579
|
+
const projectRoot = await realpath(candidate);
|
|
580
|
+
if (dirname(projectRoot) !== workspaceRoot) throw new Error("Refresh event project must be a direct workspace child.");
|
|
581
|
+
return projectRoot;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function defaultWorker(event: DashboardRefreshEvent, projectRoot: string, signal?: AbortSignal): Promise<DashboardRefreshWorkResult> {
|
|
585
|
+
const result = await buildDashboardProjection(projectRoot, event.preset, event.locale ?? "zh-CN", {
|
|
586
|
+
scopeId: event.scope_id,
|
|
587
|
+
cycleId: event.cycle_id,
|
|
588
|
+
forecastRef: event.forecast_ref,
|
|
589
|
+
...(event.execution_ref ? { executionRef: event.execution_ref } : {}),
|
|
590
|
+
...(event.next_forecast_ref ? { nextForecastRef: event.next_forecast_ref } : {}),
|
|
591
|
+
...(event.forward_forecast_refs ? { forwardForecastRefs: event.forward_forecast_refs } : {}),
|
|
592
|
+
}, signal);
|
|
593
|
+
if (result.sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${result.sliceKeyMismatch}`);
|
|
594
|
+
if (result.forecast.status !== "complete" || !result.forecast.approval_conditions_satisfied) {
|
|
595
|
+
throw new Error("Dashboard publish requires a complete approved forecast with all approval conditions satisfied.");
|
|
596
|
+
}
|
|
597
|
+
const generationId = dashboardBuildFingerprint(result.build, result.projector);
|
|
598
|
+
if (event.desired_generation_id && generationId !== event.desired_generation_id) {
|
|
599
|
+
throw new ObsoleteDashboardRefreshError("Dashboard inputs changed after polling; the stale desired generation must be replanned.");
|
|
600
|
+
}
|
|
601
|
+
await publishDashboard({ cwd: projectRoot, build: result.build, projector: result.projector, runtime: result.runtime, actualsSnapshot: result.actuals });
|
|
602
|
+
return {
|
|
603
|
+
generation_id: generationId,
|
|
604
|
+
data_as_of: result.build.source.data_as_of,
|
|
605
|
+
published: true,
|
|
606
|
+
actuals_watermark: result.build.source.actuals_watermark,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function recordSubscriptionSuccess(
|
|
611
|
+
directories: QueueDirectories,
|
|
612
|
+
event: DashboardRefreshEvent,
|
|
613
|
+
result: DashboardRefreshWorkResult,
|
|
614
|
+
now: Date,
|
|
615
|
+
): Promise<void> {
|
|
616
|
+
const subscriptionId = subscriptionIdentity(event.project_name, event.scope_id, event.cycle_id);
|
|
617
|
+
const path = join(directories.subscriptions, `${subscriptionId}.json`);
|
|
618
|
+
const subscription = await readOptionalSubscription(path);
|
|
619
|
+
if (!subscription || subscription.forecast_ref.entry_id !== event.forecast_ref.entry_id) return;
|
|
620
|
+
if (event.desired_generation_id && result.generation_id !== event.desired_generation_id) {
|
|
621
|
+
throw new Error("Dashboard refresh result does not match the event's desired generation identity.");
|
|
622
|
+
}
|
|
623
|
+
await atomicReplace(path, `${JSON.stringify({
|
|
624
|
+
...subscription,
|
|
625
|
+
last_generation_id: result.generation_id,
|
|
626
|
+
desired_generation_id: event.desired_generation_id ?? result.generation_id,
|
|
627
|
+
last_actuals_watermark: result.actuals_watermark ?? result.generation_id,
|
|
628
|
+
next_poll_at: new Date(now.getTime() + ACTUALS_POLL_INTERVAL_MS).toISOString(),
|
|
629
|
+
updated_at: now.toISOString(),
|
|
630
|
+
}, null, 2)}\n`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export async function processDashboardRefreshQueue(cwd: string, options: ProcessDashboardRefreshQueueOptions = {}): Promise<ProcessDashboardRefreshQueueResult> {
|
|
634
|
+
const projectRoot = await realpath(cwd);
|
|
635
|
+
const workspaceRoot = dirname(projectRoot);
|
|
636
|
+
const directories = await queueDirectories(projectRoot);
|
|
637
|
+
const now = options.now?.() ?? new Date();
|
|
638
|
+
const release = await acquireLock(directories, now);
|
|
639
|
+
if (!release) return { status: "busy", processed: 0, succeeded: 0, superseded: 0, retried: 0, failed: 0, ...await inspectDashboardRefreshQueue(projectRoot), busy: true };
|
|
640
|
+
let processed = 0;
|
|
641
|
+
let succeeded = 0;
|
|
642
|
+
let superseded = 0;
|
|
643
|
+
let retried = 0;
|
|
644
|
+
let failed = 0;
|
|
645
|
+
try {
|
|
646
|
+
const completedNames = new Set(await readdir(directories.completed));
|
|
647
|
+
for (const name of await readdir(directories.processing)) {
|
|
648
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
|
|
649
|
+
const path = join(directories.processing, name);
|
|
650
|
+
if (completedNames.has(name)) {
|
|
651
|
+
await unlink(path).catch(() => undefined);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const value = await stat(path);
|
|
655
|
+
if (now.getTime() - value.mtimeMs > LOCK_STALE_MS) await rename(path, join(directories.pending, name)).catch(() => undefined);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const names = (await readdir(directories.pending)).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort();
|
|
659
|
+
const limit = Math.max(1, Math.min(options.limit ?? 32, 100));
|
|
660
|
+
for (const name of names) {
|
|
661
|
+
if (processed >= limit || options.signal?.aborted) break;
|
|
662
|
+
const pendingPath = join(directories.pending, name);
|
|
663
|
+
let event: DashboardRefreshEvent;
|
|
664
|
+
try {
|
|
665
|
+
event = await readEvent(pendingPath);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
await rename(pendingPath, join(directories.dead, name));
|
|
668
|
+
failed += 1;
|
|
669
|
+
processed += 1;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (event.not_before && Date.parse(event.not_before) > now.getTime()) continue;
|
|
673
|
+
const processingPath = join(directories.processing, name);
|
|
674
|
+
try {
|
|
675
|
+
await rename(pendingPath, processingPath);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
678
|
+
throw error;
|
|
679
|
+
}
|
|
680
|
+
processed += 1;
|
|
681
|
+
try {
|
|
682
|
+
const result = await (options.worker ?? defaultWorker)(event, await resolveEventProject(workspaceRoot, event.project_name), options.signal);
|
|
683
|
+
await atomicReplace(join(directories.completed, name), `${JSON.stringify({
|
|
684
|
+
kind: "fpa.dashboard.refresh-result",
|
|
685
|
+
schema_version: 1,
|
|
686
|
+
event_id: event.event_id,
|
|
687
|
+
completed_at: now.toISOString(),
|
|
688
|
+
...result,
|
|
689
|
+
}, null, 2)}\n`);
|
|
690
|
+
await recordSubscriptionSuccess(directories, event, result, now);
|
|
691
|
+
await unlink(processingPath);
|
|
692
|
+
succeeded += 1;
|
|
693
|
+
} catch (error) {
|
|
694
|
+
const message = (error instanceof Error ? error.message : String(error)).slice(0, 2048);
|
|
695
|
+
if (error instanceof ObsoleteDashboardRefreshError) {
|
|
696
|
+
await atomicReplace(join(directories.completed, name), `${JSON.stringify({
|
|
697
|
+
kind: "fpa.dashboard.refresh-result",
|
|
698
|
+
schema_version: 1,
|
|
699
|
+
event_id: event.event_id,
|
|
700
|
+
status: "superseded",
|
|
701
|
+
completed_at: now.toISOString(),
|
|
702
|
+
message,
|
|
703
|
+
}, null, 2)}\n`);
|
|
704
|
+
await unlink(processingPath);
|
|
705
|
+
superseded += 1;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const attempt = event.attempt + 1;
|
|
709
|
+
if (attempt >= MAX_ATTEMPTS) {
|
|
710
|
+
await atomicReplace(join(directories.dead, name), `${JSON.stringify({ ...event, attempt, last_error: message, failed_at: now.toISOString() }, null, 2)}\n`);
|
|
711
|
+
await unlink(processingPath);
|
|
712
|
+
failed += 1;
|
|
713
|
+
} else {
|
|
714
|
+
const notBefore = new Date(now.getTime() + (2 ** (attempt - 1)) * 30_000).toISOString();
|
|
715
|
+
await atomicReplace(join(directories.pending, name), `${JSON.stringify({ ...event, attempt, last_error: message, not_before: notBefore }, null, 2)}\n`);
|
|
716
|
+
await unlink(processingPath);
|
|
717
|
+
retried += 1;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} finally {
|
|
722
|
+
await release();
|
|
723
|
+
}
|
|
724
|
+
return { status: "processed", processed, succeeded, superseded, retried, failed, ...await inspectDashboardRefreshQueue(projectRoot), busy: false };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export interface PollDashboardActualsResult {
|
|
728
|
+
status: "idle" | "not_due" | "pending_exists" | "unchanged" | "enqueued";
|
|
729
|
+
event_id?: string;
|
|
730
|
+
generation_id?: string;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function dashboardProjectionIsCurrent(
|
|
734
|
+
last: { actuals_watermark?: string; generation_id?: string },
|
|
735
|
+
desired: { actuals_watermark?: string; generation_id: string },
|
|
736
|
+
): boolean {
|
|
737
|
+
return Boolean(desired.actuals_watermark)
|
|
738
|
+
&& desired.actuals_watermark === last.actuals_watermark
|
|
739
|
+
&& desired.generation_id === last.generation_id;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export async function pollDashboardActualsWatermark(
|
|
743
|
+
cwd: string,
|
|
744
|
+
options: { signal?: AbortSignal; now?: () => Date } = {},
|
|
745
|
+
): Promise<PollDashboardActualsResult> {
|
|
746
|
+
const projectRoot = await realpath(cwd);
|
|
747
|
+
const workspaceRoot = dirname(projectRoot);
|
|
748
|
+
const directories = await queueDirectories(projectRoot);
|
|
749
|
+
if (await countJsonFiles(directories.pending) > 0 || await countJsonFiles(directories.processing) > 0) return { status: "pending_exists" };
|
|
750
|
+
const subscription = await activeSubscription(directories);
|
|
751
|
+
if (!subscription) return { status: "idle" };
|
|
752
|
+
const now = options.now?.() ?? new Date();
|
|
753
|
+
if (subscription.next_poll_at && Date.parse(subscription.next_poll_at) > now.getTime()) return { status: "not_due" };
|
|
754
|
+
const targetProject = await resolveEventProject(workspaceRoot, subscription.project_name);
|
|
755
|
+
const projection = await buildDashboardProjection(targetProject, subscription.preset, subscription.locale, {
|
|
756
|
+
scopeId: subscription.scope_id,
|
|
757
|
+
cycleId: subscription.cycle_id,
|
|
758
|
+
forecastRef: subscription.forecast_ref,
|
|
759
|
+
...(subscription.execution_ref ? { executionRef: subscription.execution_ref } : {}),
|
|
760
|
+
...(subscription.next_forecast_ref ? { nextForecastRef: subscription.next_forecast_ref } : {}),
|
|
761
|
+
...(subscription.forward_forecast_refs ? { forwardForecastRefs: subscription.forward_forecast_refs } : {}),
|
|
762
|
+
}, options.signal);
|
|
763
|
+
const generationId = dashboardBuildFingerprint(projection.build, projection.projector);
|
|
764
|
+
const actualsWatermark = projection.build.source.actuals_watermark;
|
|
765
|
+
const path = join(directories.subscriptions, `${subscription.subscription_id}.json`);
|
|
766
|
+
if (dashboardProjectionIsCurrent(
|
|
767
|
+
{ actuals_watermark: subscription.last_actuals_watermark, generation_id: subscription.last_generation_id },
|
|
768
|
+
{ actuals_watermark: actualsWatermark, generation_id: generationId },
|
|
769
|
+
)) {
|
|
770
|
+
await atomicReplace(path, `${JSON.stringify({
|
|
771
|
+
...subscription,
|
|
772
|
+
desired_generation_id: generationId,
|
|
773
|
+
next_poll_at: new Date(now.getTime() + ACTUALS_POLL_INTERVAL_MS).toISOString(),
|
|
774
|
+
updated_at: now.toISOString(),
|
|
775
|
+
}, null, 2)}\n`);
|
|
776
|
+
return { status: "unchanged", generation_id: generationId };
|
|
777
|
+
}
|
|
778
|
+
await atomicReplace(path, `${JSON.stringify({
|
|
779
|
+
...subscription,
|
|
780
|
+
desired_generation_id: generationId,
|
|
781
|
+
updated_at: now.toISOString(),
|
|
782
|
+
}, null, 2)}\n`);
|
|
783
|
+
const enqueued = await enqueueDashboardRefresh(targetProject, {
|
|
784
|
+
preset: subscription.preset,
|
|
785
|
+
locale: subscription.locale,
|
|
786
|
+
scope_id: subscription.scope_id,
|
|
787
|
+
cycle_id: subscription.cycle_id,
|
|
788
|
+
forecast_ref: subscription.forecast_ref,
|
|
789
|
+
...(subscription.forecast_role ? { forecast_role: subscription.forecast_role } : {}),
|
|
790
|
+
...(subscription.execution_ref ? { execution_ref: subscription.execution_ref } : {}),
|
|
791
|
+
...(subscription.next_forecast_ref ? { next_forecast_ref: subscription.next_forecast_ref } : {}),
|
|
792
|
+
...(subscription.forward_forecast_refs ? { forward_forecast_refs: subscription.forward_forecast_refs } : {}),
|
|
793
|
+
reason: actualsWatermark === subscription.last_actuals_watermark ? "generation_identity" : "actuals_watermark",
|
|
794
|
+
actuals_watermark: actualsWatermark ?? generationId,
|
|
795
|
+
desired_generation_id: generationId,
|
|
796
|
+
});
|
|
797
|
+
return { status: "enqueued", event_id: enqueued.event_id, generation_id: generationId };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export async function runDashboardRefreshCoordinator(
|
|
801
|
+
cwd: string,
|
|
802
|
+
options: ProcessDashboardRefreshQueueOptions = {},
|
|
803
|
+
): Promise<{ poll: PollDashboardActualsResult; queue: ProcessDashboardRefreshQueueResult }> {
|
|
804
|
+
const operationNow = options.now?.() ?? new Date();
|
|
805
|
+
try {
|
|
806
|
+
const poll = await pollDashboardActualsWatermark(cwd, { signal: options.signal, now: () => operationNow });
|
|
807
|
+
const queue = await processDashboardRefreshQueue(cwd, { ...options, now: () => operationNow });
|
|
808
|
+
const directories = await queueDirectories(cwd);
|
|
809
|
+
const subscription = await activeSubscription(directories);
|
|
810
|
+
const completedNow = options.now?.() ?? new Date();
|
|
811
|
+
await writeCoordinatorStatus(cwd, {
|
|
812
|
+
now: completedNow,
|
|
813
|
+
queue,
|
|
814
|
+
state: queue.dead > 0 || queue.failed > 0 ? "degraded" : queue.pending + queue.processing > 0 ? "refreshing" : subscription ? "healthy" : "idle",
|
|
815
|
+
lastSuccess: queue.succeeded > 0,
|
|
816
|
+
lastActualsWatermark: subscription?.last_actuals_watermark ?? null,
|
|
817
|
+
nextPollAt: subscription?.next_poll_at ?? null,
|
|
818
|
+
subscription,
|
|
819
|
+
lastError: queue.failed > 0 ? `${queue.failed} refresh event(s) moved to dead letter.` : null,
|
|
820
|
+
});
|
|
821
|
+
return { poll, queue };
|
|
822
|
+
} catch (error) {
|
|
823
|
+
const failedNow = options.now?.() ?? new Date();
|
|
824
|
+
await writeCoordinatorStatus(cwd, {
|
|
825
|
+
now: failedNow,
|
|
826
|
+
state: "failed",
|
|
827
|
+
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 2048),
|
|
828
|
+
}).catch(() => undefined);
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
}
|