已合并
Watcher of pipeline progress #293
nikolay-igotti-2创建于 4 天前
Watcher of pipeline progress #293
已合并
共 30 个文件变更+4405-54
| @@ -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 | ||
| 25 | import { BillboardChannel, BillboardChannels } from "./channels" | 25 | import { 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. */ |
| 28 | export const BILLBOARD_REMOTE_KIND: string = "billboard" | 29 | export 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 is | 84 | * 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 the | 86 | * 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.value | 94 | 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 status | 122 | return status |
| 75 | } | 123 | } |
| @@ -31,7 +31,7 @@ | |||
| 31 | import { Assert } from "@koalaui/harness" | 31 | import { Assert } from "@koalaui/harness" |
| 32 | import { initCurrentStateManager } from "arkui.incremental.runtime" | 32 | import { initCurrentStateManager } from "arkui.incremental.runtime" |
| 33 | import { StreamConnectionState } from "arktsx.a2ui" | 33 | import { 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 | ||
| 36 | const LIVE_SURFACE: string = "billboard-channel-2" | 36 | const 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 | + | ||
| 334 | export function main(): void { | 377 | export 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/ | |||
| 2 | build/ | 2 | build/ |
| 3 | .precompile-work/ | 3 | .precompile-work/ |
| 4 | .precompile-work.lock | 4 | .precompile-work.lock |
| 5 | +.kh-build.stamp | ||
| 5 | node_modules/ | 6 | node_modules/ |
| @@ -14,8 +14,9 @@ kh/graph/slide.ts the declared Plan/Brief/G/J/T pipelines | |||
| 14 | kh/paths.ts the slide folder's artifact manifest | 14 | kh/paths.ts the slide folder's artifact manifest |
| 15 | kh/widgets/ ContextPhase, RequestModel, Phase and discovery rules | 15 | kh/widgets/ ContextPhase, RequestModel, Phase and discovery rules |
| 16 | src/kh/ contracts, prompts, context documents, narrative memory | 16 | src/kh/ contracts, prompts, context documents, narrative memory |
| 17 | -src/host/ prompt assets, model transport, vision, formal rules, feed | 17 | +src/host/ prompt assets, model transport, vision, formal rules, feed, replay |
| 18 | tools/live.mts the live host | 18 | tools/live.mts the live host |
| 19 | +tools/replay-device.mts serve a settled slide's plant to the grasp CLI | ||
| 19 | tools/precompile.mjs arkts/memo precompile → dist-kh/ | 20 | tools/precompile.mjs arkts/memo precompile → dist-kh/ |
| 20 | tools/graph.mjs write the declared graph to build/ (diagnostic only) | 21 | tools/graph.mjs write the declared graph to build/ (diagnostic only) |
| 21 | test/ deterministic pipeline, rules and context tests | 22 | test/ deterministic pipeline, rules and context tests |
| @@ -36,7 +37,30 @@ runs `kh:compile-deps` (the two `tspc` builds) and then the memo precompile. | |||
| 36 | Smarty's `npm run build` / `build:plugins` also runs this build with | 37 | Smarty'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` |
| 38 | plugin starts the host and the host requires `dist-kh/` at startup. | 39 | plugin 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 | ## Running | 65 | ## Running |
| 42 | 66 | ||
| @@ -25,7 +25,8 @@ | |||
| 25 | 25 | ||
| 26 | import { NodeAttach } from "@koalaui/runtime" | 26 | import { NodeAttach } from "@koalaui/runtime" |
| 27 | import { | 27 | import { |
| 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" |
| 30 | import { GenerationRuleNode } from "@koalaui/haus2-generation" | 31 | import { 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: string | 65 | readonly detail: string |
| 66 | + /** What this rule is, in words a person reads on the build board. */ | ||
| 67 | + readonly description: string | ||
| 65 | readonly body: PhaseBody | 68 | 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.body | 79 | 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.out | 85 | return options.out |
| 79 | } | 86 | } |
| @@ -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 reply | 87 | 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 | +} | ||
| @@ -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 | +}) | ||