已合并
Watcher of pipeline progress #293
nikolay-igotti-2创建于 4 天前
Watcher of pipeline progress #293
已合并
nikolay-igotti-2创建于 4 天前
从ni/trace-bb合入到master
共 30 个文件变更+4405-54
Marktsx/a2ui-billboard/lib/billboardApp.ets+16-2文件内容审核中,请稍后刷新重试
@@ -23,6 +23,7 @@
23// screen is the only output, and an ssh session cannot see it.23// screen is the only output, and an ssh session cannot see it.
24 24 
25import { BillboardChannel, BillboardChannels } from "./channels"25import { BillboardChannel, BillboardChannels } from "./channels"
26+import { billboardPipelineJson, billboardPipelineSummaryJson } from "./statusBadge"
26 27 
27/** The `kind` every reply carries; a controller keys its UI off it. */28/** The `kind` every reply carries; a controller keys its UI off it. */
28export const BILLBOARD_REMOTE_KIND: string = "billboard"29export const BILLBOARD_REMOTE_KIND: string = "billboard"
@@ -41,6 +42,43 @@ export function billboardActiveSurface(activeChannel: int): string {
41 return activeChannel <= 0 ? "billboard" : `billboard-channel-${activeChannel}`42 return activeChannel <= 0 ? "billboard" : `billboard-channel-${activeChannel}`
42}43}
43 44 
45+/**
46+ * The pipeline snapshot the host sent, or undefined before one arrives. It is
47+ * stored as serialized JSON because it is published whole by the host; reading
48+ * it back here keeps `billboardRemoteStatus` the one place the reply's shape is
49+ * assembled.
50+ */
51+function pipelineElement(includeArtifacts: boolean): jsonx.JsonElement | undefined {
52+ // The badge prepares both forms once per publish; a status poll parses
53+ // only the small one instead of building the preview tree and dropping it.
54+ let text = includeArtifacts ? billboardPipelineJson() : billboardPipelineSummaryJson()
55+ if (text.length === 0 && !includeArtifacts) {
56+ // A snapshot stored before the summary existed: fall back to the full
57+ // form so the reply still carries the stages.
58+ text = billboardPipelineJson()
59+ }
60+ if (text.length === 0) {
61+ return undefined
62+ }
63+ try {
64+ const element = JSON.parseJsonElement(text)
65+ if (element.jsonType !== jsonx.JsonType.JsonObject) {
66+ return undefined
67+ }
68+ if (!includeArtifacts) {
69+ // Belt and braces for the fallback above: a poll reply must never
70+ // carry the preview half.
71+ element.setElement("artifacts", JSON.parseJsonElement("{}"))
72+ }
73+ return element
74+ } catch (error) {
75+ // The snapshot is this app's own serialization of a host object; a
76+ // failure means the frame was corrupted between the two.
77+ console.error(`[billboard] pipeline snapshot is not JSON: ${error}`)
78+ return undefined
79+ }
80+}
81+ 
44/**82/**
45 * Live billboard state as JSON — the status block every remote reply embeds,83 * Live billboard state as JSON — the status block every remote reply embeds,
46 * and what `{"action":"status"}` answers on its own. Honest while a slide is84 * and what `{"action":"status"}` answers on its own. Honest while a slide is
@@ -48,7 +86,11 @@ export function billboardActiveSurface(activeChannel: int): string {
48 * the last attempt failed, so a controller never has to guess whether the86 * the last attempt failed, so a controller never has to guess whether the
49 * blank screen it is looking at is a placeholder or a crash.87 * blank screen it is looking at is a placeholder or a crash.
50 */88 */
51-export function billboardRemoteStatus(channels: BillboardChannels, fullscreen: boolean): jsonx.JsonElement {89+export function billboardRemoteStatus(
90+ channels: BillboardChannels,
91+ fullscreen: boolean,
92+ includeArtifacts: boolean,
93+): jsonx.JsonElement {
52 const known: BillboardChannel[] = channels.channels.value94 const known: BillboardChannel[] = channels.channels.value
53 const listed: Array<jsonx.JsonElement> = []95 const listed: Array<jsonx.JsonElement> = []
54 for (let i: int = 0; i < known.length; i++) {96 for (let i: int = 0; i < known.length; i++) {
@@ -71,5 +113,11 @@ export function billboardRemoteStatus(channels: BillboardChannels, fullscreen: b
71 status.setElement("slideVersion", jsonx.JsonElement.createInteger(channels.slideVersion.value))113 status.setElement("slideVersion", jsonx.JsonElement.createInteger(channels.slideVersion.value))
72 status.setElement("surface", jsonx.JsonElement.createString(billboardActiveSurface(active)))114 status.setElement("surface", jsonx.JsonElement.createString(billboardActiveSurface(active)))
73 status.setElement("fullscreen", jsonx.JsonElement.createBoolean(fullscreen))115 status.setElement("fullscreen", jsonx.JsonElement.createBoolean(fullscreen))
116+ // The build plant is optional: a target that predates the pipeline protocol
117+ // never sends the service frame, and the controller must still drive it.
118+ const pipeline = pipelineElement(includeArtifacts)
119+ if (pipeline !== undefined) {
120+ status.setElement("pipeline", pipeline)
121+ }
74 return status122 return status
75}123}
Marktsx/a2ui-billboard/lib/statusBadge.ets+53-0文件内容可能有违规信息
@@ -31,7 +31,7 @@
31import { Assert } from "@koalaui/harness"31import { Assert } from "@koalaui/harness"
32import { initCurrentStateManager } from "arkui.incremental.runtime"32import { initCurrentStateManager } from "arkui.incremental.runtime"
33import { StreamConnectionState } from "arktsx.a2ui"33import { StreamConnectionState } from "arktsx.a2ui"
34-import { StatusPlateKind, handleBillboardServiceMessage, hideStatusPlates, noteBoardDelivery, noteExchangeBroken, noteJudgeRequest, noteJudgeRound, parseBillboardServiceEvent, setBadgeConnection, setBadgeHidden, showStatusPlate, statusBadgeSnapshot } from "../lib/statusBadge"34+import { StatusPlateKind, billboardPipelineJson, billboardPipelineSummaryJson, handleBillboardServiceMessage, hasPipelineSnapshot, hideStatusPlates, noteBoardDelivery, noteExchangeBroken, noteJudgeRequest, noteJudgeRound, parseBillboardServiceEvent, setBadgeConnection, setBadgeHidden, showStatusPlate, statusBadgeSnapshot } from "../lib/statusBadge"
35 35 
36const LIVE_SURFACE: string = "billboard-channel-2"36const LIVE_SURFACE: string = "billboard-channel-2"
37 37 
@@ -331,6 +331,49 @@ export function testJudgeCaptureHidesOnlyTheView(): void {
331 Assert.equal(snapshot.includes("slide=9"), true)331 Assert.equal(snapshot.includes("slide=9"), true)
332}332}
333 333 
334+/**
335+ * The build plant is not a plate and not a delivery boundary: it is a whole
336+ * snapshot the remote controller and the CLI read back, so the badge stores it
337+ * verbatim and leaves the verdict cell alone.
338+ */
339+export function testPipelineServiceFrameIsStored(): void {
340+ initCurrentStateManager(true)
341+ hideStatusPlates()
342+ // Before any frame: the `pipeline` action answers "not published yet".
343+ Assert.equal(hasPipelineSnapshot(), false)
344+ const pipeline = new jsonx.JsonElement()
345+ pipeline.setElement("state", jsonx.JsonElement.createString("running"))
346+ pipeline.setElement("stage", jsonx.JsonElement.createString("g1"))
347+ const artifacts = new jsonx.JsonElement()
348+ const preview = new jsonx.JsonElement()
349+ preview.setElement("bytes", jsonx.JsonElement.createInteger(42))
350+ preview.setElement("preview", jsonx.JsonElement.createString("{\"plan\":true}"))
351+ artifacts.setElement("plan/plan.json", preview)
352+ pipeline.setElement("artifacts", artifacts)
353+ const data = new jsonx.JsonElement()
354+ data.setElement("kind", jsonx.JsonElement.createString("billboard"))
355+ data.setElement("event", jsonx.JsonElement.createString("pipeline"))
356+ data.setElement("pipeline", pipeline)
357+ handleBillboardServiceMessage(data, LIVE_SURFACE)
358+ Assert.equal(billboardPipelineJson().includes(`"stage":"g1"`), true)
359+ // The full form keeps the previews; the summary prepared for status polls
360+ // carries the stages but has the heavy half emptied.
361+ Assert.equal(billboardPipelineJson().includes(`{\\"plan\\":true}`), true)
362+ Assert.equal(billboardPipelineSummaryJson().includes(`"stage":"g1"`), true)
363+ Assert.equal(billboardPipelineSummaryJson().includes(`{\\"plan\\":true}`), false)
364+ Assert.equal(billboardPipelineSummaryJson().includes(`"artifacts":{}`), true)
365+ Assert.equal(hasPipelineSnapshot(), true)
366+ Assert.equal(statusBadgeSnapshot().includes("pipeline=present"), true)
367+ Assert.equal(statusBadgeSnapshot().includes("plate=''"), true)
368+ // A frame with no object under `pipeline` is ignored rather than storing
369+ // "undefined" as a snapshot.
370+ const empty = new jsonx.JsonElement()
371+ empty.setElement("kind", jsonx.JsonElement.createString("billboard"))
372+ empty.setElement("event", jsonx.JsonElement.createString("pipeline"))
373+ handleBillboardServiceMessage(empty, LIVE_SURFACE)
374+ Assert.equal(billboardPipelineJson().includes(`"stage":"g1"`), true)
375+}
376+ 
334export function main(): void {377export function main(): void {
335 testPlateOverwritesTheSingleCell()378 testPlateOverwritesTheSingleCell()
336 testHideRetiresThePlate()379 testHideRetiresThePlate()
@@ -346,4 +389,5 @@ export function main(): void {
346 testServerStagePhrasesCarryTheirCodes()389 testServerStagePhrasesCarryTheirCodes()
347 testNonStatusServiceEventsAreIgnored()390 testNonStatusServiceEventsAreIgnored()
348 testJudgeCaptureHidesOnlyTheView()391 testJudgeCaptureHidesOnlyTheView()
392+ testPipelineServiceFrameIsStored()
349}393}
@@ -2,4 +2,5 @@ dist-kh/
2build/2build/
3.precompile-work/3.precompile-work/
4.precompile-work.lock4.precompile-work.lock
5+.kh-build.stamp
5node_modules/6node_modules/
@@ -14,8 +14,9 @@ kh/graph/slide.ts the declared Plan/Brief/G/J/T pipelines
14kh/paths.ts the slide folder's artifact manifest14kh/paths.ts the slide folder's artifact manifest
15kh/widgets/ ContextPhase, RequestModel, Phase and discovery rules15kh/widgets/ ContextPhase, RequestModel, Phase and discovery rules
16src/kh/ contracts, prompts, context documents, narrative memory16src/kh/ contracts, prompts, context documents, narrative memory
17-src/host/ prompt assets, model transport, vision, formal rules, feed17+src/host/ prompt assets, model transport, vision, formal rules, feed, replay
18tools/live.mts the live host18tools/live.mts the live host
19+tools/replay-device.mts serve a settled slide's plant to the grasp CLI
19tools/precompile.mjs arkts/memo precompile → dist-kh/20tools/precompile.mjs arkts/memo precompile → dist-kh/
20tools/graph.mjs write the declared graph to build/ (diagnostic only)21tools/graph.mjs write the declared graph to build/ (diagnostic only)
21test/ deterministic pipeline, rules and context tests22test/ deterministic pipeline, rules and context tests
@@ -36,7 +37,30 @@ runs `kh:compile-deps` (the two `tspc` builds) and then the memo precompile.
36Smarty's `npm run build` / `build:plugins` also runs this build with37Smarty's `npm run build` / `build:plugins` also runs this build with
37`npm run kh:build -C ../arktsx/billboard-slides`, because its `billboard-kh`38`npm run kh:build -C ../arktsx/billboard-slides`, because its `billboard-kh`
38plugin starts the host and the host requires `dist-kh/` at startup.39plugin starts the host and the host requires `dist-kh/` at startup.
39-`npm run compile:billboard-slides` at the root does the same thing.40+`npm run compile:billboard-slides` at the root does the same thing. The package
41+owns no `kh.build.ts` — its pipeline is precompiled by its own ets-tsc/memo
42+tool, not by the kh2 daemon — so kh2's compile does not see it; the repository's
43+`npm run setup` therefore ends with `compile:billboard-slides`, and the package
44+also has its own CI job rather than joining `compile:all`.
45+ 
46+The live host does not rely on a manual build: `tools/live.mts` checks
47+`dist-kh/` against the source on start and rebuilds it when it is missing or
48+stale, so `kh:build` is only needed to pre-build for CI or `kh:verify`.
49+ 
50+## Replaying a finished slide
51+ 
52+The build plant normally lives in the billboard client's service frames. When the client is not
53+running (closed, crashed, or the host restarted), the slide folder still has every artifact and
54+transport record, and the host persisted the last published plant to `.kh/pipeline.json`:
55+ 
56+```bash
57+# Register a read-only grasp device that answers send_json from that snapshot.
58+BILLBOARD_WORKSPACE=<workspace> npx tsx tools/replay-device.mts
59+grasp json billboard-replay:0 '{"action":"pipeline"}'
60+```
61+ 
62+The remote controller can target the same device; its pipeline pane shows the stages, token
63+spend and artifact previews of the settled build.
40 64 
41## Running65## Running
42 66 
Marktsx/billboard-slides/kh/graph/slide.ts+25-0文件内容审核中,请稍后刷新重试
Marktsx/billboard-slides/kh/widgets/context.ts+1-0文件内容可能有违规信息
Marktsx/billboard-slides/kh/widgets/discovery.ts+29-19文件内容可能有违规信息
@@ -25,7 +25,8 @@
25 25 
26import { NodeAttach } from "@koalaui/runtime"26import { NodeAttach } from "@koalaui/runtime"
27import {27import {
28- attachRule, type BuildControl, type FileInfo, type HostFileSystem, type RuleNodeBuildResult,28+ attachRule, DescribeOutput, DescribeRule,
29+ type BuildControl, type FileInfo, type HostFileSystem, type RuleNodeBuildResult,
29} from "@koalaui/haus2-engine"30} from "@koalaui/haus2-engine"
30import { GenerationRuleNode } from "@koalaui/haus2-generation"31import { GenerationRuleNode } from "@koalaui/haus2-generation"
31 32 
@@ -62,6 +63,8 @@ export interface PhaseOptions {
62 readonly out: FileInfo | ReadonlyArray<FileInfo>63 readonly out: FileInfo | ReadonlyArray<FileInfo>
63 readonly ins: ReadonlyArray<FileInfo>64 readonly ins: ReadonlyArray<FileInfo>
64 readonly detail: string65 readonly detail: string
66+ /** What this rule is, in words a person reads on the build board. */
67+ readonly description: string
65 readonly body: PhaseBody68 readonly body: PhaseBody
66}69}
67 70 
@@ -74,6 +77,10 @@ export function Phase(options: PhaseOptions): FileInfo | ReadonlyArray<FileInfo>
74 node.setOutputFiles(Array.from(outs))77 node.setOutputFiles(Array.from(outs))
75 node.setMeta(pureMeta("phase", options.detail))78 node.setMeta(pureMeta("phase", options.detail))
76 node.body = options.body79 node.body = options.body
80+ // The board reads the rule's own words; it never derives a phase from
81+ // the first output's path (see miniapps PR #7, `tasksOfTree`).
82+ DescribeRule(options.description)
83+ for (const out of outs) DescribeOutput(out, options.description)
77 })84 })
78 return options.out85 return options.out
79}86}
Marktsx/billboard-slides/kh/widgets/request-model.ts+5-1文件内容可能有违规信息
@@ -78,6 +78,10 @@ export async function requestContext(file, options = {}) {
78 model: reply.model,78 model: reply.model,
79 text: reply.text,79 text: reply.text,
80 finishReason: reply.finishReason,80 finishReason: reply.finishReason,
81+ // The provider's own accounting, when it reports any: the pipeline
82+ // view sums these per stage, so a build's spend is an artifact of the
83+ // transport records rather than a second bookkeeping system.
84+ ...(reply.usage === undefined ? {} : { usage: reply.usage }),
81 createdAt: new Date().toISOString(),85 createdAt: new Date().toISOString(),
82 })86 })
83 return reply87 return reply
@@ -125,10 +129,16 @@ async function send(messages, options = {}, signal = undefined) {
125 if (response.ok) {129 if (response.ok) {
126 const payload = await response.json()130 const payload = await response.json()
127 const choice = payload.choices?.[0]131 const choice = payload.choices?.[0]
132+ const usage = payload.usage
128 return {133 return {
129 text: typeof choice?.message?.content === "string" ? choice.message.content : "",134 text: typeof choice?.message?.content === "string" ? choice.message.content : "",
130 finishReason: choice?.finish_reason === "length" ? "length" : "stop",135 finishReason: choice?.finish_reason === "length" ? "length" : "stop",
131 model,136 model,
137+ usage: usage === undefined || usage === null ? undefined : {
138+ prompt: Number(usage.prompt_tokens ?? 0),
139+ completion: Number(usage.completion_tokens ?? 0),
140+ total: Number(usage.total_tokens ?? 0),
141+ },
132 }142 }
133 }143 }
134 const failure = await response.text()144 const failure = await response.text()
@@ -0,0 +1,533 @@
1+/*
2+ * Copyright (c) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+ 
16+/**
17+ * A settled slide folder, read back as the plant a live build published.
18+ *
19+ * The live client holds the service frame that described the plant; when that
20+ * client is gone (closed, crashed, or the host restarted), the slide folder is
21+ * still on disk with every artifact and every transport record. The host now
22+ * persists the snapshot it published to `.kh/pipeline.json`; this module reads
23+ * that first, so a replay is the same snapshot rather than a second derivation
24+ * of the graph. The folder-derived projection remains for folders written
25+ * before that file existed.
26+ */
27+ 
28+import fs from "node:fs"
29+import path from "node:path"
30+ 
31+import {
32+ NO_TOKENS, SLIDE_STAGES, declaredStages, previewableArtifact, stageOfPath,
33+ withArtifactPreviews, withTokenUsage,
34+ type ArtifactPreview, type PipelineKind, type PipelineSnapshot, type PipelineStage, type TokenUsage,
35+} from "../kh/pipeline-view.ts"
36+ 
37+/** Per-artifact preview cap and the whole snapshot's budget for preview text. */
38+export const PREVIEW_BYTES = 4 * 1024
39+export const PREVIEW_TOTAL_BYTES = 64 * 1024
40+const BINARY_ARTIFACT = /\.(jpe?g|png|webp|gif|bmp|abc|so|woff2?|ttf|otf|mp4|webm|mov|pdf|zip)$/i
41+/** The engine's records and our own persisted snapshot are not artifacts. */
42+const INTERNAL_DIRECTORY = ".kh"
43+/** A transport record the host writes; no rule declares it as an artifact. */
44+const TRANSPORT_RECORD = /^llm\/[^/]+\.response\.json$/
45+ 
46+/** The snapshot the host published for this slide, when it left one behind. */
47+export const REPLAY_SNAPSHOT = ".kh/pipeline.json"
48+ 
49+function messageOf(error: unknown): string {
50+ return error instanceof Error ? error.message : String(error)
51+}
52+ 
53+/** A file that is not there is expected (an artifact the build has not reached). */
54+function missing(error: unknown): boolean {
55+ const code = (error as NodeJS.ErrnoException).code
56+ return code === "ENOENT" || code === "ENOTDIR"
57+}
58+ 
59+function report(what: string, error: unknown): void {
60+ console.error(`[replay] ${what}: ${messageOf(error)}`)
61+}
62+ 
63+function realPath(file: string): string | undefined {
64+ try {
65+ return fs.realpathSync(file)
66+ } catch (error) {
67+ if (!missing(error)) report(`cannot resolve ${file}`, error)
68+ return undefined
69+ }
70+}
71+ 
72+interface ResolvedArtifact {
73+ readonly file: string
74+ readonly bytes: number
75+ readonly mtimeMs: number
76+}
77+ 
78+/**
79+ * Resolve an artifact inside the slide folder. Containment is checked on the
80+ * real path, not the lexical one: a symlink planted in a shared workspace must
81+ * not turn a preview into a read of a file outside it.
82+ */
83+function resolveArtifact(root: string, relative: string): ResolvedArtifact | undefined {
84+ const base = realPath(root)
85+ if (base === undefined) return undefined
86+ const resolved = path.resolve(base, relative)
87+ if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) return undefined
88+ const file = realPath(resolved)
89+ if (file === undefined) return undefined
90+ if (file !== base && !file.startsWith(`${base}${path.sep}`)) return undefined
91+ let stat: fs.Stats
92+ try {
93+ stat = fs.statSync(file)
94+ } catch (error) {
95+ if (!missing(error)) report(`cannot stat ${file}`, error)
96+ return undefined
97+ }
98+ if (!stat.isFile()) return undefined
99+ return { file: file, bytes: stat.size, mtimeMs: stat.mtimeMs }
100+}
Aarktsx/billboard-slides/src/kh/pipeline-view.ts+582-0文件内容可能有违规信息
Aarktsx/billboard-slides/test/phase-descriptions.test.mjs+71-0文件内容可能有违规信息
Aarktsx/billboard-slides/test/pipeline-view.test.mjs+312-0文件内容可能有违规信息
@@ -0,0 +1,157 @@
1+/*
2+ * Copyright (c) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+ 
16+/**
17+ * A settled slide folder read back as a plant: the files become clean stage
18+ * outputs, the declared stage inputs survive, token spend comes from the
19+ * transport records, and previews are bounded per artifact.
20+ */
21+ 
22+import assert from 'node:assert/strict'
23+import fs from 'node:fs'
24+import os from 'node:os'
25+import path from 'node:path'
26+import { test } from 'node:test'
27+ 
28+import { latestSlideDir, snapshotFromSlideDir } from '../src/host/replay.ts'
29+ 
30+function write(root, relative, content) {
31+ const file = path.join(root, relative)
32+ fs.mkdirSync(path.dirname(file), { recursive: true })
33+ fs.writeFileSync(file, content)
34+}
35+ 
36+function slideFolder() {
37+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'billboard-replay-'))
38+ write(dir, 'slide.json', JSON.stringify({
39+ channel: 2, slide: 1, surfaceId: 'billboard-channel-2',
40+ frame: { width: 1920, height: 1080 },
41+ }))
42+ write(dir, 'brief.json', '{"topic":"italian painting"}')
43+ write(dir, 'narrative.json', '{"beats":[]}')
44+ write(dir, 'plan/plan.json', '{"slides":[{"headline":"First"}]}')
45+ write(dir, 'brief/out.json', '{"subject":"a brief"}')
46+ write(dir, 'slides/g0.tersel', 'const card = Card(bg);')
47+ write(dir, 'render/g0.jpg', Buffer.from([0xff, 0xd8, 0xff, 0xe0]))
48+ write(dir, 'llm/plan.request.json', '{"label":"plan","stage":"plan"}')
49+ write(dir, 'llm/plan.response.json', JSON.stringify({
50+ label: 'plan', stage: 'plan',
51+ usage: { prompt: 800, completion: 200, total: 1000 },
52+ }))
53+ // A provider that reports no usage still ran one call.
54+ write(dir, 'llm/g0.a0.call1.response.json', '{"label":"g0.a0.call1"}')
55+ write(dir, 'publish/final.json', '{"channel":"2"}')
56+ return dir
57+}
58+ 
59+test('a settled slide folder becomes a completed plant', () => {
60+ const dir = slideFolder()
61+ try {
62+ const snapshot = snapshotFromSlideDir(dir)
63+ assert.notEqual(snapshot, undefined)
64+ assert.equal(snapshot.state, 'done')
65+ assert.equal(snapshot.kind, 'slide')
66+ assert.equal(snapshot.channel, 2)
67+ assert.equal(snapshot.slide, 1)
68+ assert.equal(snapshot.stage, 'publish')
69+ assert.equal(snapshot.generation, 0)
70+ 
71+ const byId = new Map(snapshot.stages.map((stage) => [stage.id, stage]))
72+ assert.equal(byId.get('context').state, 'clean')
73+ // plan/plan.json and the request record; a *.response.json is a host
74+ // transport record, not a graph artifact.
75+ assert.equal(byId.get('plan').done, 2)
76+ assert.deepEqual(byId.get('plan').outputs.map((output) => output.path).sort(),
77+ ['llm/plan.request.json', 'plan/plan.json'])
78+ // Declared inputs, kept only when the file is actually present.
79+ assert.deepEqual(byId.get('plan').inputs, ['brief.json', 'narrative.json'])
80+ assert.equal(byId.get('g0').done, 2) // slides/g0.tersel + render/g0.jpg
81+ assert.equal(byId.get('publish').done, 1)
82+ assert.equal(byId.get('g1').state, 'idle')
83+ 
84+ // Token spend still comes from the transport record; a record without
85+ // usage still counts its call.
86+ assert.equal(byId.get('plan').tokens.total, 1000)
87+ assert.equal(byId.get('g0').tokens.calls, 1)
88+ assert.equal(byId.get('g0').tokens.total, 0)
89+ assert.equal(snapshot.tokens.total, 1000)
90+ assert.equal(snapshot.tokens.calls, 2)
91+ 
92+ // Text artifacts carry a preview; a JPEG keeps only its size, and a
93+ // transport record keeps only its size too.
94+ assert.equal(snapshot.artifacts['plan/plan.json'].preview, '{"slides":[{"headline":"First"}]}')
95+ assert.equal(snapshot.artifacts['render/g0.jpg'].preview, undefined)
96+ assert.equal(snapshot.artifacts['render/g0.jpg'].bytes, 4)
97+ assert.equal(snapshot.artifacts['llm/plan.request.json'].preview, undefined)
98+ assert.equal(snapshot.artifacts['llm/plan.request.json'].bytes, 31)
99+ } finally {
100+ fs.rmSync(dir, { recursive: true, force: true })
101+ }
102+})
103+ 
104+test('the host-published snapshot wins over a second derivation', () => {
105+ const dir = slideFolder()
106+ try {
107+ write(dir, '.kh/pipeline.json', JSON.stringify({
108+ version: 1, kind: 'slide', state: 'done', channel: 2, slide: 1,
109+ surface: 'billboard-channel-2', generation: 1, stage: 'publish', status: 'G1',
110+ elapsedMs: 1234, startedAtMs: 1, updatedAtMs: 2,
111+ stages: [{ id: 'g1', label: 'G1', description: '', state: 'clean', done: 1, total: 1,
112+ running: 0, inputs: [], outputs: [{ path: 'slides/g1.tersel', state: 'clean' }],
113+ tokens: { prompt: 0, completion: 0, total: 0, calls: 0 } }],
114+ tokens: { prompt: 0, completion: 0, total: 0, calls: 0 }, artifacts: {}, blocked: [],
115+ }))
116+ const snapshot = snapshotFromSlideDir(dir)
117+ assert.equal(snapshot.generation, 1)
118+ assert.equal(snapshot.stage, 'publish')
119+ assert.deepEqual(snapshot.stages.map((stage) => stage.id), ['g1'])
120+ } finally {
121+ fs.rmSync(dir, { recursive: true, force: true })
122+ }
123+})
124+ 
125+test('a feed slide replays as its one feed stage', () => {
126+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'billboard-feed-'))
127+ try {
128+ write(dir, 'slide.json', JSON.stringify({ channel: 0, slide: 3, surfaceId: 'billboard' }))
129+ write(dir, 'feed/screen.json', '{"source":"gitee"}')
130+ write(dir, 'slides/feed.tersel', 'const card = Card(bg);')
131+ write(dir, 'publish/final.json', '{"channel":"0"}')
132+ const snapshot = snapshotFromSlideDir(dir)
133+ assert.equal(snapshot.kind, 'feed')
134+ assert.equal(snapshot.stage, 'feed')
135+ assert.deepEqual(snapshot.stages.map((stage) => stage.id), ['feed'])
136+ assert.equal(snapshot.stages[0].done, 3)
137+ } finally {
138+ fs.rmSync(dir, { recursive: true, force: true })
139+ }
140+})
141+ 
142+test('the latest published slide is the one replayed', () => {
143+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'billboard-workspace-'))
144+ try {
145+ const older = path.join(workspace, 'channel-0', 'feed-a', 'slide-0001')
146+ const newer = path.join(workspace, 'channel-2', 'topic-b', 'slide-0002')
147+ for (const dir of [older, newer]) {
148+ write(dir, 'slide.json', JSON.stringify({ channel: dir === newer ? 2 : 0, slide: 1 }))
149+ write(dir, 'publish/final.json', '{}')
150+ }
151+ fs.utimesSync(path.join(older, 'publish', 'final.json'), new Date(1000), new Date(1000))
152+ fs.utimesSync(path.join(newer, 'publish', 'final.json'), new Date(2000), new Date(2000))
153+ assert.equal(latestSlideDir(workspace), newer)
154+ } finally {
155+ fs.rmSync(workspace, { recursive: true, force: true })
156+ }
157+})
Marktsx/billboard-slides/tools/live.mts+287-2文件内容审核中,请稍后刷新重试
Aarktsx/billboard-slides/tools/replay-device.mts+226-0文件内容审核中,请稍后刷新重试
Marktsx/remote-controller/lib/app.ets+1005-6文件内容审核中,请稍后刷新重试
Marktsx/remote-controller/lib/controller.ets+75-6文件内容审核中,请稍后刷新重试
Marktsx/remote-controller/lib/model.ets+16-4文件内容审核中,请稍后刷新重试
Marktsx/remote-controller/lib/protocol.ets+515-1文件内容审核中,请稍后刷新重试
Marktsx/remote-controller/test/remote-protocol.test.ets+129-0文件内容审核中,请稍后刷新重试
Mdocs/second-brain/10_Architecture/Billboard_Slides_KH2_Generation_Pipeline.md+110-3文件内容审核中,请稍后刷新重试
Mdocs/second-brain/30_Support_Libraries/A2UI_Billboard.md+13-0文件内容审核中,请稍后刷新重试
Mdocs/second-brain/30_Support_Libraries/Remote_Controller.md+46-4文件内容审核中,请稍后刷新重试
Mpackage.json+1-1文件内容审核中,请稍后刷新重试
Msmarty/src/smarty/plugins/billboard-kh/application.ts+34-0文件内容审核中,请稍后刷新重试
Msmarty/test/billboard-kh-adapter.test.ts+25-0文件内容审核中,请稍后刷新重试