@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 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
- 看板按 `app_id + store + channel_group` 精确限定同口径 Actuals,比率全部在聚合后重算,缺值保持 `NULL`;未获批的付费分片只形成告警,不混入预测对比,上周期对比按当前 Actuals 已覆盖的等长日历窗口计算。发布器写内容寻址的数据集,并最后原子提升 `manifest.json`,不会让 Web 端读到半成品代际。
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.4.1
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.4.1` 对应 `v0.4.1`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
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
- exactKeys(item, ["app_id", "store", "channel_group", "baseline_spend", "approved_spend", "action"], path);
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 { commitArtifact, commitArtifactFromPath, readProjectJsonFile } from "./store.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";
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
  });