Skip to content

Commit 7ac3106

Browse files
committed
fix(example-showcase): 让 4 条「声明了但运行时从不兑现」的接线真正生效 (#4774)
启动日志里剩下的 4 条 showcase 自身告警,根因都是同一类:声明在 authoring 期被接受,运行时却静默地什么也不做,只留一行 boot warning。 1. 夜间 job 从来没跑过。`showcase_health_sweep` 声明 `handler: 'sweepProjectHealth'`,而全仓没有任何同名函数,AppPlugin 每次启动都跳过它。按「每种能力至少出现一次」补上真实实现:用预算 燃尽率与任务交付进度的落差重算 `showcase_project.health`。job handler 由 job service 以 `{ jobId, data }` 调用、拿不到数据引擎(flow function 默认是纯函数),所以引擎句柄在 `onEnable` 捕获,并以 `effect: 'writes'` 声明——这样这次运行报告的是「无法统计这些写入」,而不是谎称没写过。 2. `showcase.export_data` 没有 owning package,永远不会写进 `sys_capability`, 于是 `OpsPermissionSet` 授予了一个不会存在的权限。补上 ADR-0086 D3 的 `packageId`(spec 自己给出的作者声明入口)。归属判定的结论是平台侧缺陷: app 声明的 capability 拿不到 registry 的 `_packageId` 戳,且被拒绝的声明 仍会抑制向后兼容的派生——已拆成 #4967,没有在 showcase 里绕过去。 3. 两处 `try_catch` 仍写 `retryDelayMs`,只靠 `retry-policy-converged` 在 load 时改写才能工作,而该 conversion 在 protocol 18 退役。改为正名 `backoffMs`。`maxRetryDelayMs` 不在这次改名范围内,是 `RetryPolicySchema` 的正式键,保留。 4. 10 条 `showcase_task.cover` 是内联 `data:image/svg+xml` URI,而 `Field.image()` 的存储形态是 opaque `sys_file` id。由于一次启动不能证明 它自己刚刚违反的契约(#4769),全新库因此永远无法 attest `adr-0104-file-references`——参考应用的每一次全新安装,闸门从第一天起就 开着。移除这些值;`cover` 字段与 gallery 绑定保留,由上传真实封面来填充。 种子不能诚实地铸造合法值:没有 sys_file 行支撑的合形状 id 会被 ADR-0104 自己的对账判为 `unowned_reference`(阻断级),而且「编一个看起来对的 id」 正是参考应用最不该教的模式。 验证(全新库冷启,`--fresh`):启动告警从 8 条降到 2 条,4 条症状全部消失, 且 `[migration] new datastore attested at creation: adr-0104-file-references, adr-0104-value-shapes`、`[AppPlugin] Scheduled background jobs {count:1, failed:0}`、`[security] declared capabilities seeded {seeded:1}` 均出现。 剩余 2 条与本单无关,已另行立案(#4968)。 新增 `test/inert-wirings.test.ts` 逐条设卡。其中 retry 一条刻意读源码文本 而非解析后的 stack:conversion 在 `defineStack` 期间就已经把退役拼写改写掉, 任何对解析结果的断言都会空转通过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX
1 parent 6bc93dc commit 7ac3106

10 files changed

Lines changed: 771 additions & 23 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
---
3+
4+
Showcase-only: fix four declarations the reference app made that the runtime
5+
never honoured, each announced by one line in the boot warning block. Releases
6+
nothing — `@objectstack/example-showcase` is private.
7+
8+
- **The nightly job now exists.** `showcase_health_sweep` named
9+
`handler: 'sweepProjectHealth'` and no function of that name was defined
10+
anywhere, so `AppPlugin` skipped it at every boot and "Nightly Project Health
11+
Sweep" had never run. Implemented for real: it recomputes
12+
`showcase_project.health` from budget burn measured against delivered task
13+
progress, over an engine handle captured at `onEnable` (a job handler is
14+
invoked with `{ jobId, data }` and no data engine), and is registered with
15+
`effect: 'writes'` so the run reports "cannot count these writes" rather than
16+
silently claiming it wrote nothing.
17+
- **`showcase.export_data` now materializes.** The capability declared no
18+
owning package, so it was never written to `sys_capability` — leaving
19+
`OpsPermissionSet` granting a permission that would never exist. It now
20+
authors the ADR-0086 D3 `packageId` provenance. The platform half (an
21+
app-declared capability can never receive the registry stamp, and a refused
22+
declaration still suppresses the back-compat derivation) is filed as #4967.
23+
- **Retry policies use the canonical key.** Two `try_catch` nodes still spelled
24+
the base delay `retryDelayMs`, which only kept working through the
25+
`retry-policy-converged` conversion — and that conversion retires in protocol
26+
18. Renamed to `backoffMs`. `maxRetryDelayMs` is unchanged: it is a canonical
27+
key of `RetryPolicySchema`, not part of that rename.
28+
- **Seed data no longer breaks ADR-0104 on its own first boot.** Ten
29+
`showcase_task.cover` values were inline `data:image/svg+xml` URIs in a
30+
`Field.image()`, whose stored form is an opaque `sys_file` id. Because a boot
31+
may not attest a contract it has already broken (#4769), a brand-new
32+
datastore could never auto-attest `adr-0104-file-references` — the gate stayed
33+
open on day one of every fresh install. The values are removed; `cover` stays
34+
declared, with its gallery binding, and is populated by uploading a cover.
35+
36+
Guarded by `test/inert-wirings.test.ts`: every declared job's handler must
37+
resolve against `defineStack({ functions })`, every declared capability must
38+
resolve an owning package, no permission set may grant an undeclared
39+
capability, no **source file** may author `retryDelayMs` (the parsed stack
40+
cannot answer this — the conversion has already rewritten it), and no seeded
41+
file-class value may fail its ADR-0104 stored shape.

examples/app-showcase/objectstack.config.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspac
3030
import { allFlows } from './src/automation/flows/index.js';
3131
import { allWebhooks } from './src/automation/webhooks/index.js';
3232
import { allHooks } from './src/data/hooks/index.js';
33-
import { allJobs } from './src/automation/jobs/index.js';
33+
import { allJobs, sweepProjectHealth, bindShowcaseJobRuntime } from './src/automation/jobs/index.js';
3434
import { allEmails } from './src/system/emails/index.js';
3535
import { allBooks } from './src/system/books/index.js';
3636
import { allApis } from './src/system/apis/index.js';
@@ -209,9 +209,18 @@ export default defineStack({
209209
// A flow function is PURE: it takes `inputs`, RETURNS a value, and a later
210210
// declarative node uses or persists it — it does no data I/O of its own
211211
// (#4396), which is why it needs no `effect` declaration here.
212+
//
213+
// A JOB handler resolves through this same map (`collectBundleFunctions`), so
214+
// `sweepProjectHealth` — the handler `HealthSweepJob` names — lives here too.
215+
// It is the case the pure contract does not cover: a nightly sweep has no
216+
// downstream declarative node to persist for it, so it writes over an engine
217+
// handle captured at `onEnable` and says so with `effect: 'writes'` (#4396).
218+
// The declaration grants nothing; it makes the run report "cannot count these
219+
// writes" instead of silently claiming it wrote nothing.
212220
functions: {
213221
summarizeCompletedTask: ({ input }: { input: Record<string, unknown> }) =>
214222
`Completed: ${String(input.title ?? 'task')} (priority ${String(input.priority ?? 'normal')}).`,
223+
sweepProjectHealth: { handler: sweepProjectHealth, effect: 'writes' as const },
215224
},
216225
jobs: allJobs,
217226
emailTemplates: allEmails,
@@ -262,4 +271,9 @@ export const onEnable = async (ctx: unknown): Promise<void> => {
262271
// real pending requests land in the inbox (cannot be a seed — see
263272
// seed-approval-demo.ts).
264273
registerShowcaseApprovalDemo(ctx as Parameters<typeof registerShowcaseApprovalDemo>[0]);
274+
// Hand the nightly health-sweep job its data handle. A job handler is invoked
275+
// by the job service with `{ jobId, data }` and no engine (flow functions are
276+
// pure by default, #4396), so `onEnable` — the one place the app is handed a
277+
// live engine — is where the sweep gets one.
278+
bindShowcaseJobRuntime(ctx as Parameters<typeof bindShowcaseJobRuntime>[0]);
265279
};

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,14 @@ export const ResilientSyncFlow = defineFlow({
981981
type: 'try_catch',
982982
label: 'Push with retry',
983983
config: {
984-
retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2, maxRetryDelayMs: 10000 },
984+
// Canonical retry policy (`@objectstack/spec` 17.0.0, #4661): the base
985+
// delay is `backoffMs` on BOTH `try_catch.retry` and `job.retryPolicy`.
986+
// The pre-17 automation-side spelling `retryDelayMs` is tombstoned and
987+
// only survives via the `retry-policy-converged` conversion, which
988+
// retires in protocol 18 — never author it. `maxRetryDelayMs` is NOT
989+
// part of that rename: it is a canonical key of `RetryPolicySchema`
990+
// (the ceiling for a single backoff delay).
991+
retry: { maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2, maxRetryDelayMs: 10000 },
985992
errorVariable: '$error',
986993
try: {
987994
nodes: [
@@ -1173,7 +1180,8 @@ export const ProjectEscalationFlow = defineFlow({
11731180
type: 'try_catch',
11741181
label: 'Push to incident system',
11751182
config: {
1176-
retry: { maxRetries: 2, retryDelayMs: 500, backoffMultiplier: 2 },
1183+
// Canonical `backoffMs` — see the note on ResilientSyncFlow above.
1184+
retry: { maxRetries: 2, backoffMs: 500, backoffMultiplier: 2 },
11771185
errorVariable: '$error',
11781186
try: {
11791187
nodes: [{ id: 'push', type: 'http', label: 'POST incident', config: { url: 'https://api.example.com/v1/incidents', method: 'POST', body: { project: '{record.id}', severity: 'critical' } } }],

examples/app-showcase/src/automation/jobs/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22

33
import { defineJob } from '@objectstack/spec';
44

5-
/** Nightly job — recompute project health. Handler is registered in defineStack({ functions }). */
5+
export { sweepProjectHealth, bindShowcaseJobRuntime, healthFor } from './sweep-project-health.js';
6+
7+
/**
8+
* Nightly job — recompute project health.
9+
*
10+
* `handler` names a key of `defineStack({ functions })` (the only form
11+
* `JobSchema.handler` accepts); `sweepProjectHealth` is registered there with
12+
* `effect: 'writes'`. It was declared here for a long time with no function of
13+
* that name anywhere in the example, so the AppPlugin skipped it at every boot
14+
* and the sweep never ran (#4774 / #4888) — see `./sweep-project-health.ts`.
15+
*/
616
export const HealthSweepJob = defineJob({
717
name: 'showcase_health_sweep',
818
label: 'Nightly Project Health Sweep',
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `sweepProjectHealth` — the handler behind the nightly `showcase_health_sweep`
5+
* job (see `./index.ts`).
6+
*
7+
* ## Why this file exists
8+
*
9+
* The job declared `handler: 'sweepProjectHealth'` and nothing of that name was
10+
* ever defined, so every boot printed
11+
*
12+
* ```
13+
* WARN [AppPlugin] job handler not found in bundle.functions — skipping
14+
* {"appId":"com.example.showcase","job":"showcase_health_sweep","handler":"sweepProjectHealth"}
15+
* ```
16+
*
17+
* and the "Nightly Project Health Sweep" never ran (#4774 / #4888). A scheduled
18+
* job is one of the capabilities the showcase exists to demonstrate end to end,
19+
* so the fix is a real implementation rather than deleting the declaration —
20+
* "never advertise a capability the runtime doesn't deliver" (AGENTS.md Prime
21+
* Directive #10) cuts both ways.
22+
*
23+
* ## Why the engine handle is captured rather than passed in
24+
*
25+
* A job handler is resolved through the SAME `defineStack({ functions })`
26+
* registry as a `script` flow node (`collectBundleFunctions` in
27+
* `@objectstack/runtime`), and the job service invokes it with
28+
* `{ jobId, data }` — `IJobService`'s `JobHandler` context — plus the `bundle`
29+
* the AppPlugin adds. There is deliberately no data engine in that context: a
30+
* flow function is PURE by default, returning a value a later declarative node
31+
* persists (#4343 / #4396).
32+
*
33+
* A background job is the case that contract does not cover — nothing
34+
* downstream is going to persist for it — so it does its own I/O over a handle
35+
* captured at `onEnable`, and DECLARES that in the `functions` map with
36+
* `effect: 'writes'` (#4396). That declaration grants nothing; it tells the
37+
* platform this callable's writes are not counted by the caller, so a run
38+
* reports "cannot say" instead of silently claiming it wrote nothing.
39+
*
40+
* ## What it computes
41+
*
42+
* Health is budget burn measured against delivered progress — the drift between
43+
* the money spent and the work finished:
44+
*
45+
* burn = spent / budget (0 when no budget is set)
46+
* done = mean(task.progress) / 100 (0 when the project has no tasks)
47+
* drift = burn - done
48+
*
49+
* red — over budget (burn > 1), or drift >= 0.30
50+
* yellow — drift >= 0.15
51+
* green — otherwise
52+
*
53+
* Only `active` / `on_hold` projects are swept: `planned` has nothing to burn
54+
* yet, and `completed` / `cancelled` are settled facts a nightly job must not
55+
* relitigate. Writes are limited to the projects whose health actually changed,
56+
* so a steady-state sweep performs zero updates.
57+
*/
58+
59+
/** Statuses whose health is still in play. */
60+
const SWEPT_STATUSES = ['active', 'on_hold'] as const;
61+
62+
/** Drift at or above which a project turns red (spending far ahead of delivery). */
63+
const RED_DRIFT = 0.3;
64+
/** Drift at or above which a project turns yellow. */
65+
const YELLOW_DRIFT = 0.15;
66+
67+
/** Bound on rows read per sweep — a demo dataset, read in one pass. */
68+
const READ_LIMIT = 1000;
69+
70+
const SYS = { isSystem: true } as const;
71+
72+
type Health = 'green' | 'yellow' | 'red';
73+
74+
interface JobHostEngine {
75+
find: (object: string, query: unknown, options?: unknown) => Promise<unknown>;
76+
update: (object: string, data: Record<string, unknown>, options?: unknown) => Promise<unknown>;
77+
}
78+
79+
interface JobHostContext {
80+
ql: JobHostEngine;
81+
logger?: {
82+
info?: (...a: unknown[]) => void;
83+
warn?: (...a: unknown[]) => void;
84+
};
85+
}
86+
87+
/**
88+
* The engine handle the job runs over, captured from the host context at
89+
* `onEnable`. Module scope is what makes it reachable from a `functions` entry,
90+
* which the job service calls with no context of its own — the "closed over a
91+
* client at module scope" shape `effect: 'writes'` exists to declare.
92+
*/
93+
let host: JobHostContext | undefined;
94+
95+
/**
96+
* Give `sweepProjectHealth` its data handle. Called from `onEnable` in
97+
* `objectstack.config.ts`, which is the one place the app is handed a live
98+
* engine. Idempotent — a re-enable simply rebinds.
99+
*/
100+
export function bindShowcaseJobRuntime(ctx: JobHostContext): void {
101+
host = ctx;
102+
}
103+
104+
/** Normalize the engine's list shape (array, or `{ records }`). */
105+
function rowsOf(result: unknown): Array<Record<string, unknown>> {
106+
if (Array.isArray(result)) return result as Array<Record<string, unknown>>;
107+
const records = (result as { records?: unknown })?.records;
108+
return Array.isArray(records) ? (records as Array<Record<string, unknown>>) : [];
109+
}
110+
111+
/** Read a numeric column defensively — a currency/progress column may arrive as a string. */
112+
function num(value: unknown): number | undefined {
113+
if (typeof value === 'number' && Number.isFinite(value)) return value;
114+
if (typeof value === 'string' && value.trim() !== '') {
115+
const parsed = Number(value);
116+
if (Number.isFinite(parsed)) return parsed;
117+
}
118+
return undefined;
119+
}
120+
121+
/**
122+
* The health verdict for one project — exported so the rule is unit-testable
123+
* without an engine (see `test/job-health-sweep.test.ts`).
124+
*/
125+
export function healthFor(input: {
126+
budget?: unknown;
127+
spent?: unknown;
128+
taskProgress: readonly number[];
129+
}): Health {
130+
const budget = num(input.budget) ?? 0;
131+
const spent = num(input.spent) ?? 0;
132+
const burn = budget > 0 ? spent / budget : 0;
133+
if (burn > 1) return 'red';
134+
135+
const done =
136+
input.taskProgress.length > 0
137+
? input.taskProgress.reduce((sum, p) => sum + p, 0) / input.taskProgress.length / 100
138+
: 0;
139+
140+
const drift = burn - done;
141+
if (drift >= RED_DRIFT) return 'red';
142+
if (drift >= YELLOW_DRIFT) return 'yellow';
143+
return 'green';
144+
}
145+
146+
/**
147+
* Recompute `showcase_project.health` for every in-play project.
148+
*
149+
* Registered as `functions.sweepProjectHealth` with `effect: 'writes'` and
150+
* scheduled by `HealthSweepJob` (`0 1 * * *` UTC).
151+
*/
152+
export async function sweepProjectHealth(ctx?: { jobId?: string }): Promise<void> {
153+
const jobId = ctx?.jobId ?? 'showcase_health_sweep';
154+
if (!host) {
155+
// Reached only if the job somehow fires before `onEnable` bound the
156+
// handle. Functional degradation, not a durability one: nothing claimed to
157+
// be persisted has been lost, and the next scheduled run recomputes
158+
// everything from scratch (AGENTS.md "Degradation log levels").
159+
// eslint-disable-next-line no-console
160+
console.warn(`[showcase] ${jobId}: no engine handle bound yet — skipping this run`);
161+
return;
162+
}
163+
const { ql, logger } = host;
164+
165+
const projects = rowsOf(
166+
await ql.find('showcase_project', {
167+
where: { status: { $in: [...SWEPT_STATUSES] } },
168+
fields: ['id', 'status', 'health', 'budget', 'spent'],
169+
limit: READ_LIMIT,
170+
context: SYS,
171+
}),
172+
);
173+
if (projects.length === 0) {
174+
logger?.info?.('[showcase] project health sweep: no in-play projects', { job: jobId });
175+
return;
176+
}
177+
178+
const projectIds = projects.map((p) => String(p.id));
179+
const tasks = rowsOf(
180+
await ql.find('showcase_task', {
181+
where: { project: { $in: projectIds } },
182+
fields: ['project', 'progress'],
183+
limit: READ_LIMIT,
184+
context: SYS,
185+
}),
186+
);
187+
188+
const progressByProject = new Map<string, number[]>();
189+
for (const task of tasks) {
190+
const projectId = task.project == null ? '' : String(task.project);
191+
if (!projectId) continue;
192+
const progress = num(task.progress) ?? 0;
193+
const bucket = progressByProject.get(projectId);
194+
if (bucket) bucket.push(progress);
195+
else progressByProject.set(projectId, [progress]);
196+
}
197+
198+
let updated = 0;
199+
for (const project of projects) {
200+
const id = String(project.id);
201+
const next = healthFor({
202+
budget: project.budget,
203+
spent: project.spent,
204+
taskProgress: progressByProject.get(id) ?? [],
205+
});
206+
if (next === project.health) continue;
207+
try {
208+
await ql.update('showcase_project', { id, health: next }, { context: SYS });
209+
updated += 1;
210+
} catch (err) {
211+
logger?.warn?.('[showcase] project health update failed', {
212+
job: jobId,
213+
project: id,
214+
error: err instanceof Error ? err.message : String(err),
215+
});
216+
}
217+
}
218+
219+
logger?.info?.('[showcase] project health sweep complete', {
220+
job: jobId,
221+
scanned: projects.length,
222+
updated,
223+
});
224+
}

examples/app-showcase/src/coverage.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,12 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
9797

9898
// ── automation ──
9999
flow: { status: 'demonstrated', files: ['src/automation/flows/index.ts'] },
100-
job: { status: 'demonstrated', files: ['src/automation/jobs/index.ts'] },
100+
// `job` is only demonstrated end-to-end if its handler actually resolves —
101+
// the declaration alone left the sweep registered and never run (#4774).
102+
job: {
103+
status: 'demonstrated',
104+
files: ['src/automation/jobs/index.ts', 'src/automation/jobs/sweep-project-health.ts'],
105+
},
101106

102107
// ── system ──
103108
datasource: {

0 commit comments

Comments
 (0)