@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
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { composeApprovedForecast } from "./compose.ts";
|
|
5
|
+
import { enqueueDashboardRefresh } from "../fpa-dashboard/coordinator.ts";
|
|
6
|
+
import {
|
|
7
|
+
commitArtifact,
|
|
8
|
+
commitArtifactFromPath,
|
|
9
|
+
readArtifactByRef,
|
|
10
|
+
readProjectJsonFile,
|
|
11
|
+
type ArtifactRefV2,
|
|
12
|
+
} from "./store.ts";
|
|
5
13
|
|
|
6
14
|
function toolResult(value: Record<string, unknown>) {
|
|
7
15
|
return {
|
|
@@ -10,6 +18,17 @@ function toolResult(value: Record<string, unknown>) {
|
|
|
10
18
|
};
|
|
11
19
|
}
|
|
12
20
|
|
|
21
|
+
const artifactRefSchema = Type.Object({
|
|
22
|
+
scope_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
23
|
+
cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
24
|
+
artifact_type: Type.Union([
|
|
25
|
+
Type.Literal("approved_cycle_forecast"),
|
|
26
|
+
Type.Literal("execution_receipt"),
|
|
27
|
+
]),
|
|
28
|
+
entry_id: Type.String({ pattern: "^[a-f0-9]{64}$" }),
|
|
29
|
+
body_fingerprint: Type.String({ pattern: "^[a-f0-9]{64}$" }),
|
|
30
|
+
}, { additionalProperties: false });
|
|
31
|
+
|
|
13
32
|
export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
14
33
|
pi.registerTool({
|
|
15
34
|
name: "fpa_artifact_commit",
|
|
@@ -31,6 +50,13 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
31
50
|
minLength: 1,
|
|
32
51
|
description: "Project-relative path to a JSON file holding the canonical artifact. Mutually exclusive with artifact.",
|
|
33
52
|
})),
|
|
53
|
+
context: Type.Optional(Type.Object({
|
|
54
|
+
scope_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
55
|
+
cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
56
|
+
forecast_role: Type.Optional(Type.Union([Type.Literal("original"), Type.Literal("eac"), Type.Literal("next_plan")])),
|
|
57
|
+
upstream_refs: Type.Optional(Type.Array(artifactRefSchema, { maxItems: 16 })),
|
|
58
|
+
promote_legacy_pointer: Type.Optional(Type.Boolean()),
|
|
59
|
+
}, { additionalProperties: false })),
|
|
34
60
|
},
|
|
35
61
|
{ additionalProperties: false },
|
|
36
62
|
),
|
|
@@ -41,14 +67,103 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
41
67
|
if (hasInline === hasPath) {
|
|
42
68
|
throw new Error("Supply exactly one of artifact or artifact_path.");
|
|
43
69
|
}
|
|
70
|
+
const commitOptions = params.context ? {
|
|
71
|
+
context: {
|
|
72
|
+
scope_id: params.context.scope_id,
|
|
73
|
+
cycle_id: params.context.cycle_id,
|
|
74
|
+
forecast_role: params.context.forecast_role,
|
|
75
|
+
upstream_refs: params.context.upstream_refs as ArtifactRefV2[] | undefined,
|
|
76
|
+
},
|
|
77
|
+
promoteLegacyPointer: params.context.promote_legacy_pointer,
|
|
78
|
+
} : undefined;
|
|
44
79
|
const committed = hasPath
|
|
45
|
-
? await commitArtifactFromPath(ctx.cwd, params.artifact_path as string)
|
|
46
|
-
: await commitArtifact(ctx.cwd, params.artifact);
|
|
80
|
+
? await commitArtifactFromPath(ctx.cwd, params.artifact_path as string, commitOptions)
|
|
81
|
+
: await commitArtifact(ctx.cwd, params.artifact, commitOptions);
|
|
82
|
+
let dashboardRefresh;
|
|
83
|
+
let dashboardRefreshError: string | undefined;
|
|
84
|
+
if (committed.artifactRef && params.context) {
|
|
85
|
+
try {
|
|
86
|
+
const forecastRef = committed.artifactType === "approved_cycle_forecast"
|
|
87
|
+
? committed.artifactRef
|
|
88
|
+
: params.context.upstream_refs?.find((ref) => ref.artifact_type === "approved_cycle_forecast") as ArtifactRefV2 | undefined;
|
|
89
|
+
if (!forecastRef) throw new Error("Assigned execution commit has no forecast ref for dashboard refresh.");
|
|
90
|
+
dashboardRefresh = await enqueueDashboardRefresh(ctx.cwd, {
|
|
91
|
+
preset: "forecast-closed-loop-v1",
|
|
92
|
+
locale: "zh-CN",
|
|
93
|
+
scope_id: params.context.scope_id,
|
|
94
|
+
cycle_id: params.context.cycle_id,
|
|
95
|
+
forecast_ref: forecastRef,
|
|
96
|
+
...(committed.artifactType === "approved_cycle_forecast" && params.context.forecast_role ? { forecast_role: params.context.forecast_role } : {}),
|
|
97
|
+
...(committed.artifactType === "execution_receipt" ? { execution_ref: committed.artifactRef } : {}),
|
|
98
|
+
reason: committed.artifactType === "approved_cycle_forecast" ? "forecast_committed" : "execution_committed",
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// The immutable ledger commit is already durable. Report the
|
|
102
|
+
// secondary delivery failure explicitly instead of pretending the
|
|
103
|
+
// commit itself failed and encouraging an unsafe duplicate retry.
|
|
104
|
+
dashboardRefreshError = error instanceof Error ? error.message : String(error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
47
107
|
return toolResult({
|
|
48
|
-
status: "committed",
|
|
108
|
+
status: dashboardRefreshError ? "committed_refresh_enqueue_failed" : "committed",
|
|
49
109
|
artifact_type: committed.artifactType,
|
|
50
110
|
immutable_fingerprint: committed.fingerprint,
|
|
51
111
|
path: committed.path,
|
|
112
|
+
ledger_status: committed.ledgerStatus,
|
|
113
|
+
...(committed.artifactRef ? { artifact_ref: committed.artifactRef } : {}),
|
|
114
|
+
...(dashboardRefresh ? { dashboard_refresh: dashboardRefresh } : {}),
|
|
115
|
+
...(dashboardRefreshError ? { dashboard_refresh_error: dashboardRefreshError } : {}),
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
pi.registerTool({
|
|
121
|
+
name: "fpa_artifact_read",
|
|
122
|
+
label: "Read Exact FP&A Artifact",
|
|
123
|
+
description: "Resolve and verify one immutable FP&A artifact by its exact scope, cycle, entry, and body fingerprint. Never falls back to the current pointer.",
|
|
124
|
+
promptSnippet: "Read an exact immutable FP&A artifact reference",
|
|
125
|
+
parameters: Type.Object({ artifact_ref: artifactRefSchema }, { additionalProperties: false }),
|
|
126
|
+
executionMode: "parallel",
|
|
127
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
128
|
+
const read = await readArtifactByRef(ctx.cwd, params.artifact_ref);
|
|
129
|
+
return toolResult({
|
|
130
|
+
status: "resolved",
|
|
131
|
+
artifact_ref: params.artifact_ref,
|
|
132
|
+
artifact: read.artifact as unknown as Record<string, unknown>,
|
|
133
|
+
path: read.path,
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
pi.registerTool({
|
|
139
|
+
name: "fpa_forecast_compose",
|
|
140
|
+
label: "Compose Approved Forecast",
|
|
141
|
+
description:
|
|
142
|
+
"Derive a complete, self-consistent approved_cycle_forecast from a compact plan file holding only the approved allocation and its per-slice ROAS assumptions. " +
|
|
143
|
+
"Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks the plan's slice keys against the dimension values ua_spend actually uses. " +
|
|
144
|
+
"Returns the artifact for fpa_artifact_commit to freeze; writes nothing.",
|
|
145
|
+
promptSnippet: "Derive a complete approved forecast from a compact plan",
|
|
146
|
+
promptGuidelines: [
|
|
147
|
+
"Author the compact plan, not the full artifact: supply the allocation and each slice's ROAS assumption and let this tool derive the rest.",
|
|
148
|
+
"Do not compute revenue, ROAS, or consolidated totals by hand; hand-rounded values fail the commit reconciliation.",
|
|
149
|
+
],
|
|
150
|
+
parameters: Type.Object(
|
|
151
|
+
{
|
|
152
|
+
plan_path: Type.String({
|
|
153
|
+
minLength: 1,
|
|
154
|
+
description: "Project-relative path to the compact forecast plan JSON file.",
|
|
155
|
+
}),
|
|
156
|
+
},
|
|
157
|
+
{ additionalProperties: false },
|
|
158
|
+
),
|
|
159
|
+
executionMode: "parallel",
|
|
160
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
161
|
+
const plan = await readProjectJsonFile(ctx.cwd, params.plan_path, "plan_path");
|
|
162
|
+
const composed = await composeApprovedForecast(plan, { signal });
|
|
163
|
+
return toolResult({
|
|
164
|
+
status: "composed",
|
|
165
|
+
artifact: composed.artifact as unknown as Record<string, unknown>,
|
|
166
|
+
diagnostics: composed.diagnostics as unknown as Record<string, unknown>,
|
|
52
167
|
});
|
|
53
168
|
},
|
|
54
169
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { link, lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
|
|
3
3
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import {
|
|
@@ -16,14 +16,58 @@ export interface CommitArtifactResult {
|
|
|
16
16
|
artifactType: ArtifactType;
|
|
17
17
|
fingerprint: string;
|
|
18
18
|
path: string;
|
|
19
|
+
ledgerStatus: "assigned" | "legacy_unassigned";
|
|
20
|
+
artifactRef?: ArtifactRefV2;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface ReadArtifactResult {
|
|
22
24
|
artifact: CanonicalArtifact;
|
|
23
25
|
fingerprint: string;
|
|
24
26
|
path: string;
|
|
27
|
+
ledgerContext?: ArtifactLedgerContext;
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
export interface ArtifactRefV2 {
|
|
31
|
+
scope_id: string;
|
|
32
|
+
cycle_id: string;
|
|
33
|
+
artifact_type: ArtifactType;
|
|
34
|
+
entry_id: string;
|
|
35
|
+
body_fingerprint: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ArtifactCommitContext {
|
|
39
|
+
scope_id: string;
|
|
40
|
+
cycle_id: string;
|
|
41
|
+
upstream_refs?: ArtifactRefV2[];
|
|
42
|
+
forecast_role?: "original" | "eac" | "next_plan";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ArtifactLedgerContext {
|
|
46
|
+
scope_id: string;
|
|
47
|
+
cycle_id: string;
|
|
48
|
+
upstream_refs: ArtifactRefV2[];
|
|
49
|
+
forecast_role?: "original" | "eac" | "next_plan";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CommitArtifactOptions {
|
|
53
|
+
context?: ArtifactCommitContext;
|
|
54
|
+
promoteLegacyPointer?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ArtifactLedgerEntry extends ArtifactRefV2 {
|
|
58
|
+
kind: "fpa.artifact.entry";
|
|
59
|
+
schema_version: 2;
|
|
60
|
+
body_ref: string;
|
|
61
|
+
created_at: string;
|
|
62
|
+
available_at: string;
|
|
63
|
+
upstream_refs: ArtifactRefV2[];
|
|
64
|
+
forecast_role?: "original" | "eac" | "next_plan";
|
|
65
|
+
assignment_status: "assigned";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
69
|
+
const CONTEXT_ID_MAX_LENGTH = 256;
|
|
70
|
+
|
|
27
71
|
export function stableJson(value: unknown): string {
|
|
28
72
|
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
29
73
|
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
|
@@ -60,26 +104,176 @@ async function existingArtifactDirectory(projectRoot: string): Promise<string> {
|
|
|
60
104
|
return artifactsDir;
|
|
61
105
|
}
|
|
62
106
|
|
|
107
|
+
async function ensureOwnedDirectory(parent: string, name: string): Promise<string> {
|
|
108
|
+
const path = join(parent, name);
|
|
109
|
+
try {
|
|
110
|
+
const stat = await lstat(path);
|
|
111
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${name} must be a regular directory, not a symlink.`);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
114
|
+
await mkdir(path, { mode: 0o700 }).catch((mkdirError: NodeJS.ErrnoException) => {
|
|
115
|
+
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
116
|
+
});
|
|
117
|
+
const stat = await lstat(path);
|
|
118
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${name} must be a regular directory, not a symlink.`);
|
|
119
|
+
}
|
|
120
|
+
return path;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function ledgerDirectories(projectRoot: string): Promise<{ root: string; objects: string; entries: string; heads: string }> {
|
|
124
|
+
const artifacts = await ensureArtifactDirectory(projectRoot);
|
|
125
|
+
const root = await ensureOwnedDirectory(artifacts, ".ledger");
|
|
126
|
+
return {
|
|
127
|
+
root,
|
|
128
|
+
objects: await ensureOwnedDirectory(root, "objects"),
|
|
129
|
+
entries: await ensureOwnedDirectory(root, "entries"),
|
|
130
|
+
heads: await ensureOwnedDirectory(root, "heads"),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function existingLedgerDirectories(projectRoot: string): Promise<{ root: string; objects: string; entries: string }> {
|
|
135
|
+
const artifacts = await existingArtifactDirectory(projectRoot);
|
|
136
|
+
const root = join(artifacts, ".ledger");
|
|
137
|
+
const objects = join(root, "objects");
|
|
138
|
+
const entries = join(root, "entries");
|
|
139
|
+
for (const [path, label] of [[root, ".ledger"], [objects, ".ledger/objects"], [entries, ".ledger/entries"]] as const) {
|
|
140
|
+
const stat = await lstat(path);
|
|
141
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} must be a regular directory, not a symlink.`);
|
|
142
|
+
}
|
|
143
|
+
return { root, objects, entries };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function syncDirectory(path: string): Promise<void> {
|
|
147
|
+
const handle = await open(path, "r");
|
|
148
|
+
try {
|
|
149
|
+
await handle.sync();
|
|
150
|
+
} finally {
|
|
151
|
+
await handle.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function atomicReplace(directory: string, destination: string, contents: string): Promise<void> {
|
|
156
|
+
const temporary = join(directory, `.${randomUUID()}.tmp`);
|
|
157
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
158
|
+
let closed = false;
|
|
159
|
+
try {
|
|
160
|
+
await handle.writeFile(contents, "utf8");
|
|
161
|
+
await handle.sync();
|
|
162
|
+
await handle.close();
|
|
163
|
+
closed = true;
|
|
164
|
+
await rename(temporary, destination);
|
|
165
|
+
await syncDirectory(directory);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (!closed) await handle.close().catch(() => undefined);
|
|
168
|
+
await unlink(temporary).catch(() => undefined);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
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
|
+
let closed = false;
|
|
177
|
+
try {
|
|
178
|
+
await handle.writeFile(contents, "utf8");
|
|
179
|
+
await handle.sync();
|
|
180
|
+
await handle.close();
|
|
181
|
+
closed = true;
|
|
182
|
+
try {
|
|
183
|
+
await link(temporary, destination);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return "exists";
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
await syncDirectory(directory);
|
|
189
|
+
return "created";
|
|
190
|
+
} finally {
|
|
191
|
+
if (!closed) await handle.close().catch(() => undefined);
|
|
192
|
+
await unlink(temporary).catch(() => undefined);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function contextId(value: unknown, path: string): string {
|
|
197
|
+
if (typeof value !== "string" || value.trim() === "" || value.length > CONTEXT_ID_MAX_LENGTH || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
198
|
+
throw new Error(`${path} must be a non-empty string of at most ${CONTEXT_ID_MAX_LENGTH} characters without control characters.`);
|
|
199
|
+
}
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function validateArtifactRef(value: unknown, path = "artifact_ref", allowEnvelopeFields = false): ArtifactRefV2 {
|
|
204
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
|
|
205
|
+
const source = value as Record<string, unknown>;
|
|
206
|
+
const allowed = new Set(["scope_id", "cycle_id", "artifact_type", "entry_id", "body_fingerprint"]);
|
|
207
|
+
if (!allowEnvelopeFields) for (const key of Object.keys(source)) if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed.`);
|
|
208
|
+
const artifactType = source.artifact_type;
|
|
209
|
+
if (artifactType !== "approved_cycle_forecast" && artifactType !== "execution_receipt") throw new Error(`${path}.artifact_type is unsupported.`);
|
|
210
|
+
if (typeof source.entry_id !== "string" || !SHA256_RE.test(source.entry_id)) throw new Error(`${path}.entry_id must be a SHA-256 digest.`);
|
|
211
|
+
if (typeof source.body_fingerprint !== "string" || !SHA256_RE.test(source.body_fingerprint)) throw new Error(`${path}.body_fingerprint must be a SHA-256 digest.`);
|
|
212
|
+
return {
|
|
213
|
+
scope_id: contextId(source.scope_id, `${path}.scope_id`),
|
|
214
|
+
cycle_id: contextId(source.cycle_id, `${path}.cycle_id`),
|
|
215
|
+
artifact_type: artifactType,
|
|
216
|
+
entry_id: source.entry_id,
|
|
217
|
+
body_fingerprint: source.body_fingerprint,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function validateCommitContext(value: ArtifactCommitContext): ArtifactLedgerContext {
|
|
222
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("context must be an object.");
|
|
223
|
+
const source = value as unknown as Record<string, unknown>;
|
|
224
|
+
const allowed = new Set(["scope_id", "cycle_id", "upstream_refs", "forecast_role"]);
|
|
225
|
+
for (const key of Object.keys(source)) if (!allowed.has(key)) throw new Error(`context.${key} is not allowed.`);
|
|
226
|
+
const upstream = source.upstream_refs === undefined ? [] : source.upstream_refs;
|
|
227
|
+
if (!Array.isArray(upstream) || upstream.length > 16) throw new Error("context.upstream_refs must be an array of at most 16 artifact refs.");
|
|
228
|
+
const upstreamRefs = upstream.map((ref, index) => validateArtifactRef(ref, `context.upstream_refs[${index}]`));
|
|
229
|
+
if (new Set(upstreamRefs.map((ref) => ref.entry_id)).size !== upstreamRefs.length) throw new Error("context.upstream_refs must not contain duplicates.");
|
|
230
|
+
if (source.forecast_role !== undefined && source.forecast_role !== "original" && source.forecast_role !== "eac" && source.forecast_role !== "next_plan") {
|
|
231
|
+
throw new Error("context.forecast_role must be original, eac, or next_plan.");
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
scope_id: contextId(source.scope_id, "context.scope_id"),
|
|
235
|
+
cycle_id: contextId(source.cycle_id, "context.cycle_id"),
|
|
236
|
+
upstream_refs: upstreamRefs,
|
|
237
|
+
...(source.forecast_role ? { forecast_role: source.forecast_role as ArtifactLedgerContext["forecast_role"] } : {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
63
241
|
/**
|
|
64
242
|
* Resolve a caller-supplied draft path inside the project.
|
|
65
243
|
*
|
|
66
244
|
* Same fail-closed posture as the committed artifacts themselves: no escaping
|
|
67
245
|
* the project root, no symlinks, no unbounded reads.
|
|
68
246
|
*/
|
|
69
|
-
async function resolveDraftPath(projectRoot: string, artifactPath: string): Promise<string> {
|
|
70
|
-
if (!artifactPath.trim()) throw new Error(
|
|
247
|
+
async function resolveDraftPath(projectRoot: string, artifactPath: string, label = "artifact_path"): Promise<string> {
|
|
248
|
+
if (!artifactPath.trim()) throw new Error(`${label} must be a non-empty path.`);
|
|
71
249
|
const root = await realpath(projectRoot);
|
|
72
250
|
const resolved = resolve(root, artifactPath);
|
|
73
251
|
const relation = relative(root, resolved);
|
|
74
252
|
if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) {
|
|
75
|
-
throw new Error(
|
|
253
|
+
throw new Error(`${label} must stay inside the project directory.`);
|
|
76
254
|
}
|
|
77
255
|
const stat = await lstat(resolved);
|
|
78
|
-
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(
|
|
79
|
-
if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(
|
|
256
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must point at a regular file, not a symlink.`);
|
|
257
|
+
if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(`${label} exceeds the ${ARTIFACT_MAX_BYTES / (1024 * 1024)}MB artifact limit.`);
|
|
80
258
|
return resolved;
|
|
81
259
|
}
|
|
82
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Read and parse a JSON file the agent wrote somewhere inside the project.
|
|
263
|
+
*
|
|
264
|
+
* Shared by every by-path entry point so they all get the same fail-closed
|
|
265
|
+
* posture, and so a malformed file is reported as a parse error against the
|
|
266
|
+
* caller's own path rather than as something deeper and less obvious.
|
|
267
|
+
*/
|
|
268
|
+
export async function readProjectJsonFile(projectRoot: string, artifactPath: string, label = "artifact_path"): Promise<unknown> {
|
|
269
|
+
const path = await resolveDraftPath(projectRoot, artifactPath, label);
|
|
270
|
+
try {
|
|
271
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
272
|
+
} catch (error) {
|
|
273
|
+
throw new Error(`${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
83
277
|
/**
|
|
84
278
|
* Commit a canonical artifact an agent wrote to disk instead of passing inline.
|
|
85
279
|
*
|
|
@@ -90,51 +284,192 @@ async function resolveDraftPath(projectRoot: string, artifactPath: string): Prom
|
|
|
90
284
|
* many turns as it takes, and this entry point keeps the validation,
|
|
91
285
|
* reconciliation, fingerprinting, and atomic write identical to the inline path.
|
|
92
286
|
*/
|
|
93
|
-
export async function commitArtifactFromPath(projectRoot: string, artifactPath: string): Promise<CommitArtifactResult> {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
287
|
+
export async function commitArtifactFromPath(projectRoot: string, artifactPath: string, options: CommitArtifactOptions = {}): Promise<CommitArtifactResult> {
|
|
288
|
+
return commitArtifact(projectRoot, await readProjectJsonFile(projectRoot, artifactPath), options);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function writeLegacyPointer(projectRoot: string, committed: CanonicalArtifact): Promise<string> {
|
|
292
|
+
const artifactsDir = await ensureArtifactDirectory(projectRoot);
|
|
293
|
+
const destination = join(artifactsDir, `${committed.artifact_type}.json`);
|
|
294
|
+
await atomicReplace(artifactsDir, destination, `${JSON.stringify(committed, null, 2)}\n`);
|
|
295
|
+
return destination;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function entryIdentity(
|
|
299
|
+
context: ArtifactLedgerContext,
|
|
300
|
+
artifactType: ArtifactType,
|
|
301
|
+
bodyFingerprint: string,
|
|
302
|
+
): string {
|
|
303
|
+
return createHash("sha256").update(stableJson({
|
|
304
|
+
scope_id: context.scope_id,
|
|
305
|
+
cycle_id: context.cycle_id,
|
|
306
|
+
artifact_type: artifactType,
|
|
307
|
+
body_fingerprint: bodyFingerprint,
|
|
308
|
+
upstream_refs: context.upstream_refs,
|
|
309
|
+
...(context.forecast_role ? { forecast_role: context.forecast_role } : {}),
|
|
310
|
+
})).digest("hex");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function validateLedgerEntry(value: Record<string, unknown>, path: string): { ref: ArtifactRefV2; context: ArtifactLedgerContext } {
|
|
314
|
+
const allowed = new Set([
|
|
315
|
+
"kind", "schema_version", "scope_id", "cycle_id", "artifact_type", "entry_id", "body_fingerprint",
|
|
316
|
+
"body_ref", "created_at", "available_at", "upstream_refs", "forecast_role", "assignment_status",
|
|
317
|
+
]);
|
|
318
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed.`);
|
|
319
|
+
if (value.kind !== "fpa.artifact.entry" || value.schema_version !== 2 || value.assignment_status !== "assigned") {
|
|
320
|
+
throw new Error(`${path} has an unsupported kind, schema, or assignment status.`);
|
|
321
|
+
}
|
|
322
|
+
const ref = validateArtifactRef(value, path, true);
|
|
323
|
+
if (value.body_ref !== `objects/${ref.body_fingerprint}.json`) throw new Error(`${path} has an invalid body_ref.`);
|
|
324
|
+
for (const field of ["created_at", "available_at"] as const) {
|
|
325
|
+
if (typeof value[field] !== "string" || Number.isNaN(Date.parse(value[field]))) throw new Error(`${path}.${field} must be an ISO timestamp.`);
|
|
326
|
+
}
|
|
327
|
+
if (!Array.isArray(value.upstream_refs) || value.upstream_refs.length > 16) throw new Error(`${path}.upstream_refs must be an array of at most 16 artifact refs.`);
|
|
328
|
+
const upstreamRefs = value.upstream_refs.map((item, index) => validateArtifactRef(item, `${path}.upstream_refs[${index}]`));
|
|
329
|
+
if (value.forecast_role !== undefined && value.forecast_role !== "original" && value.forecast_role !== "eac" && value.forecast_role !== "next_plan") {
|
|
330
|
+
throw new Error(`${path}.forecast_role is unsupported.`);
|
|
100
331
|
}
|
|
101
|
-
|
|
332
|
+
if (ref.artifact_type === "execution_receipt" && value.forecast_role !== undefined) throw new Error(`${path}.forecast_role is allowed only for forecasts.`);
|
|
333
|
+
const context: ArtifactLedgerContext = {
|
|
334
|
+
scope_id: ref.scope_id,
|
|
335
|
+
cycle_id: ref.cycle_id,
|
|
336
|
+
upstream_refs: upstreamRefs,
|
|
337
|
+
...(value.forecast_role ? { forecast_role: value.forecast_role as ArtifactLedgerContext["forecast_role"] } : {}),
|
|
338
|
+
};
|
|
339
|
+
const expectedEntryId = entryIdentity(context, ref.artifact_type, ref.body_fingerprint);
|
|
340
|
+
if (expectedEntryId !== ref.entry_id) throw new Error(`${path} identity does not match its upstream refs.`);
|
|
341
|
+
return { ref, context };
|
|
102
342
|
}
|
|
103
343
|
|
|
104
|
-
|
|
344
|
+
async function readBoundedJson(path: string, label: string): Promise<Record<string, unknown>> {
|
|
345
|
+
const stat = await lstat(path);
|
|
346
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a regular file, not a symlink.`);
|
|
347
|
+
if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(`${label} exceeds the ${ARTIFACT_MAX_BYTES / (1024 * 1024)}MB artifact limit.`);
|
|
348
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
349
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${label} must contain a JSON object.`);
|
|
350
|
+
return parsed as Record<string, unknown>;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function canonicalFromStored(source: Record<string, unknown>, artifactType: ArtifactType, label: string): CanonicalArtifact {
|
|
354
|
+
const body = { ...source };
|
|
355
|
+
const fingerprint = body.immutable_fingerprint;
|
|
356
|
+
delete body.immutable_fingerprint;
|
|
357
|
+
if (typeof fingerprint !== "string" || !SHA256_RE.test(fingerprint)) throw new Error(`${label} has no valid immutable_fingerprint.`);
|
|
358
|
+
const artifact = validateArtifact(body);
|
|
359
|
+
if (artifact.artifact_type !== artifactType) throw new Error(`${label} contains ${artifact.artifact_type}, expected ${artifactType}.`);
|
|
360
|
+
if (artifactFingerprint(artifact) !== fingerprint) throw new Error(`${label} fingerprint mismatch: the committed artifact was modified after freezing.`);
|
|
361
|
+
return { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function commitLedgerEntry(
|
|
365
|
+
projectRoot: string,
|
|
366
|
+
committed: CanonicalArtifact,
|
|
367
|
+
context: ArtifactLedgerContext,
|
|
368
|
+
): Promise<{ ref: ArtifactRefV2; bodyPath: string }> {
|
|
369
|
+
const directories = await ledgerDirectories(projectRoot);
|
|
370
|
+
const fingerprint = committed.immutable_fingerprint;
|
|
371
|
+
const objectPath = join(directories.objects, `${fingerprint}.json`);
|
|
372
|
+
const objectContents = `${JSON.stringify(committed, null, 2)}\n`;
|
|
373
|
+
if (await appendOnlyWrite(directories.objects, objectPath, objectContents) === "exists") {
|
|
374
|
+
const existing = canonicalFromStored(await readBoundedJson(objectPath, `ledger object ${fingerprint}`), committed.artifact_type, `ledger object ${fingerprint}`);
|
|
375
|
+
if (existing.immutable_fingerprint !== fingerprint) throw new Error(`Ledger object ${fingerprint} conflicts with its content address.`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const entryId = entryIdentity(context, committed.artifact_type, fingerprint);
|
|
379
|
+
const ref: ArtifactRefV2 = {
|
|
380
|
+
scope_id: context.scope_id,
|
|
381
|
+
cycle_id: context.cycle_id,
|
|
382
|
+
artifact_type: committed.artifact_type,
|
|
383
|
+
entry_id: entryId,
|
|
384
|
+
body_fingerprint: fingerprint,
|
|
385
|
+
};
|
|
386
|
+
const entryPath = join(directories.entries, `${entryId}.json`);
|
|
387
|
+
const now = new Date().toISOString();
|
|
388
|
+
const entry: ArtifactLedgerEntry = {
|
|
389
|
+
kind: "fpa.artifact.entry",
|
|
390
|
+
schema_version: 2,
|
|
391
|
+
...ref,
|
|
392
|
+
body_ref: `objects/${fingerprint}.json`,
|
|
393
|
+
created_at: now,
|
|
394
|
+
available_at: now,
|
|
395
|
+
upstream_refs: context.upstream_refs,
|
|
396
|
+
...(context.forecast_role ? { forecast_role: context.forecast_role } : {}),
|
|
397
|
+
assignment_status: "assigned",
|
|
398
|
+
};
|
|
399
|
+
if (await appendOnlyWrite(directories.entries, entryPath, `${JSON.stringify(entry, null, 2)}\n`) === "exists") {
|
|
400
|
+
const existing = await readBoundedJson(entryPath, `ledger entry ${entryId}`);
|
|
401
|
+
const existingRef = validateLedgerEntry(existing, `ledger entry ${entryId}`).ref;
|
|
402
|
+
if (stableJson(existingRef) !== stableJson(ref)) throw new Error(`Ledger entry ${entryId} conflicts with the requested artifact ref.`);
|
|
403
|
+
}
|
|
404
|
+
const headId = createHash("sha256").update(stableJson({
|
|
405
|
+
scope_id: context.scope_id,
|
|
406
|
+
cycle_id: context.cycle_id,
|
|
407
|
+
artifact_type: committed.artifact_type,
|
|
408
|
+
})).digest("hex");
|
|
409
|
+
await atomicReplace(directories.heads, join(directories.heads, `${headId}.json`), `${JSON.stringify({
|
|
410
|
+
kind: "fpa.artifact.head",
|
|
411
|
+
schema_version: 2,
|
|
412
|
+
...ref,
|
|
413
|
+
updated_at: now,
|
|
414
|
+
}, null, 2)}\n`);
|
|
415
|
+
return { ref, bodyPath: objectPath };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function commitArtifact(projectRoot: string, input: unknown, options: CommitArtifactOptions = {}): Promise<CommitArtifactResult> {
|
|
105
419
|
const artifact = validateArtifact(input);
|
|
420
|
+
const context = options.context ? validateCommitContext(options.context) : null;
|
|
421
|
+
if (artifact.artifact_type === "approved_cycle_forecast" && context?.forecast_role === "original"
|
|
422
|
+
&& Date.parse(artifact.frozen_at) > Date.parse(artifact.target_period.start_inclusive)) {
|
|
423
|
+
throw new Error("An original forecast must be frozen no later than the target period start; use forecast_role=eac for an in-period reforecast.");
|
|
424
|
+
}
|
|
106
425
|
if (artifact.artifact_type === "execution_receipt") {
|
|
107
|
-
|
|
426
|
+
if (context?.forecast_role !== undefined) throw new Error("context.forecast_role is allowed only for approved forecasts.");
|
|
427
|
+
let forecastRead: ReadArtifactResult;
|
|
428
|
+
if (context) {
|
|
429
|
+
if (context.upstream_refs.length !== 1 || context.upstream_refs[0].artifact_type !== "approved_cycle_forecast") {
|
|
430
|
+
throw new Error("An assigned execution receipt requires exactly one approved_cycle_forecast upstream ref.");
|
|
431
|
+
}
|
|
432
|
+
const forecastRef = context.upstream_refs[0];
|
|
433
|
+
if (forecastRef.scope_id !== context.scope_id || forecastRef.cycle_id !== context.cycle_id) {
|
|
434
|
+
throw new Error("Execution context scope_id and cycle_id must match its forecast upstream ref.");
|
|
435
|
+
}
|
|
436
|
+
forecastRead = await readArtifactByRef(projectRoot, forecastRef);
|
|
437
|
+
} else {
|
|
438
|
+
forecastRead = await readCommittedArtifact(projectRoot, "approved_cycle_forecast");
|
|
439
|
+
}
|
|
108
440
|
if (forecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Committed approved forecast has the wrong artifact type.");
|
|
109
441
|
validateExecutionAgainstForecast(artifact, forecastRead.artifact);
|
|
110
442
|
}
|
|
111
443
|
const fingerprint = artifactFingerprint(artifact);
|
|
112
444
|
const committed = { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact;
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
445
|
+
const ledgerCommit = context ? await commitLedgerEntry(projectRoot, committed, context) : undefined;
|
|
446
|
+
const artifactRef = ledgerCommit?.ref;
|
|
447
|
+
const promoteLegacyPointer = options.promoteLegacyPointer ?? true;
|
|
448
|
+
const destination = promoteLegacyPointer
|
|
449
|
+
? await writeLegacyPointer(projectRoot, committed)
|
|
450
|
+
: ledgerCommit?.bodyPath ?? join(await ensureArtifactDirectory(projectRoot), `${artifact.artifact_type}.json`);
|
|
116
451
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const directoryHandle = await open(artifactsDir, "r");
|
|
126
|
-
try {
|
|
127
|
-
await directoryHandle.sync();
|
|
128
|
-
} finally {
|
|
129
|
-
await directoryHandle.close();
|
|
130
|
-
}
|
|
131
|
-
} catch (error) {
|
|
132
|
-
if (!closed) await handle.close().catch(() => undefined);
|
|
133
|
-
await unlink(temporary).catch(() => undefined);
|
|
134
|
-
throw error;
|
|
135
|
-
}
|
|
452
|
+
return {
|
|
453
|
+
artifactType: artifact.artifact_type,
|
|
454
|
+
fingerprint,
|
|
455
|
+
path: destination,
|
|
456
|
+
ledgerStatus: artifactRef ? "assigned" : "legacy_unassigned",
|
|
457
|
+
...(artifactRef ? { artifactRef } : {}),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
136
460
|
|
|
137
|
-
|
|
461
|
+
export async function readArtifactByRef(projectRoot: string, value: unknown): Promise<ReadArtifactResult> {
|
|
462
|
+
const ref = validateArtifactRef(value);
|
|
463
|
+
const directories = await existingLedgerDirectories(projectRoot);
|
|
464
|
+
const entryPath = join(directories.entries, `${ref.entry_id}.json`);
|
|
465
|
+
const entry = await readBoundedJson(entryPath, `ledger entry ${ref.entry_id}`);
|
|
466
|
+
const validatedEntry = validateLedgerEntry(entry, `ledger entry ${ref.entry_id}`);
|
|
467
|
+
const storedRef = validatedEntry.ref;
|
|
468
|
+
if (stableJson(storedRef) !== stableJson(ref)) throw new Error(`Ledger entry ${ref.entry_id} does not match the requested artifact ref.`);
|
|
469
|
+
const bodyPath = join(directories.objects, `${ref.body_fingerprint}.json`);
|
|
470
|
+
const artifact = canonicalFromStored(await readBoundedJson(bodyPath, `ledger object ${ref.body_fingerprint}`), ref.artifact_type, `ledger object ${ref.body_fingerprint}`);
|
|
471
|
+
if (artifact.immutable_fingerprint !== ref.body_fingerprint) throw new Error(`Ledger object ${ref.body_fingerprint} does not match the artifact ref.`);
|
|
472
|
+
return { artifact, fingerprint: ref.body_fingerprint, path: bodyPath, ledgerContext: validatedEntry.context };
|
|
138
473
|
}
|
|
139
474
|
|
|
140
475
|
export async function readCommittedArtifact(projectRoot: string, artifactType: ArtifactType): Promise<ReadArtifactResult> {
|
|
@@ -149,17 +484,8 @@ export async function readCommittedArtifact(projectRoot: string, artifactType: A
|
|
|
149
484
|
throw new Error(`${artifactType} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
150
485
|
}
|
|
151
486
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${artifactType} must contain a JSON object.`);
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
delete source.immutable_fingerprint;
|
|
155
|
-
if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint)) {
|
|
156
|
-
throw new Error(`${artifactType} has no valid immutable_fingerprint.`);
|
|
157
|
-
}
|
|
158
|
-
const artifact = validateArtifact(source);
|
|
159
|
-
if (artifact.artifact_type !== artifactType) throw new Error(`${path} contains ${artifact.artifact_type}, expected ${artifactType}.`);
|
|
160
|
-
const expected = artifactFingerprint(artifact);
|
|
161
|
-
if (expected !== fingerprint) throw new Error(`${artifactType} fingerprint mismatch: the committed artifact was modified after freezing.`);
|
|
162
|
-
return { artifact: { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact, fingerprint, path };
|
|
487
|
+
const artifact = canonicalFromStored(parsed as Record<string, unknown>, artifactType, artifactType);
|
|
488
|
+
return { artifact, fingerprint: artifact.immutable_fingerprint, path };
|
|
163
489
|
}
|
|
164
490
|
|
|
165
491
|
export async function readOptionalCommittedArtifact(projectRoot: string, artifactType: ArtifactType): Promise<ReadArtifactResult | null> {
|