@viccydev/pi-fpa 0.5.0 → 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 +4 -1
- package/extensions/fpa-artifacts/contracts.ts +9 -1
- package/extensions/fpa-artifacts/index.ts +85 -4
- package/extensions/fpa-artifacts/store.ts +355 -39
- package/extensions/fpa-dashboard/actuals.ts +215 -51
- 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 +88 -79
- 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 +118 -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 +7 -2
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +2 -0
- package/skills/fpa-refresh-dashboard/SKILL.md +9 -1
package/README.md
CHANGED
|
@@ -54,14 +54,31 @@ Extension 内置的关键防护:
|
|
|
54
54
|
|
|
55
55
|
`fpa-artifacts` 提供 `fpa_artifact_commit`,对 `approved_cycle_forecast` 和 `execution_receipt` 做严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
|
|
56
56
|
|
|
57
|
-
`fpa-dashboard`
|
|
57
|
+
`fpa-dashboard` 提供三个工具:
|
|
58
58
|
|
|
59
59
|
| 工具 | 作用 |
|
|
60
60
|
| --- | --- |
|
|
61
61
|
| `fpa_dashboard_status` | 只读检查当前 manifest、构建回执和各数据集是否可读 |
|
|
62
62
|
| `fpa_dashboard_refresh` | 从冻结预测、可选执行回执和实时 Actuals 生成固定的闭环看板;先 preview,再携带相同指纹原子 publish |
|
|
63
|
+
| `fpa_dashboard_refresh_queue` | 检查或处理持久刷新队列;Forecast/Execution commit 自动入队,Actuals watermark 按 SLA 轮询并幂等发布 |
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
持续刷新由 package 自带的独立 worker 驱动,Web 保持严格只读:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
fpa-dashboard-worker --workspace /absolute/path/to/workspace
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
使用 `--once` 可接 cron/systemd timer;常驻运行时默认每 30 秒检查队列,并按内部 5 分钟 Actuals 水位 SLA 轮询。worker 只消费精确 immutable artifact refs,执行有界重试,并通过与交互工具相同的原子 publisher 发布。
|
|
72
|
+
|
|
73
|
+
周期关账不按“过了若干小时”推断。ETL 必须原子写入一份有界、非 group/world-writable 的 JSON 关账信号,并同时配置 `FPA_ACTUALS_CLOSE_SIGNAL_PATH` 与 `FPA_ACTUALS_CLOSE_SIGNAL_ROOT`。可信根必须位于 Agent workspace 之外,且文件及其目录链必须由 Agent 运行身份之外的控制面身份拥有;否则 worker 会拒绝把周期判为已关账:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{"kind":"fpa.actuals.source-close","schema_version":1,"dataset":"ua_spend","signal_id":"ua-close-2026-08-v1","closed_through":"2026-08-31","emitted_at":"2026-09-01T03:00:00Z"}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
缺少该信号时,完整日期覆盖仍只算累计 Actual,不开放整周期 Forecast vs Actual 差异。
|
|
80
|
+
|
|
81
|
+
看板按 `app_id + store + channel_group` 精确限定同口径 Actuals,比率全部在聚合后重算,缺值保持 `NULL`;未获批的付费分片只形成告警,不混入预测对比。整周期 Forecast vs Actual 只在 Actuals 提供统一、完整、可勾稽的单快照,且比较基线是周期开始前冻结的 `original` Forecast 时开放;EAC、稀疏覆盖和多查询未核验快照都不会生成伪差异。发布器写内容寻址的数据集、不可变 generation catalog,并最后原子提升 `manifest.json`,不会让 Web 端读到半成品代际。
|
|
65
82
|
|
|
66
83
|
### 凭证配置
|
|
67
84
|
|
|
@@ -110,12 +127,12 @@ pi list
|
|
|
110
127
|
团队分发建议使用固定 Git tag:
|
|
111
128
|
|
|
112
129
|
```bash
|
|
113
|
-
pi install git:github.com/linyqh/pi-fpa@v0.
|
|
130
|
+
pi install git:github.com/linyqh/pi-fpa@v0.6.0
|
|
114
131
|
```
|
|
115
132
|
|
|
116
133
|
## 发布到 npm
|
|
117
134
|
|
|
118
|
-
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.
|
|
135
|
+
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.6.0` 对应 `v0.6.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
|
|
119
136
|
|
|
120
137
|
发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
|
|
121
138
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
9
|
+
process.stdout.write([
|
|
10
|
+
"Usage: fpa-dashboard-worker (--project <project-cwd> | --workspace <workspace-root>) [--once] [--interval-ms <milliseconds>]",
|
|
11
|
+
"",
|
|
12
|
+
"Runs the package-owned durable dashboard refresh coordinator.",
|
|
13
|
+
"The Web application remains read-only and must not invoke this worker in-process.",
|
|
14
|
+
"",
|
|
15
|
+
].join("\n"));
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function option(name) {
|
|
20
|
+
const index = args.indexOf(name);
|
|
21
|
+
return index === -1 ? undefined : args[index + 1];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const projectArg = option("--project");
|
|
25
|
+
const workspaceArg = option("--workspace");
|
|
26
|
+
if ((!projectArg && !workspaceArg) || (projectArg && workspaceArg)) {
|
|
27
|
+
process.stderr.write("fpa-dashboard-worker requires exactly one of --project or --workspace.\n");
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
const configuredProject = projectArg ? resolve(projectArg) : null;
|
|
31
|
+
const workspace = workspaceArg ? resolve(workspaceArg) : null;
|
|
32
|
+
const interval = Number(option("--interval-ms") ?? 30_000);
|
|
33
|
+
if (!Number.isInteger(interval) || interval < 5_000 || interval > 3_600_000) {
|
|
34
|
+
process.stderr.write("--interval-ms must be an integer between 5000 and 3600000.\n");
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Node does not strip TypeScript under node_modules. Use the same runtime
|
|
39
|
+
// loader as Pi itself so the published package works from an installed tree.
|
|
40
|
+
const jiti = createJiti(import.meta.url);
|
|
41
|
+
const { runDashboardRefreshCoordinator } = await jiti.import("../extensions/fpa-dashboard/coordinator.ts");
|
|
42
|
+
let stopping = false;
|
|
43
|
+
const abortController = new AbortController();
|
|
44
|
+
let wakeSleep = null;
|
|
45
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => {
|
|
46
|
+
stopping = true;
|
|
47
|
+
abortController.abort(new Error(`Dashboard worker received ${signal}.`));
|
|
48
|
+
wakeSleep?.();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function runOnce() {
|
|
52
|
+
const project = configuredProject ?? await selectWorkspaceProject(workspace);
|
|
53
|
+
if (!project) {
|
|
54
|
+
process.stdout.write(`${JSON.stringify({ at: new Date().toISOString(), workspace, status: "no_project" })}\n`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const result = await runDashboardRefreshCoordinator(project, { limit: 32, signal: abortController.signal });
|
|
58
|
+
process.stdout.write(`${JSON.stringify({ at: new Date().toISOString(), project, ...result })}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function selectWorkspaceProject(root) {
|
|
62
|
+
const canonicalRoot = await realpath(root);
|
|
63
|
+
const entries = await readdir(canonicalRoot, { withFileTypes: true });
|
|
64
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
65
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === ".fpa-dashboard") continue;
|
|
66
|
+
const project = await realpath(resolve(canonicalRoot, entry.name));
|
|
67
|
+
if (dirname(project) === canonicalRoot) return project;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (args.includes("--once")) {
|
|
73
|
+
try {
|
|
74
|
+
await runOnce();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!abortController.signal.aborted) throw error;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
while (!stopping) {
|
|
80
|
+
try {
|
|
81
|
+
await runOnce();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (abortController.signal.aborted) break;
|
|
84
|
+
process.stderr.write(`${new Date().toISOString()} ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
85
|
+
}
|
|
86
|
+
if (!stopping) await new Promise((resolveWait) => {
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
wakeSleep = null;
|
|
89
|
+
resolveWait();
|
|
90
|
+
}, interval);
|
|
91
|
+
wakeSleep = () => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
wakeSleep = null;
|
|
94
|
+
resolveWait();
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -55,6 +55,7 @@ export interface PlanSlice {
|
|
|
55
55
|
baseline_spend: number | null;
|
|
56
56
|
approved_spend: number;
|
|
57
57
|
action: AllocationAction;
|
|
58
|
+
owner?: string;
|
|
58
59
|
roas: PlanSliceRoas;
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -119,7 +120,7 @@ function text(value: unknown, path: string): string {
|
|
|
119
120
|
function planSlice(value: unknown, index: number): PlanSlice {
|
|
120
121
|
const path = `plan.slices[${index}]`;
|
|
121
122
|
const source = record(value, path);
|
|
122
|
-
const allowed = new Set(["app_id", "store", "channel_group", "baseline_spend", "approved_spend", "action", "roas"]);
|
|
123
|
+
const allowed = new Set(["app_id", "store", "channel_group", "baseline_spend", "approved_spend", "action", "owner", "roas"]);
|
|
123
124
|
const unknown = Object.keys(source).filter((key) => !allowed.has(key));
|
|
124
125
|
if (unknown.length > 0) throw new Error(`${path} has unsupported keys: ${unknown.join(", ")}.`);
|
|
125
126
|
const action = source.action;
|
|
@@ -136,6 +137,7 @@ function planSlice(value: unknown, index: number): PlanSlice {
|
|
|
136
137
|
baseline_spend: nonNegative(source.baseline_spend, `${path}.baseline_spend`, true),
|
|
137
138
|
approved_spend: nonNegative(source.approved_spend, `${path}.approved_spend`) as number,
|
|
138
139
|
action: action as AllocationAction,
|
|
140
|
+
...(source.owner === undefined ? {} : { owner: text(source.owner, `${path}.owner`) }),
|
|
139
141
|
roas: {
|
|
140
142
|
downside: nonNegative(roasSource.downside, `${path}.roas.downside`, true),
|
|
141
143
|
base: nonNegative(roasSource.base, `${path}.roas.base`, true),
|
|
@@ -302,6 +304,7 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
|
|
|
302
304
|
baseline_spend: slice.baseline_spend,
|
|
303
305
|
approved_spend: slice.approved_spend,
|
|
304
306
|
action: slice.action,
|
|
307
|
+
...(slice.owner ? { owner: slice.owner } : {}),
|
|
305
308
|
}));
|
|
306
309
|
|
|
307
310
|
// Spend is the decision, not a prediction: an approved allocation is the same
|
|
@@ -25,6 +25,7 @@ export interface ForecastAllocation {
|
|
|
25
25
|
baseline_spend: number | null;
|
|
26
26
|
approved_spend: number;
|
|
27
27
|
action: AllocationAction;
|
|
28
|
+
owner?: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface ForecastSlice {
|
|
@@ -187,6 +188,12 @@ function exactKeys(value: Record<string, unknown>, allowed: readonly string[], p
|
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
function requiredAndOptionalKeys(value: Record<string, unknown>, required: readonly string[], optional: readonly string[], path: string): void {
|
|
192
|
+
const allowed = new Set([...required, ...optional]);
|
|
193
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed.`);
|
|
194
|
+
for (const key of required) if (!(key in value)) throw new Error(`${path}.${key} is required.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
190
197
|
function string(value: unknown, path: string): string {
|
|
191
198
|
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
192
199
|
return value;
|
|
@@ -364,7 +371,7 @@ function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycl
|
|
|
364
371
|
const allocations = array(source.approved_allocation, "artifact.approved_allocation").map((value, index) => {
|
|
365
372
|
const path = `artifact.approved_allocation[${index}]`;
|
|
366
373
|
const item = record(value, path);
|
|
367
|
-
|
|
374
|
+
requiredAndOptionalKeys(item, ["app_id", "store", "channel_group", "baseline_spend", "approved_spend", "action"], ["owner"], path);
|
|
368
375
|
return {
|
|
369
376
|
app_id: string(item.app_id, `${path}.app_id`),
|
|
370
377
|
store: string(item.store, `${path}.store`),
|
|
@@ -372,6 +379,7 @@ function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycl
|
|
|
372
379
|
baseline_spend: nonNegative(item.baseline_spend, `${path}.baseline_spend`, true),
|
|
373
380
|
approved_spend: nonNegative(item.approved_spend, `${path}.approved_spend`) as number,
|
|
374
381
|
action: enumValue(item.action, ALLOCATION_ACTIONS, `${path}.action`),
|
|
382
|
+
...(item.owner === undefined ? {} : { owner: string(item.owner, `${path}.owner`) }),
|
|
375
383
|
};
|
|
376
384
|
});
|
|
377
385
|
if (allocations.length === 0) throw new Error("artifact.approved_allocation must not be empty.");
|
|
@@ -2,7 +2,14 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
4
|
import { composeApprovedForecast } from "./compose.ts";
|
|
5
|
-
import {
|
|
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";
|
|
6
13
|
|
|
7
14
|
function toolResult(value: Record<string, unknown>) {
|
|
8
15
|
return {
|
|
@@ -11,6 +18,17 @@ function toolResult(value: Record<string, unknown>) {
|
|
|
11
18
|
};
|
|
12
19
|
}
|
|
13
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
|
+
|
|
14
32
|
export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
15
33
|
pi.registerTool({
|
|
16
34
|
name: "fpa_artifact_commit",
|
|
@@ -32,6 +50,13 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
32
50
|
minLength: 1,
|
|
33
51
|
description: "Project-relative path to a JSON file holding the canonical artifact. Mutually exclusive with artifact.",
|
|
34
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 })),
|
|
35
60
|
},
|
|
36
61
|
{ additionalProperties: false },
|
|
37
62
|
),
|
|
@@ -42,14 +67,70 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
42
67
|
if (hasInline === hasPath) {
|
|
43
68
|
throw new Error("Supply exactly one of artifact or artifact_path.");
|
|
44
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;
|
|
45
79
|
const committed = hasPath
|
|
46
|
-
? await commitArtifactFromPath(ctx.cwd, params.artifact_path as string)
|
|
47
|
-
: 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
|
+
}
|
|
48
107
|
return toolResult({
|
|
49
|
-
status: "committed",
|
|
108
|
+
status: dashboardRefreshError ? "committed_refresh_enqueue_failed" : "committed",
|
|
50
109
|
artifact_type: committed.artifactType,
|
|
51
110
|
immutable_fingerprint: committed.fingerprint,
|
|
52
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,
|
|
53
134
|
});
|
|
54
135
|
},
|
|
55
136
|
});
|