@viccydev/pi-fpa 0.9.4 → 0.9.6
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/calibration-projector.ts +359 -0
- package/extensions/fpa-dashboard/compat-publisher.ts +89 -3
- package/extensions/fpa-dashboard/decision-ledger-projector.ts +323 -0
- package/extensions/fpa-dashboard/decision-package.ts +437 -0
- package/extensions/fpa-dashboard/finance-projector.ts +375 -0
- package/extensions/fpa-dashboard/index.ts +186 -0
- package/extensions/fpa-dashboard/projector.ts +26 -1
- package/extensions/fpa-dashboard/schema.ts +57 -1
- package/extensions/fpa-dashboard/strategy-decision.ts +72 -1
- package/extensions/fpa-data/csvsource.ts +296 -0
- package/extensions/fpa-data/registry.ts +324 -0
- package/extensions/fpa-data/runtime.ts +21 -1
- package/package.json +3 -3
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { link, lstat, mkdir, open, readFile, readdir, realpath, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Decision packages.
|
|
9
|
+
//
|
|
10
|
+
// A confirmed strategy used to leave no trace a person could follow: the
|
|
11
|
+
// dashboard posted an action, the agent re-ran a Graph, and the decision
|
|
12
|
+
// itself stopped existing. A decision package is that decision made into an
|
|
13
|
+
// object with a lifecycle — what was approved, by whom, against which budget,
|
|
14
|
+
// and what happened afterwards.
|
|
15
|
+
//
|
|
16
|
+
// Two stores, because the data has two different natures:
|
|
17
|
+
//
|
|
18
|
+
// * The package is written once and never edited. It carries the parameter
|
|
19
|
+
// snapshot as it stood at approval. If this were mutable, "what was
|
|
20
|
+
// approved" would drift into "what we ended up doing", and the ledger
|
|
21
|
+
// would lose the only thing it exists to prove.
|
|
22
|
+
//
|
|
23
|
+
// * Events append. Execution progress, threshold breaches, and task
|
|
24
|
+
// transitions arrive over time and must never overwrite each other.
|
|
25
|
+
//
|
|
26
|
+
// Deliberately NOT an entry in the artifact ledger: that ledger holds the
|
|
27
|
+
// immutable forecast/execution chain, addressed by content fingerprint. A
|
|
28
|
+
// package accumulates history, so it would have to be re-committed on every
|
|
29
|
+
// event, and each re-commit would mint a new identity for the same decision.
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
33
|
+
const DECISION_ID_RE = /^DEC-\d{4}-\d{4}-[a-f0-9]{8}$/;
|
|
34
|
+
const PACKAGES_DIR = "decision-packages";
|
|
35
|
+
|
|
36
|
+
export const DECISION_STATUSES = ["in_progress", "completed", "variance", "cancelled"] as const;
|
|
37
|
+
export type DecisionStatus = (typeof DECISION_STATUSES)[number];
|
|
38
|
+
|
|
39
|
+
export const DECISION_EVENT_TYPES = [
|
|
40
|
+
"approved",
|
|
41
|
+
"parameters_locked",
|
|
42
|
+
"task_accepted",
|
|
43
|
+
"task_completed",
|
|
44
|
+
"parameter_adjusted",
|
|
45
|
+
"threshold_breached",
|
|
46
|
+
"closed",
|
|
47
|
+
] as const;
|
|
48
|
+
export type DecisionEventType = (typeof DECISION_EVENT_TYPES)[number];
|
|
49
|
+
|
|
50
|
+
export const DECISION_TASK_STATUSES = ["not_started", "in_progress", "completed", "adjusted", "synced", "configured", "not_completed"] as const;
|
|
51
|
+
export type DecisionTaskStatus = (typeof DECISION_TASK_STATUSES)[number];
|
|
52
|
+
|
|
53
|
+
export interface DecisionApproval {
|
|
54
|
+
step_order: number;
|
|
55
|
+
role: string;
|
|
56
|
+
approver: string;
|
|
57
|
+
action: "approved" | "acknowledged" | "rejected";
|
|
58
|
+
decided_at: string;
|
|
59
|
+
comment?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DecisionTask {
|
|
63
|
+
task_id: string;
|
|
64
|
+
task_name: string;
|
|
65
|
+
owner: string;
|
|
66
|
+
due_date: string;
|
|
67
|
+
status: DecisionTaskStatus;
|
|
68
|
+
completed_at?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DecisionPackage {
|
|
72
|
+
kind: "fpa.decision.package";
|
|
73
|
+
schema_version: 1;
|
|
74
|
+
decision_id: string;
|
|
75
|
+
version: string;
|
|
76
|
+
title: string;
|
|
77
|
+
scope_id: string;
|
|
78
|
+
cycle_id: string;
|
|
79
|
+
strategy_version: string;
|
|
80
|
+
handoff_fingerprint: string;
|
|
81
|
+
action_id: string;
|
|
82
|
+
owner: string | null;
|
|
83
|
+
approver_role: string;
|
|
84
|
+
/** Null when the approved strategy carries no budget the ledger can track. */
|
|
85
|
+
approved_budget_usd: number | null;
|
|
86
|
+
/** Fraction over budget that trips an alert, e.g. 0.10 for +10%. */
|
|
87
|
+
budget_threshold_pct: number;
|
|
88
|
+
reporting_currency: string;
|
|
89
|
+
approved_at: string;
|
|
90
|
+
locked_at: string;
|
|
91
|
+
/** The parameters as approved. Never edited — see the note above. */
|
|
92
|
+
parameters: Record<string, unknown>;
|
|
93
|
+
approvals: DecisionApproval[];
|
|
94
|
+
tasks: DecisionTask[];
|
|
95
|
+
memo_ref: string | null;
|
|
96
|
+
/** Integrity over everything above. */
|
|
97
|
+
package_fingerprint: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DecisionEvent {
|
|
101
|
+
kind: "fpa.decision.event";
|
|
102
|
+
schema_version: 1;
|
|
103
|
+
decision_id: string;
|
|
104
|
+
event_at: string;
|
|
105
|
+
actor: string;
|
|
106
|
+
actor_type: "human" | "system";
|
|
107
|
+
event_type: DecisionEventType;
|
|
108
|
+
summary: string;
|
|
109
|
+
reason?: string;
|
|
110
|
+
change_detail?: string;
|
|
111
|
+
estimated_impact_usd?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Identifiers and titles: no control characters at all, DEL included. */
|
|
115
|
+
const CONTROL_CHARS = new RegExp("[\\u0000-\\u001f\\u007f]");
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Free text a person wrote — an approval comment, a reason for a change.
|
|
119
|
+
* Newlines and tabs are content there, so only the remaining control
|
|
120
|
+
* characters are rejected.
|
|
121
|
+
*/
|
|
122
|
+
const CONTROL_CHARS_ALLOWING_NEWLINES = new RegExp("[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]");
|
|
123
|
+
|
|
124
|
+
function sha256(value: string): string {
|
|
125
|
+
return createHash("sha256").update(value).digest("hex");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function boundedString(value: unknown, label: string, max = 512): string {
|
|
129
|
+
if (typeof value !== "string" || value.trim() === "" || value.length > max || CONTROL_CHARS.test(value)) {
|
|
130
|
+
throw new Error(`${label} must be a non-empty bounded string without control characters.`);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function optionalBoundedString(value: unknown, label: string, max = 4_096): string | undefined {
|
|
136
|
+
if (value === undefined || value === null) return undefined;
|
|
137
|
+
if (typeof value !== "string" || value.length > max || CONTROL_CHARS_ALLOWING_NEWLINES.test(value)) {
|
|
138
|
+
throw new Error(`${label} must be a bounded string without control characters.`);
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isoTimestamp(value: unknown, label: string): string {
|
|
144
|
+
const text = boundedString(value, label, 64);
|
|
145
|
+
if (Number.isNaN(Date.parse(text))) throw new Error(`${label} must be an ISO timestamp.`);
|
|
146
|
+
return text;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function finiteNumber(value: unknown, label: string): number {
|
|
150
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a finite number.`);
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function enumValue<T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
|
|
155
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
156
|
+
throw new Error(`${label} must be one of ${allowed.join(", ")}.`);
|
|
157
|
+
}
|
|
158
|
+
return value as T[number];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function ensureDirectory(parent: string, name: string): Promise<string> {
|
|
162
|
+
const path = join(parent, name);
|
|
163
|
+
try {
|
|
164
|
+
const stat = await lstat(path);
|
|
165
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${name} must be a regular directory, not a symlink.`);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
168
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
169
|
+
}
|
|
170
|
+
return path;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function appendOnlyWrite(directory: string, destination: string, contents: string): Promise<"created" | "exists"> {
|
|
174
|
+
const temporary = join(directory, `.${randomUUID()}.tmp`);
|
|
175
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
176
|
+
try {
|
|
177
|
+
await handle.writeFile(contents, "utf8");
|
|
178
|
+
await handle.sync();
|
|
179
|
+
await handle.close();
|
|
180
|
+
try {
|
|
181
|
+
await link(temporary, destination);
|
|
182
|
+
return "created";
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return "exists";
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
await handle.close().catch(() => undefined);
|
|
189
|
+
await unlink(temporary).catch(() => undefined);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A human-readable id with a content-addressed tail.
|
|
195
|
+
*
|
|
196
|
+
* The ledger is read by people discussing a specific decision, so `DEC-2026-
|
|
197
|
+
* 0718-3f9a1c02` beats a bare digest in a meeting. The tail still binds the id
|
|
198
|
+
* to the approval it came from, so two decisions cannot collide on the same day.
|
|
199
|
+
*/
|
|
200
|
+
export function decisionIdFor(input: { actionId: string; strategyVersion: string; approvedAt: string }): string {
|
|
201
|
+
const date = new Date(input.approvedAt);
|
|
202
|
+
if (Number.isNaN(date.getTime())) throw new Error("approvedAt must be an ISO timestamp.");
|
|
203
|
+
const year = date.getUTCFullYear();
|
|
204
|
+
const monthDay = `${String(date.getUTCMonth() + 1).padStart(2, "0")}${String(date.getUTCDate()).padStart(2, "0")}`;
|
|
205
|
+
const tail = sha256(stableJson({ action_id: input.actionId, strategy_version: input.strategyVersion })).slice(0, 8);
|
|
206
|
+
return `DEC-${year}-${monthDay}-${tail}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface CreateDecisionPackageInput {
|
|
210
|
+
actionId: string;
|
|
211
|
+
strategyVersion: string;
|
|
212
|
+
handoffFingerprint: string;
|
|
213
|
+
title: string;
|
|
214
|
+
scopeId: string;
|
|
215
|
+
cycleId: string;
|
|
216
|
+
approverRole: string;
|
|
217
|
+
owner?: string | null;
|
|
218
|
+
approvedBudgetUsd?: number | null;
|
|
219
|
+
budgetThresholdPct?: number;
|
|
220
|
+
reportingCurrency?: string;
|
|
221
|
+
parameters?: Record<string, unknown>;
|
|
222
|
+
approvals?: DecisionApproval[];
|
|
223
|
+
tasks?: DecisionTask[];
|
|
224
|
+
memoRef?: string | null;
|
|
225
|
+
version?: string;
|
|
226
|
+
approvedAt?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function validateApprovals(value: unknown): DecisionApproval[] {
|
|
230
|
+
if (value === undefined) return [];
|
|
231
|
+
if (!Array.isArray(value) || value.length > 16) throw new Error("approvals must be an array of at most 16 entries.");
|
|
232
|
+
return value.map((raw, index) => {
|
|
233
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`approvals[${index}] must be an object.`);
|
|
234
|
+
const source = raw as Record<string, unknown>;
|
|
235
|
+
return {
|
|
236
|
+
step_order: finiteNumber(source.step_order, `approvals[${index}].step_order`),
|
|
237
|
+
role: boundedString(source.role, `approvals[${index}].role`, 128),
|
|
238
|
+
approver: boundedString(source.approver, `approvals[${index}].approver`, 128),
|
|
239
|
+
action: enumValue(source.action, ["approved", "acknowledged", "rejected"] as const, `approvals[${index}].action`),
|
|
240
|
+
decided_at: isoTimestamp(source.decided_at, `approvals[${index}].decided_at`),
|
|
241
|
+
...(optionalBoundedString(source.comment, `approvals[${index}].comment`) !== undefined
|
|
242
|
+
? { comment: optionalBoundedString(source.comment, `approvals[${index}].comment`) as string }
|
|
243
|
+
: {}),
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function validateTasks(value: unknown): DecisionTask[] {
|
|
249
|
+
if (value === undefined) return [];
|
|
250
|
+
if (!Array.isArray(value) || value.length > 64) throw new Error("tasks must be an array of at most 64 entries.");
|
|
251
|
+
return value.map((raw, index) => {
|
|
252
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`tasks[${index}] must be an object.`);
|
|
253
|
+
const source = raw as Record<string, unknown>;
|
|
254
|
+
return {
|
|
255
|
+
task_id: boundedString(source.task_id, `tasks[${index}].task_id`, 64),
|
|
256
|
+
task_name: boundedString(source.task_name, `tasks[${index}].task_name`, 256),
|
|
257
|
+
owner: boundedString(source.owner, `tasks[${index}].owner`, 128),
|
|
258
|
+
due_date: boundedString(source.due_date, `tasks[${index}].due_date`, 32),
|
|
259
|
+
status: enumValue(source.status, DECISION_TASK_STATUSES, `tasks[${index}].status`),
|
|
260
|
+
...(source.completed_at ? { completed_at: isoTimestamp(source.completed_at, `tasks[${index}].completed_at`) } : {}),
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Write the package for a confirmed strategy. Idempotent: committing the same
|
|
267
|
+
* decision twice returns the existing package rather than minting a second one.
|
|
268
|
+
*/
|
|
269
|
+
export async function createDecisionPackage(cwd: string, input: CreateDecisionPackageInput): Promise<DecisionPackage> {
|
|
270
|
+
if (!SHA256_RE.test(input.actionId)) throw new Error("actionId must be a SHA-256 digest.");
|
|
271
|
+
if (!SHA256_RE.test(input.handoffFingerprint)) throw new Error("handoffFingerprint must be a SHA-256 digest.");
|
|
272
|
+
const approvedAt = input.approvedAt ?? new Date().toISOString();
|
|
273
|
+
const budgetThreshold = input.budgetThresholdPct ?? 0.1;
|
|
274
|
+
if (budgetThreshold < 0 || budgetThreshold > 1) throw new Error("budgetThresholdPct must be between 0 and 1.");
|
|
275
|
+
const approvedBudget = input.approvedBudgetUsd ?? null;
|
|
276
|
+
if (approvedBudget !== null) finiteNumber(approvedBudget, "approvedBudgetUsd");
|
|
277
|
+
|
|
278
|
+
const decisionId = decisionIdFor({ actionId: input.actionId, strategyVersion: input.strategyVersion, approvedAt });
|
|
279
|
+
const core = {
|
|
280
|
+
kind: "fpa.decision.package" as const,
|
|
281
|
+
schema_version: 1 as const,
|
|
282
|
+
decision_id: decisionId,
|
|
283
|
+
version: input.version ?? "v1",
|
|
284
|
+
title: boundedString(input.title, "title", 256),
|
|
285
|
+
scope_id: boundedString(input.scopeId, "scopeId", 256),
|
|
286
|
+
cycle_id: boundedString(input.cycleId, "cycleId", 256),
|
|
287
|
+
strategy_version: boundedString(input.strategyVersion, "strategyVersion"),
|
|
288
|
+
handoff_fingerprint: input.handoffFingerprint,
|
|
289
|
+
action_id: input.actionId,
|
|
290
|
+
owner: input.owner ? boundedString(input.owner, "owner", 128) : null,
|
|
291
|
+
approver_role: boundedString(input.approverRole, "approverRole", 128),
|
|
292
|
+
approved_budget_usd: approvedBudget,
|
|
293
|
+
budget_threshold_pct: budgetThreshold,
|
|
294
|
+
reporting_currency: input.reportingCurrency ?? "USD",
|
|
295
|
+
approved_at: approvedAt,
|
|
296
|
+
// The snapshot is sealed at the same instant it is approved: a gap
|
|
297
|
+
// between the two is a window in which the parameters could change.
|
|
298
|
+
locked_at: approvedAt,
|
|
299
|
+
parameters: input.parameters ?? {},
|
|
300
|
+
approvals: validateApprovals(input.approvals),
|
|
301
|
+
tasks: validateTasks(input.tasks),
|
|
302
|
+
memo_ref: input.memoRef ?? null,
|
|
303
|
+
};
|
|
304
|
+
const pkg: DecisionPackage = { ...core, package_fingerprint: sha256(stableJson(core)) };
|
|
305
|
+
|
|
306
|
+
const projectRoot = await realpath(cwd);
|
|
307
|
+
const artifactsDir = await ensureDirectory(projectRoot, "artifacts");
|
|
308
|
+
const packagesDir = await ensureDirectory(artifactsDir, PACKAGES_DIR);
|
|
309
|
+
const path = join(packagesDir, `${decisionId}.json`);
|
|
310
|
+
const write = await appendOnlyWrite(packagesDir, path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
311
|
+
if (write === "exists") {
|
|
312
|
+
const existing = await readDecisionPackage(cwd, decisionId);
|
|
313
|
+
if (existing.package_fingerprint !== pkg.package_fingerprint) {
|
|
314
|
+
throw new Error(`Decision package ${decisionId} already exists with different content.`);
|
|
315
|
+
}
|
|
316
|
+
return existing;
|
|
317
|
+
}
|
|
318
|
+
await appendDecisionEvent(cwd, {
|
|
319
|
+
decision_id: decisionId,
|
|
320
|
+
event_at: approvedAt,
|
|
321
|
+
actor: input.approverRole,
|
|
322
|
+
actor_type: "human",
|
|
323
|
+
event_type: "approved",
|
|
324
|
+
summary: `${input.approverRole} approved ${core.title}`,
|
|
325
|
+
});
|
|
326
|
+
await appendDecisionEvent(cwd, {
|
|
327
|
+
decision_id: decisionId,
|
|
328
|
+
event_at: approvedAt,
|
|
329
|
+
actor: "system",
|
|
330
|
+
actor_type: "system",
|
|
331
|
+
event_type: "parameters_locked",
|
|
332
|
+
summary: "Parameter snapshot locked; the approved parameters are now immutable.",
|
|
333
|
+
});
|
|
334
|
+
return pkg;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function validateDecisionPackage(value: unknown, label = "decision package"): DecisionPackage {
|
|
338
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
339
|
+
const source = value as Record<string, unknown>;
|
|
340
|
+
if (source.kind !== "fpa.decision.package" || source.schema_version !== 1) throw new Error(`${label} kind or schema version is unsupported.`);
|
|
341
|
+
const decisionId = boundedString(source.decision_id, `${label}.decision_id`, 64);
|
|
342
|
+
if (!DECISION_ID_RE.test(decisionId)) throw new Error(`${label}.decision_id is malformed.`);
|
|
343
|
+
if (typeof source.package_fingerprint !== "string" || !SHA256_RE.test(source.package_fingerprint)) {
|
|
344
|
+
throw new Error(`${label}.package_fingerprint must be a SHA-256 digest.`);
|
|
345
|
+
}
|
|
346
|
+
const { package_fingerprint: fingerprint, ...core } = source;
|
|
347
|
+
if (sha256(stableJson(core)) !== fingerprint) throw new Error(`${label} fingerprint does not match its content.`);
|
|
348
|
+
return source as unknown as DecisionPackage;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function readDecisionPackage(cwd: string, decisionId: string): Promise<DecisionPackage> {
|
|
352
|
+
if (!DECISION_ID_RE.test(decisionId)) throw new Error("decisionId is malformed.");
|
|
353
|
+
const projectRoot = await realpath(cwd);
|
|
354
|
+
const path = join(projectRoot, "artifacts", PACKAGES_DIR, `${decisionId}.json`);
|
|
355
|
+
const stat = await lstat(path);
|
|
356
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Decision package must be a regular file, not a symlink.");
|
|
357
|
+
if (stat.size > 512 * 1024) throw new Error("Decision package exceeds its 512KB limit.");
|
|
358
|
+
return validateDecisionPackage(JSON.parse(await readFile(path, "utf8")), `decision package ${decisionId}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export async function listDecisionPackages(cwd: string): Promise<DecisionPackage[]> {
|
|
362
|
+
const projectRoot = await realpath(cwd);
|
|
363
|
+
const packagesDir = join(projectRoot, "artifacts", PACKAGES_DIR);
|
|
364
|
+
let names: string[];
|
|
365
|
+
try {
|
|
366
|
+
names = await readdir(packagesDir);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
const packages: DecisionPackage[] = [];
|
|
372
|
+
for (const name of names.filter((entry) => entry.endsWith(".json")).slice(0, 500)) {
|
|
373
|
+
const decisionId = name.slice(0, -".json".length);
|
|
374
|
+
if (!DECISION_ID_RE.test(decisionId)) continue;
|
|
375
|
+
packages.push(await readDecisionPackage(cwd, decisionId));
|
|
376
|
+
}
|
|
377
|
+
// Newest first: the decision someone is asking about is usually the last one.
|
|
378
|
+
return packages.sort((left, right) => right.approved_at.localeCompare(left.approved_at));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function appendDecisionEvent(cwd: string, event: Omit<DecisionEvent, "kind" | "schema_version">): Promise<DecisionEvent> {
|
|
382
|
+
if (!DECISION_ID_RE.test(event.decision_id)) throw new Error("decision_id is malformed.");
|
|
383
|
+
const record: DecisionEvent = {
|
|
384
|
+
kind: "fpa.decision.event",
|
|
385
|
+
schema_version: 1,
|
|
386
|
+
decision_id: event.decision_id,
|
|
387
|
+
event_at: isoTimestamp(event.event_at, "event_at"),
|
|
388
|
+
actor: boundedString(event.actor, "actor", 128),
|
|
389
|
+
actor_type: enumValue(event.actor_type, ["human", "system"] as const, "actor_type"),
|
|
390
|
+
event_type: enumValue(event.event_type, DECISION_EVENT_TYPES, "event_type"),
|
|
391
|
+
summary: boundedString(event.summary, "summary", 1_024),
|
|
392
|
+
...(optionalBoundedString(event.reason, "reason") !== undefined ? { reason: event.reason as string } : {}),
|
|
393
|
+
...(optionalBoundedString(event.change_detail, "change_detail") !== undefined ? { change_detail: event.change_detail as string } : {}),
|
|
394
|
+
...(event.estimated_impact_usd !== undefined ? { estimated_impact_usd: finiteNumber(event.estimated_impact_usd, "estimated_impact_usd") } : {}),
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const projectRoot = await realpath(cwd);
|
|
398
|
+
const artifactsDir = await ensureDirectory(projectRoot, "artifacts");
|
|
399
|
+
const packagesDir = await ensureDirectory(artifactsDir, PACKAGES_DIR);
|
|
400
|
+
const path = join(packagesDir, `${event.decision_id}.events.jsonl`);
|
|
401
|
+
// Append with O_APPEND so concurrent writers interleave whole lines rather
|
|
402
|
+
// than overwriting each other's offsets.
|
|
403
|
+
const handle = await open(path, "a", 0o600);
|
|
404
|
+
try {
|
|
405
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
406
|
+
await handle.sync();
|
|
407
|
+
} finally {
|
|
408
|
+
await handle.close();
|
|
409
|
+
}
|
|
410
|
+
return record;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function readDecisionEvents(cwd: string, decisionId: string): Promise<DecisionEvent[]> {
|
|
414
|
+
if (!DECISION_ID_RE.test(decisionId)) throw new Error("decisionId is malformed.");
|
|
415
|
+
const projectRoot = await realpath(cwd);
|
|
416
|
+
const path = join(projectRoot, "artifacts", PACKAGES_DIR, `${decisionId}.events.jsonl`);
|
|
417
|
+
let raw: string;
|
|
418
|
+
try {
|
|
419
|
+
const stat = await lstat(path);
|
|
420
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Decision event log must be a regular file, not a symlink.");
|
|
421
|
+
if (stat.size > 4 * 1024 * 1024) throw new Error("Decision event log exceeds its 4MB limit.");
|
|
422
|
+
raw = await readFile(path, "utf8");
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
const events: DecisionEvent[] = [];
|
|
428
|
+
for (const [index, line] of raw.split("\n").entries()) {
|
|
429
|
+
if (line.trim() === "") continue;
|
|
430
|
+
const parsed = JSON.parse(line) as DecisionEvent;
|
|
431
|
+
if (parsed.kind !== "fpa.decision.event" || parsed.schema_version !== 1 || parsed.decision_id !== decisionId) {
|
|
432
|
+
throw new Error(`Decision event log line ${index + 1} does not belong to ${decisionId}.`);
|
|
433
|
+
}
|
|
434
|
+
events.push(parsed);
|
|
435
|
+
}
|
|
436
|
+
return events.sort((left, right) => left.event_at.localeCompare(right.event_at));
|
|
437
|
+
}
|