From 4df4bc96245c01cd95e35ddf1b2b03d0e0d231c5 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 6 Sep 2026 09:50:49 +0800 Subject: [PATCH] feat: integrate automatic game resource delivery --- ...06-game-resource-auto-delivery-7a4e2c91.md | 119 +++++ README.md | 2 +- electron/api/coding-composition.ts | 31 +- .../coding-plugins/adapters/game-resource.ts | 103 ++-- electron/coding-plugins/registry.ts | 28 +- electron/coding-runtime/pi/extension-host.ts | 41 +- .../pi/extensions/makelore-runtime.ts | 72 ++- electron/coding-runtime/pi/product-tools.ts | 2 + electron/coding-runtime/pi/release-proof.ts | 2 +- electron/services/game-resource-delivery.ts | 471 +++++++++++++++++ .../com.makelore/capability.json | 95 +--- .../skills/game-resource/SKILL.md | 28 +- src/pages/Chat/CodingConversationTimeline.tsx | 35 +- tests/unit/coding-capability-registry.test.ts | 49 +- .../coding-conversation-timeline.test.tsx | 94 ++++ tests/unit/coding-plugin-manifest.test.ts | 14 + tests/unit/game-resource-delivery.test.ts | 494 ++++++++++++++++++ .../unit/game-resource-plugin-adapter.test.ts | 67 ++- tests/unit/pi-extension-bundle.test.ts | 109 ++++ tests/unit/pi-managed-worker-opener.test.ts | 2 +- 20 files changed, 1667 insertions(+), 191 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260906-game-resource-auto-delivery-7a4e2c91.md create mode 100644 electron/services/game-resource-delivery.ts create mode 100644 tests/unit/game-resource-delivery.test.ts diff --git a/.project-docs/30-worklog/tasks/20260906-game-resource-auto-delivery-7a4e2c91.md b/.project-docs/30-worklog/tasks/20260906-game-resource-auto-delivery-7a4e2c91.md new file mode 100644 index 0000000..0066c61 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260906-game-resource-auto-delivery-7a4e2c91.md @@ -0,0 +1,119 @@ +# Task: Implement automatic Game Resource project delivery + +## Identity + +- Task ID: 20260906-game-resource-auto-delivery-7a4e2c91 +- Mode: Feature +- Branch: codex/20260906-game-resource-auto-delivery-7a4e2c91-game-resource-auto-delivery +- Worktree: D:\Datas\OthersProjects\makelore-game-resource-auto-delivery-7a4e2c91 +- Base commit: f721966f3c8db982279e34033b94b4347f0a8174 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Replace the Agent-driven Game Resource `generate -> status polling -> path prompt -> save` + choreography with one Main-owned generate-and-deliver workflow. +- Keep the existing Works Square hosted generation/status/content protocol and billing + authority; do not add a second Provider submission or charge during polling or local + delivery recovery. +- Materialize every completed output beneath the frozen original project, using + deterministic project-relative paths and the existing short-lived project write lease. +- Project bounded progress and the terminal saved paths through the existing Pi product-tool + bridge, while removing status/save instructions from the Agent-facing Skill surface. +- Add public-seam unit/integration coverage for submit-once polling, replay, multi-output + delivery, project switching, write conflicts, local retry, cancellation/detach, and + packaged resource consistency. + +## Intent And Constraints + +- User confirmation covers the metered generation and automatic delivery together. There is + no second filename or save confirmation. +- Provider execution and local delivery are distinct states. A successful Provider result is + never regenerated or refunded solely because local materialization fails. +- Freeze the trusted project ID/path when the operation begins. A later active-project switch + must not redirect files. +- Do not hold the project write lease while the remote job is queued/running. Acquire it only + for the bounded terminal materialization transaction and always release it. +- Replays of the same logical operation return the same execution and project-relative paths; + they do not submit or charge again. +- A conversation/worker abort detaches the waiting UI. It does not silently cancel an already + accepted remote execution; the explicit cancel capability retains its existing meaning. +- The Renderer, Pi extension, Skill, and model do not gain filesystem, credential, Provider, + billing, or retry authority. Electron Main remains the deep owner. +- No server, Marketplace policy, pricing, Provider configuration, or deployment change is in + scope unless implementation evidence proves the current typed protocol cannot express this + client-owned orchestration. +- Concurrent Task Gate: Passed. This isolated feature task is the only current owner of the + Game Resource delivery/bridge/resource files; the user root worktree is not touched. +- Planning Gate: Passed after loading current project memory, ADR-006, ADR-008, the prior + Game Resource implementation and project-wide activation records, the accepted delivery + design record, and the concrete Main/Pi/write-lease implementation seams. + +## Outcome + +- Added a Main-owned `GameResourceDeliveryCoordinator` and atomic local receipt store. + One confirmed `game_resource_generate` call now submits once, polls the existing typed + execution internally, downloads every terminal output, and writes deterministic + create-only files beneath + `assets/generated/game-resource//output-.` in the frozen original + project. +- Provider and local-delivery state remain separate. Successful Provider executions use + up to three bounded local delivery attempts; unresolved receipts survive app restart or + session refresh and resume without another generation request or Token Point charge. + One unavailable reconciliation no longer blocks other persisted deliveries. +- The coordinator downloads before acquiring the shared Pi project write lease, holds that + lease only while creating project files and recording touched paths, preserves existing + files with different bytes, and treats a missing historical run in the in-memory change + tracker as a non-fatal post-save condition. +- Extended the authenticated Pi product bridge with an opt-in NDJSON progress path for + dynamic job tools and bumped the generated extension to `makelore-runtime-v6.mjs`. + Game Resource now projects one card through submitted, generating, saving, saved, and + failure/review states instead of exposing repeated Agent polling calls. +- Reduced the shipped Game Resource Agent surface to templates, generate, explicit cancel, + browser, and review. `game_resource_status` and `game_resource_save_output` remain + internal transport capabilities only and no longer appear in the package manifest or + Skill. One user confirmation now explicitly covers both the metered generation and + automatic save of all outputs. +- Wired one shared write-lease coordinator and delivery coordinator in Coding composition, + including background-lifecycle ownership, startup/session receipt recovery, shutdown + detachment, and existing change tracking. No server, Provider, Marketplace policy, + pricing, admission, or billing authority changed. + +## Verification + +- TDD public-seam red/green: the new coordinator tests first exposed missing transient + delivery retry, cross-receipt recovery isolation, and the late saving-progress transition; + the final coordinator suite passes 10/10. Coverage includes submit-once polling, + concurrent and persisted replay, multi-output save, frozen project path, background and + project write leases, restart recovery, download/write failure recovery, no-overwrite + conflicts, pending review, touched paths, and progress ordering. +- Focused Game Resource/Registry/Pi bridge/resource/timeline/composition suite: 13 files, + 99 tests passed; the coordinator subset is 10/10 and the manifest/artifact pair is 25/25. +- Full unit suite: 227 files, 1,901 passed, 2 skipped; isolated pressure suite 1/1 passed. +- TypeScript typecheck passed. Full ESLint passed with zero errors and only five unchanged + warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- Vite Renderer/Main/Preload/utility production build passed. Windows Electron tests passed + 2 files / 8 tests. Unified Plugin workspace Electron E2E passed 3/3. +- Windows x64 Pi runtime stage passed with 130 production packages and six runtime assets. + Built Main output contains `makelore-runtime-v6.mjs`; the retired Agent-facing status/save + tool names are absent from the Game Resource package and built Main output. +- `git diff --check` passed. Project documentation and task drift gates are completed during + final handoff. +- No live paid Provider generation was run: doing so would create a real Token Point charge + and is not needed to verify this client-owned orchestration change. + +## Follow-ups + +- Integration mode should promote the architecture/data-flow/business-rule/current-state + candidates below after accepting this source commit. A live paid Provider acceptance may + be run separately only with explicit authorization for the charge. + +## Promotion Candidates + +- Target: ADR/module-map/data-flow/business rules/current state/success criteria. + Proposal: record that Game Resource generation is one Main-owned submit-and-deliver + operation; polling is internal, all outputs are automatically saved under the frozen + original project, and the write lease is held only during terminal materialization. + Evidence: implementation commit plus focused/lifecycle/package verification. Future impact: + Agents no longer own hosted-job polling, path selection, or a second save confirmation. diff --git a/README.md b/README.md index fe265a7..52b1050 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 产品内置四个核心编码技能根:`agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划),统一从 vendor-neutral 的 `resources/coding-skills/` 打包。`data-service`(开发数据)由固定的 `resources/coding-plugins/data-service/` 插件包持有,不在核心技能根中复制路径或定义。 - Marketplace 官方插件 Skill 从已签名 Package Store Release 或受控内置定义动态物化,不进入核心技能根;parent worker 的冻结 effective snapshot 必须满足对应账号、设备、项目、Policy,以及该插件确实要求时的伙伴分配条件,child worker 不继承 hosted tool。所选模型的 Web Search 与 Conversation 安装的设备包使用各自独立的 Main-owned 合同,不属于这条 Marketplace 生命周期。 -- `makelore.data-service`、`makelore.game-resource` 与 `makelore.project-scaffold` 是代码所有的官方项目级例外:满足各自既有账号获取或随应用提供条件并在项目启用后,其完整 Skill/tool 集合自动进入该项目的每个 parent Agent,不要求或展示伙伴分配;禁用项目插件后未来 worker 不再加载,既有 assignment 数据可原样保留但不参与生效判断。其他 Marketplace 插件继续按自身 assignment 规则计算有效资源。 +- `makelore.data-service`、`makelore.game-resource` 与 `makelore.project-scaffold` 是代码所有的官方项目级例外:满足各自既有账号获取或随应用提供条件并在项目启用后,其完整 Skill/tool 集合自动进入该项目的每个 parent Agent,不要求或展示伙伴分配;禁用项目插件后未来 worker 不再加载,既有 assignment 数据可原样保留但不参与生效判断。游戏资源生成只需一次计费与项目写入确认,Main 会在后台完成状态查询并把全部输出自动保存到发起操作时的原项目;本地交付续作不会重新生成或扣费。其他 Marketplace 插件继续按自身 assignment 规则计算有效资源。 - `data-service` 只在用户显式请求后触发:先检查并说明最小集合,用户确认后配置一次、复制 SDK 资产,再用本地预览执行 put/read-back;它不用于已发布作品。 - 创建项目智能体时,`agent-browser`、`grilling` 与 `planning-with-files` 默认启用;`frontend-slides` 作为专项能力可手动启用。用户可以在创建或编辑智能体时调整选择。最终选择写入项目智能体的 `skillIds`。 - `grilling` 会在复杂实现前逐项确认高影响决策,用户确认前不执行变更。`planning-with-files` 只在复杂、可分阶段或需要跨会话恢复的任务中使用,并把 `task_plan.md`、`findings.md` 和 `progress.md` 直接保存到当前项目根目录,不写入 Skill 安装目录、用户目录或隐藏配置目录。 diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 56c5b02..aa8ac13 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -11,6 +11,7 @@ import { } from '../coding-projects/project-store'; import { CodingConversationService } from '../coding-runtime/conversation-service'; import { PiManagedExtensionHost } from '../coding-runtime/pi/extension-host'; +import { PiProjectWriteLeaseCoordinator } from '../coding-runtime/pi/write-lease'; import { PiAgentServerProcess } from '../coding-runtime/pi/agent-server-process'; import { PiManagedInputRevisionCoordinator } from '../coding-runtime/pi/managed-input-revision'; import { PiProductTools } from '../coding-runtime/pi/product-tools'; @@ -46,6 +47,10 @@ import { import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service'; import { createGameResourcePluginAdapter } from '../coding-plugins/adapters/game-resource'; import { GameResourceClient } from '../services/game-resource-client'; +import { + GameResourceDeliveryCoordinator, + GameResourceDeliveryReceiptStore, +} from '../services/game-resource-delivery'; import { AccountPluginCache } from '../coding-plugins/account-plugin-cache'; import { createMarketplaceClient, @@ -229,6 +234,7 @@ export function createCodingComposition( onGenerationChanged: async () => await invalidateDeviceResources(), }); const devicePackageTools = new DevicePackageTools(devicePackageManager); + const projectWriteLeases = new PiProjectWriteLeaseCoordinator(); const productTools = new PiProductTools({ browser: options.browser, attachments, @@ -245,7 +251,7 @@ export function createCodingComposition( })) : [], }); - const extensionHost = new PiManagedExtensionHost(); + const extensionHost = new PiManagedExtensionHost(projectWriteLeases); extensionHost.configureProductTools(productTools); const conversationStores = new Map>(); const conversationStoreForProject = (projectPath: string) => { @@ -302,8 +308,26 @@ export function createCodingComposition( }); const dataService = createDataServiceOperations({ projects }); const dataServiceAdapter = createDataServicePluginAdapter(dataService); + const gameResourceClient = new GameResourceClient(); + const gameResourceDelivery = new GameResourceDeliveryCoordinator({ + client: gameResourceClient, + receipts: new GameResourceDeliveryReceiptStore(path.join( + options.paths.userDataDir, + 'coding-runtime', + 'game-resource', + 'receipts.json', + )), + leases: projectWriteLeases, + recordTouchedPaths: async (conversationId, runId, paths) => { + await productTools.recordTouchedPaths(conversationId, runId, paths); + }, + ...(options.acquireBackgroundLease + ? { acquireBackgroundLease: options.acquireBackgroundLease } + : {}), + }); const gameResourceAdapter = createGameResourcePluginAdapter({ - client: new GameResourceClient(), + client: gameResourceClient, + delivery: gameResourceDelivery, marketplace: marketplaceClient, packageStore, makeloreVersion: options.clientVersion ?? '2.0.0', @@ -474,8 +498,10 @@ export function createCodingComposition( await invalidateManagedResources(); }, }); + void gameResourceDelivery.resumePending().catch(() => undefined); const unsubscribeMarketplaceSession = subscribeWorksSquareSession(() => { void invalidateManagedResources(); + void gameResourceDelivery.resumePending().catch(() => undefined); }); const host = createCodingProductHost({ projects, @@ -520,6 +546,7 @@ export function createCodingComposition( await agentServer.stop(); }, async shutdown() { + gameResourceDelivery.dispose(); previewDataSession?.dispose(); if (typeof options.browser.configurePreviewDataSession === 'function') { options.browser.configurePreviewDataSession(undefined); diff --git a/electron/coding-plugins/adapters/game-resource.ts b/electron/coding-plugins/adapters/game-resource.ts index 4cbfb37..8aa6b0a 100644 --- a/electron/coding-plugins/adapters/game-resource.ts +++ b/electron/coding-plugins/adapters/game-resource.ts @@ -1,5 +1,5 @@ import { Buffer } from 'node:buffer'; -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'; import { PiGameAssetTools } from '../../coding-runtime/pi/extensions/game-assets'; @@ -14,6 +14,11 @@ import { GameResourceClientError, type GameResourceGeneration, } from '../../services/game-resource-client'; +import type { + GameResourceDeliveryCoordinator, + GameResourceDeliveryProgress, + GameResourceDeliveryResult, +} from '../../services/game-resource-delivery'; import type { AdapterInvocationResult, CodingPluginAdapter, @@ -30,6 +35,7 @@ type Input = Record; export interface GameResourcePluginAdapterOptions { readonly client: GameResourceClient; + readonly delivery: GameResourceDeliveryCoordinator; readonly marketplace: MarketplacePackageClientPort; readonly packageStore: Pick; readonly makeloreVersion: string; @@ -89,6 +95,20 @@ function generationData(value: GameResourceGeneration, includeBilling: boolean) }; } +function deliveryData( + value: GameResourceDeliveryResult | GameResourceDeliveryProgress, +) { + return { + executionId: value.executionId, + providerStatus: value.providerStatus, + deliveryStatus: value.deliveryStatus, + outputCount: value.outputCount, + files: value.files, + ...('phase' in value ? { phase: value.phase } : {}), + ...(value.errorCode === undefined ? {} : { errorCode: value.errorCode }), + }; +} + function clientFailure(error: unknown): AdapterInvocationResult { if (error instanceof GameResourceClientError) { return failure(error.code, error.message, error.status, error.retryable); @@ -191,6 +211,7 @@ export class GameResourcePluginAdapter implements CodingPluginAdapter { context: TrustedCodingCapabilityContext, tool: CodingPluginToolDefinition, input: unknown, + onProgress?: (result: AdapterInvocationResult) => void, ): Promise { if (!isRecord(input)) return failure('plugin_input_invalid', 'Game-resource tool input is invalid', 422, false); try { @@ -215,24 +236,41 @@ export class GameResourcePluginAdapter implements CodingPluginAdapter { return failure('plugin_input_invalid', 'Game-resource generation input is invalid', 422, false); } const admission = await this.admission(context); - const generated = await this.options.client.generate({ - ...admission, - projectId: context.durableProjectId, - logicalOperationId: context.requestId, - kind, - templateName: input.templateName, - templateConfig: templateConfig(input.templateConfigJson), - requirement: input.requirement, - ...(typeof input.aspectRatio === 'string' ? { aspectRatio: input.aspectRatio } : {}), - ...(typeof input.temperature === 'number' ? { temperature: input.temperature } : {}), - ...(typeof input.jobName === 'string' ? { jobName: input.jobName } : {}), - ...(typeof input.modelName === 'string' ? { modelName: input.modelName } : {}), - ...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}), - ...(typeof input.hdRemoveBgMode === 'string' ? { hdRemoveBgMode: input.hdRemoveBgMode } : {}), - ...(typeof input.threadId === 'string' ? { threadId: input.threadId } : {}), - referenceFiles: await references(context.projectPath, input.referencePaths), + const generated = await this.options.delivery.generateAndMaterialize({ + context: { + conversationId: context.conversationId, + runId: context.runId, + localProjectId: context.localProjectId, + durableProjectId: context.durableProjectId, + projectPath: context.projectPath, + logicalOperationId: context.requestId, + }, + request: { + ...admission, + projectId: context.durableProjectId, + logicalOperationId: context.requestId, + kind, + templateName: input.templateName, + templateConfig: templateConfig(input.templateConfigJson), + requirement: input.requirement, + ...(typeof input.aspectRatio === 'string' ? { aspectRatio: input.aspectRatio } : {}), + ...(typeof input.temperature === 'number' ? { temperature: input.temperature } : {}), + ...(typeof input.jobName === 'string' ? { jobName: input.jobName } : {}), + ...(typeof input.modelName === 'string' ? { modelName: input.modelName } : {}), + ...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}), + ...(typeof input.hdRemoveBgMode === 'string' ? { hdRemoveBgMode: input.hdRemoveBgMode } : {}), + ...(typeof input.threadId === 'string' ? { threadId: input.threadId } : {}), + referenceFiles: await references(context.projectPath, input.referencePaths), + }, + ...(onProgress ? { + onProgress: (progress) => onProgress(success( + deliveryData(progress), + 202, + progress.billing, + )), + } : {}), }); - if (generated.status === 'failed' || generated.status === 'cancelled') { + if (generated.providerStatus === 'failed' || generated.providerStatus === 'cancelled') { return failure( generated.errorCode ?? 'game_resource_generation_failed', 'Hosted game-resource generation was rejected', @@ -242,41 +280,16 @@ export class GameResourcePluginAdapter implements CodingPluginAdapter { ); } return success( - generationData(generated, false), - generated.status === 'succeeded' ? 200 : 202, + deliveryData(generated), + generated.deliveryStatus === 'saved' ? 200 : 202, generated.billing, ); } - case 'game_resource_status': { - const executionId = this.executionId(input.executionId); - const generated = await this.options.client.get(executionId); - return success(generationData(generated, true)); - } case 'game_resource_cancel': { const executionId = this.executionId(input.executionId); const generated = await this.options.client.cancel(executionId); return success(generationData(generated, true)); } - case 'game_resource_save_output': { - if (input.confirmed !== true) return failure('confirmation_required', 'Explicit save confirmation is required', 400, false); - const executionId = this.executionId(input.executionId); - const outputIndex = input.outputIndex === undefined ? undefined : input.outputIndex; - if (outputIndex !== undefined && (!Number.isSafeInteger(outputIndex) || (outputIndex as number) < 0 || (outputIndex as number) > 99)) { - return failure('plugin_input_invalid', 'Game-resource output index is invalid', 422, false); - } - const target = relativeProjectPath(context.projectPath, input.relativePath); - const content = await this.options.client.download(executionId, outputIndex as number | undefined); - await mkdir(path.dirname(target.absolute), { recursive: true }); - try { - await writeFile(target.absolute, content.bytes, { flag: 'wx' }); - } catch (error) { - if (isRecord(error) && error.code === 'EEXIST') { - return failure('game_resource_destination_exists', 'Destination file already exists', 409, false); - } - throw error; - } - return success({ savedPath: target.relative, bytes: content.bytes.byteLength }); - } case 'game_asset_browser': { const result = await this.gameAssets.browse(context.projectPath, input, context.requestId); const parsed = JSON.parse(result.content[0]?.text ?? '{}') as unknown; diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index 34321ab..8bf1161 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -89,6 +89,7 @@ export interface CodingPluginAdapter { context: TrustedCodingCapabilityContext, tool: CodingPluginToolDefinition, input: unknown, + onProgress?: (result: AdapterInvocationResult) => void, ): Promise; deactivate?(projectPath: string): Promise; } @@ -124,6 +125,7 @@ export interface CodingCapabilityRegistryPort { workerRole: 'parent' | 'child'; effectiveSkillIds: readonly string[]; value: unknown; + onUpdate?: (result: PiProductToolResult) => void; }): Promise; } @@ -530,6 +532,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor workerRole: 'parent' | 'child'; effectiveSkillIds: readonly string[]; value: unknown; + onUpdate?: (result: PiProductToolResult) => void; }): Promise { let indexed = this.toolsByName.get(input.toolName); const validId = requestId(input.context) !== 'invalid-request-id'; @@ -638,7 +641,30 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor }; let result: AdapterInvocationResult; try { - result = await adapter.invoke(trustedContext, tool, input.value); + const reportProgress = input.onUpdate ? (progress: AdapterInvocationResult) => { + let progressBilling: CapabilityBillingReceiptV1; + if (catalogPolicy.billing.mode === 'platform_metered') { + if (!progress.billing || !validBillingReceipt(progress.billing) + || progress.billing.mode !== 'platform_metered') return; + progressBilling = progress.billing; + } else { + progressBilling = policyEntered(catalogPolicy.billing.mode); + } + try { + input.onUpdate?.(buildCapabilityToolResult( + definition, + tool, + input.context, + progress, + progressBilling, + )); + } catch { + // Progress is observational and must not change the capability outcome. + } + } : undefined; + result = reportProgress + ? await adapter.invoke(trustedContext, tool, input.value, reportProgress) + : await adapter.invoke(trustedContext, tool, input.value); } catch { return this.resultFailure(definition, tool, input.context, 'plugin_backend_unavailable', 'Plugin adapter is temporarily unavailable', 503, true, baseBilling); } diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index 6133ef1..22059ae 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -93,7 +93,7 @@ interface SubagentBridgeRequest { } interface ProductToolBridgeRequest { - action: 'product.invoke'; + action: 'product.invoke' | 'product.invoke.stream'; conversationId: string; workerGeneration: number; runId: string; @@ -142,7 +142,7 @@ function bridgeRequest(value: unknown): value is BridgeRequest { && typeof value.resourceId === 'string'; if (!common) return false; if (value.action === 'subagent.dispatch') return 'request' in value; - if (value.action === 'product.invoke') { + if (value.action === 'product.invoke' || value.action === 'product.invoke.stream') { return typeof value.toolName === 'string' && PRODUCT_TOOL_NAME_PATTERN.test(value.toolName) && 'input' in value; @@ -417,7 +417,7 @@ export class PiManagedExtensionHost { this.respond(response, 200, { recorded: true }); return; } - if (value.action === 'product.invoke') { + if (value.action === 'product.invoke' || value.action === 'product.invoke.stream') { if (record.role !== 'parent') { this.respond(response, 403, { error: 'Child workers cannot invoke parent product tools' }); return; @@ -431,7 +431,7 @@ export class PiManagedExtensionHost { this.respond(response, 503, { error: 'Product tools are unavailable' }); return; } - const productResult = await this.productTools.execute(value.toolName, { + const context = { conversationId: record.conversationId, workerGeneration: record.generation, runId: value.runId, @@ -440,7 +440,38 @@ export class PiManagedExtensionHost { projectPath: record.projectPath, skillIds: record.skillIds, ...(record.effectiveSnapshot ? { effectiveSnapshot: record.effectiveSnapshot } : {}), - }, value.input); + }; + if (value.action === 'product.invoke.stream') { + response.writeHead(200, { + 'content-type': 'application/x-ndjson; charset=utf-8', + ...(this.closing ? { connection: 'close' } : {}), + }); + try { + const productResult = await this.productTools.execute( + value.toolName, + context, + value.input, + (result) => { + if (!response.writableEnded && !response.destroyed) { + response.write(`${JSON.stringify({ result })}\n`); + } + }, + ); + if (!response.writableEnded && !response.destroyed) { + response.end(`${JSON.stringify({ result: productResult, done: true })}\n`); + } + } catch { + if (!response.writableEnded && !response.destroyed) { + response.end(`${JSON.stringify({ error: 'Product tool invocation failed' })}\n`); + } + } + return; + } + const productResult = await this.productTools.execute( + value.toolName, + context, + value.input, + ); this.respond(response, 200, { result: productResult }); return; } diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index d8e6d65..9330c42 100644 --- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts +++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { atomicWriteText } from '../../../coding-projects/atomic-json'; -export const MAKELORE_PI_EXTENSION_VERSION = 5; +export const MAKELORE_PI_EXTENSION_VERSION = 6; export const MAKELORE_PI_EXTENSION_FILENAME = `makelore-runtime-v${MAKELORE_PI_EXTENSION_VERSION}.mjs`; const BUNDLE_SOURCE = String.raw` @@ -180,15 +180,78 @@ export function createMakeloreRuntime(runtimeDefaults = {}) { return response.result; } - function registerProductTool(name, label, description, parameters, projectWriteLease = false) { + async function invokeProductStream(toolCallId, toolName, input, signal, onUpdate) { + const context = await runtimeContext(); + const bridgeUrl = runtimeValue(RUNTIME_FLAGS.bridgeUrl); + const workerToken = runtimeValue(RUNTIME_FLAGS.workerToken); + if (!bridgeUrl || !workerToken) throw new Error('Makelore runtime bridge is unavailable'); + const response = await fetch(bridgeUrl, { + method: 'POST', + headers: { + authorization: 'Bearer ' + workerToken, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + ...context, + action: 'product.invoke.stream', + resourceId: toolCallId, + toolName, + input, + }), + signal, + }); + if (!response.ok) { + const result = await response.json().catch(() => ({})); + throw new Error(result.error || 'Makelore runtime bridge rejected the request'); + } + if (!response.body) throw new Error('Makelore product stream returned no body'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ''; + let finalResult; + let completed = false; + while (true) { + const { value, done } = await reader.read(); + buffered += decoder.decode(value || new Uint8Array(), { stream: !done }); + let newline = buffered.indexOf('\n'); + while (newline >= 0) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (line) { + const item = JSON.parse(line); + if (typeof item.error === 'string') throw new Error(item.error); + if (item.result) { + finalResult = item.result; + if (item.done) completed = true; + else onUpdate?.(item.result); + } + } + newline = buffered.indexOf('\n'); + } + if (done) break; + } + if (!completed || !finalResult) throw new Error('Makelore product stream ended before completion'); + return finalResult; + } + + function registerProductTool( + name, + label, + description, + parameters, + projectWriteLease = false, + streamsProgress = false, + ) { pi.registerTool({ name, label, description, parameters, ...(projectWriteLease ? { executionMode: 'sequential' } : {}), - async execute(toolCallId, params, signal) { - return await invokeProduct(toolCallId, name, params, signal); + async execute(toolCallId, params, signal, onUpdate) { + return streamsProgress + ? await invokeProductStream(toolCallId, name, params, signal, onUpdate) + : await invokeProduct(toolCallId, name, params, signal); }, }); } @@ -229,6 +292,7 @@ export function createMakeloreRuntime(runtimeDefaults = {}) { declaration.description, declaration.inputSchema, dynamicLeaseTools.has(declaration.name), + declaration.executionMode === 'job', ); } } diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index 36970ac..37997cf 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -148,6 +148,7 @@ export class PiProductTools { toolName: PiProductToolName | string, context: PiProductToolContext, input: unknown, + onUpdate?: (result: PiProductToolResult) => void, ): Promise { if (DEVICE_PACKAGE_TOOL_NAMES.includes(toolName as typeof DEVICE_PACKAGE_TOOL_NAMES[number])) { if (!this.options.devicePackageTools) throw new Error('Device package management is unavailable'); @@ -176,6 +177,7 @@ export class PiProductTools { workerRole: 'parent', effectiveSkillIds: context.skillIds, value: input, + ...(onUpdate ? { onUpdate } : {}), }); } if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable'); diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index cfbbc15..0784662 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -1416,7 +1416,7 @@ export async function runFinalAsarExtensionProof(): Promise([ + 'reserved', + 'accepted', + 'running', +]); + +type HostedBillingReceipt = Extract; +type DeliveryStatus = 'not_started' | 'waiting' | 'saving' | 'saved' | 'delivery_failed'; + +export interface GameResourceDeliveryContext { + readonly conversationId: string; + readonly runId: string; + readonly localProjectId: string; + readonly durableProjectId: string; + readonly projectPath: string; + readonly logicalOperationId: string; +} + +export interface GameResourceDeliveredFile { + readonly path: string; + readonly bytes: number; +} + +export interface GameResourceDeliveryResult { + readonly executionId: string; + readonly providerStatus: GameResourceGeneration['status']; + readonly deliveryStatus: DeliveryStatus; + readonly outputCount: number; + readonly files: readonly GameResourceDeliveredFile[]; + readonly billing: HostedBillingReceipt; + readonly errorCode?: string; +} + +export interface GameResourceDeliveryProgress extends GameResourceDeliveryResult { + readonly phase: 'submitted' | 'generating' | 'saving' | 'saved'; +} + +interface DeliveryReceipt { + readonly logicalOperationId: string; + readonly executionId: string; + readonly conversationId: string; + readonly runId: string; + readonly localProjectId: string; + readonly durableProjectId: string; + readonly projectPath: string; + readonly providerStatus: GameResourceGeneration['status']; + readonly outputCount: number; + readonly pollIntervalSeconds: number; + readonly billing: HostedBillingReceipt; + readonly deliveryStatus: DeliveryStatus; + readonly files: readonly GameResourceDeliveredFile[]; + readonly errorCode?: string; + readonly updatedAt: string; +} + +interface ReceiptFile { + readonly schemaVersion: 1; + readonly receipts: readonly DeliveryReceipt[]; +} + +type WriteOutput = (filePath: string, bytes: Uint8Array) => Promise; +type RecordTouchedPaths = ( + conversationId: string, + runId: string, + paths: readonly string[], +) => Promise; +type Sleep = (milliseconds: number, signal?: AbortSignal) => Promise; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isReceipt(value: unknown): value is DeliveryReceipt { + if (!isRecord(value)) return false; + return typeof value.logicalOperationId === 'string' + && typeof value.executionId === 'string' + && typeof value.conversationId === 'string' + && typeof value.runId === 'string' + && typeof value.localProjectId === 'string' + && typeof value.durableProjectId === 'string' + && typeof value.projectPath === 'string' + && typeof value.providerStatus === 'string' + && Number.isSafeInteger(value.outputCount) + && Number.isSafeInteger(value.pollIntervalSeconds) + && isRecord(value.billing) + && typeof value.deliveryStatus === 'string' + && Array.isArray(value.files) + && typeof value.updatedAt === 'string'; +} + +function resultFrom(receipt: DeliveryReceipt): GameResourceDeliveryResult { + return { + executionId: receipt.executionId, + providerStatus: receipt.providerStatus, + deliveryStatus: receipt.deliveryStatus, + outputCount: receipt.outputCount, + files: receipt.files, + billing: receipt.billing, + ...(receipt.errorCode ? { errorCode: receipt.errorCode } : {}), + }; +} + +function extensionFor(fileName: string | null, contentType: string): string { + const named = fileName ? path.extname(fileName).toLowerCase() : ''; + if (['.png', '.webp', '.jpg', '.jpeg', '.gif'].includes(named)) return named; + const byType: Readonly> = { + 'image/png': '.png', + 'image/webp': '.webp', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + }; + return byType[contentType.split(';', 1)[0]?.trim().toLowerCase() ?? ''] ?? '.bin'; +} + +async function defaultWriteOutput(filePath: string, bytes: Uint8Array): Promise { + await writeFile(filePath, bytes, { flag: 'wx' }); +} + +async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise { + await new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Game Resource delivery interrupted')); + return; + } + const finish = () => { + signal?.removeEventListener('abort', abort); + resolve(); + }; + const timeout = setTimeout(finish, milliseconds); + const abort = () => { + clearTimeout(timeout); + signal?.removeEventListener('abort', abort); + reject(new Error('Game Resource delivery interrupted')); + }; + signal?.addEventListener('abort', abort, { once: true }); + }); +} + +export class GameResourceDeliveryReceiptStore { + private writeQueue: Promise = Promise.resolve(); + + constructor(private readonly filePath: string) {} + + async get(logicalOperationId: string): Promise { + return (await this.read()).receipts.find( + (receipt) => receipt.logicalOperationId === logicalOperationId, + ) ?? null; + } + + async list(): Promise { + return (await this.read()).receipts; + } + + async put(receipt: DeliveryReceipt): Promise { + const operation = this.writeQueue.then(async () => { + const current = await this.read(); + const remaining = current.receipts.filter( + (item) => item.logicalOperationId !== receipt.logicalOperationId, + ); + const receipts = [...remaining, receipt] + .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)) + .slice(-MAX_RECEIPTS); + await atomicWriteJson(this.filePath, { + schemaVersion: RECEIPT_SCHEMA_VERSION, + receipts, + } satisfies ReceiptFile); + }); + this.writeQueue = operation.catch(() => undefined); + await operation; + } + + private async read(): Promise { + let value: unknown; + try { + value = await readJsonFile(this.filePath); + } catch (error) { + if (isRecord(error) && error.code === 'ENOENT') { + return { schemaVersion: RECEIPT_SCHEMA_VERSION, receipts: [] }; + } + throw error; + } + if (!isRecord(value) || value.schemaVersion !== RECEIPT_SCHEMA_VERSION + || !Array.isArray(value.receipts) || !value.receipts.every(isReceipt)) { + throw new Error('Game Resource delivery receipt file is invalid'); + } + return value as unknown as ReceiptFile; + } +} + +export interface GameResourceDeliveryCoordinatorOptions { + readonly client: Pick; + readonly receipts: GameResourceDeliveryReceiptStore; + readonly leases: PiProjectWriteLeaseCoordinator; + readonly sleep?: Sleep; + readonly writeOutput?: WriteOutput; + readonly recordTouchedPaths?: RecordTouchedPaths; + readonly acquireBackgroundLease?: (lease: { + id: string; + kind: 'coding-run'; + }) => () => void; +} + +export class GameResourceDeliveryCoordinator { + private readonly flights = new Map>(); + private readonly sleep: Sleep; + private readonly writeOutput: WriteOutput; + private readonly shutdown = new AbortController(); + + constructor(private readonly options: GameResourceDeliveryCoordinatorOptions) { + this.sleep = options.sleep ?? defaultSleep; + this.writeOutput = options.writeOutput ?? defaultWriteOutput; + } + + async generateAndMaterialize(input: { + readonly context: GameResourceDeliveryContext; + readonly request: GameResourceGenerateInput; + readonly onProgress?: (progress: GameResourceDeliveryProgress) => void; + }): Promise { + const operationId = input.context.logicalOperationId; + const existingFlight = this.flights.get(operationId); + if (existingFlight) return await existingFlight; + + const releaseBackground = this.options.acquireBackgroundLease?.({ + id: `game-resource:${operationId}`, + kind: 'coding-run', + }) ?? (() => undefined); + const flight = this.run(input).finally(releaseBackground); + this.flights.set(operationId, flight); + try { + return await flight; + } finally { + if (this.flights.get(operationId) === flight) this.flights.delete(operationId); + } + } + + async resumePending(): Promise { + const receipts = await this.options.receipts.list(); + const pending = receipts.filter((receipt) => receipt.deliveryStatus !== 'saved' + && (receipt.providerStatus === 'succeeded' + || ACTIVE_PROVIDER_STATUSES.has(receipt.providerStatus))); + const results: GameResourceDeliveryResult[] = []; + for (const receipt of pending) { + const existingFlight = this.flights.get(receipt.logicalOperationId); + if (existingFlight) { + try { + results.push(await existingFlight); + } catch { + // One unavailable reconciliation must not block other persisted deliveries. + } + continue; + } + const releaseBackground = this.options.acquireBackgroundLease?.({ + id: `game-resource:${receipt.logicalOperationId}`, + kind: 'coding-run', + }) ?? (() => undefined); + const flight = this.continueReceipt(receipt).finally(releaseBackground); + this.flights.set(receipt.logicalOperationId, flight); + try { + results.push(await flight); + } catch { + // Keep the receipt for a later retry and continue with independent executions. + } finally { + if (this.flights.get(receipt.logicalOperationId) === flight) { + this.flights.delete(receipt.logicalOperationId); + } + } + } + return results; + } + + dispose(): void { + this.shutdown.abort(); + } + + private async run(input: { + readonly context: GameResourceDeliveryContext; + readonly request: GameResourceGenerateInput; + readonly onProgress?: (progress: GameResourceDeliveryProgress) => void; + }): Promise { + const persisted = await this.options.receipts.get(input.context.logicalOperationId); + if (persisted) return await this.continueReceipt(persisted, input.onProgress); + + const generated = await this.options.client.generate(input.request); + let receipt = this.receiptFromGeneration(input.context, generated); + await this.options.receipts.put(receipt); + this.report(input.onProgress, 'submitted', receipt); + return await this.continueReceipt(receipt, input.onProgress); + } + + private async continueReceipt( + initial: DeliveryReceipt, + onProgress?: (progress: GameResourceDeliveryProgress) => void, + ): Promise { + let receipt = initial; + if (receipt.deliveryStatus === 'saved') return resultFrom(receipt); + + while (ACTIVE_PROVIDER_STATUSES.has(receipt.providerStatus)) { + this.report(onProgress, 'generating', receipt); + await this.sleep(receipt.pollIntervalSeconds * 1_000, this.shutdown.signal); + const generation = await this.options.client.get(receipt.executionId); + receipt = { + ...receipt, + providerStatus: generation.status, + outputCount: generation.outputCount, + pollIntervalSeconds: generation.pollIntervalSeconds ?? receipt.pollIntervalSeconds, + billing: generation.billing, + ...(generation.errorCode ? { errorCode: generation.errorCode } : {}), + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(receipt); + } + + if (receipt.providerStatus !== 'succeeded') { + const terminal = { + ...receipt, + deliveryStatus: 'not_started' as const, + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(terminal); + return resultFrom(terminal); + } + return await this.materializeWithRetry(receipt, onProgress); + } + + private async materializeWithRetry( + receipt: DeliveryReceipt, + onProgress?: (progress: GameResourceDeliveryProgress) => void, + ): Promise { + let result = await this.materialize(receipt, onProgress); + for (let attempt = 1; + attempt < MAX_LOCAL_DELIVERY_ATTEMPTS && result.deliveryStatus === 'delivery_failed'; + attempt += 1) { + await this.sleep(LOCAL_DELIVERY_RETRY_DELAY_MS, this.shutdown.signal); + const persisted = await this.options.receipts.get(receipt.logicalOperationId); + result = await this.materialize(persisted ?? receipt, onProgress); + } + return result; + } + + private async materialize( + receipt: DeliveryReceipt, + onProgress?: (progress: GameResourceDeliveryProgress) => void, + ): Promise { + const saving: DeliveryReceipt = { + ...receipt, + deliveryStatus: 'saving', + files: [], + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(saving); + this.report(onProgress, 'saving', saving); + + let downloads: Awaited>[]; + try { + downloads = await Promise.all(Array.from( + { length: receipt.outputCount }, + async (_, index) => await this.options.client.download(receipt.executionId, index), + )); + } catch { + return await this.failDelivery(receipt); + } + const files = downloads.map((download, index) => ({ + path: `assets/generated/game-resource/${receipt.executionId}/output-${index + 1}${extensionFor(download.fileName, download.contentType)}`, + bytes: download.bytes.byteLength, + content: download.bytes, + })); + + const lease = await this.options.leases.acquire( + receipt.localProjectId, + `game-resource:${receipt.logicalOperationId}`, + ); + try { + for (const file of files) { + const target = path.join(receipt.projectPath, ...file.path.split('/')); + await mkdir(path.dirname(target), { recursive: true }); + try { + await this.writeOutput(target, file.content); + } catch (error) { + if (!isRecord(error) || error.code !== 'EEXIST') throw error; + const existing = await readFile(target); + if (!existing.equals(Buffer.from(file.content))) throw error; + } + } + const saved: DeliveryReceipt = { + ...receipt, + deliveryStatus: 'saved', + files: files.map(({ path: filePath, bytes }) => ({ path: filePath, bytes })), + errorCode: undefined, + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(saved); + try { + await this.options.recordTouchedPaths?.( + receipt.conversationId, + receipt.runId, + saved.files.map((file) => file.path), + ); + } catch { + // The original run may no longer exist after an app restart; saved files remain authoritative. + } + this.report(onProgress, 'saved', saved); + return resultFrom(saved); + } catch { + return await this.failDelivery(receipt); + } finally { + lease.release(); + } + } + + private async failDelivery(receipt: DeliveryReceipt): Promise { + const failed: DeliveryReceipt = { + ...receipt, + deliveryStatus: 'delivery_failed', + files: [], + errorCode: 'game_resource_delivery_failed', + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(failed); + return resultFrom(failed); + } + + private receiptFromGeneration( + context: GameResourceDeliveryContext, + generation: GameResourceGeneration, + ): DeliveryReceipt { + return { + logicalOperationId: context.logicalOperationId, + executionId: generation.executionId, + conversationId: context.conversationId, + runId: context.runId, + localProjectId: context.localProjectId, + durableProjectId: context.durableProjectId, + projectPath: context.projectPath, + providerStatus: generation.status, + outputCount: generation.outputCount, + pollIntervalSeconds: generation.pollIntervalSeconds ?? 3, + billing: generation.billing, + deliveryStatus: generation.status === 'succeeded' ? 'waiting' : 'not_started', + files: [], + ...(generation.errorCode ? { errorCode: generation.errorCode } : {}), + updatedAt: new Date().toISOString(), + }; + } + + private report( + callback: ((progress: GameResourceDeliveryProgress) => void) | undefined, + phase: GameResourceDeliveryProgress['phase'], + receipt: DeliveryReceipt, + ): void { + try { + callback?.({ phase, ...resultFrom(receipt) }); + } catch { + // Progress is observational and must not alter provider or delivery state. + } + } +} diff --git a/resources/coding-plugins/game-resource/com.makelore/capability.json b/resources/coding-plugins/game-resource/com.makelore/capability.json index c438a90..e07a0b6 100644 --- a/resources/coding-plugins/game-resource/com.makelore/capability.json +++ b/resources/coding-plugins/game-resource/com.makelore/capability.json @@ -54,7 +54,7 @@ { "name": "game_resource_generate", "label": "Generate game resource", - "description": "Submit one hosted pixel-art or HD game-resource generation job.", + "description": "Generate one hosted pixel-art or HD game resource and automatically save every output into the current project.", "capabilityId": "game-resource.generate", "operation": "generate", "roles": ["parent"], @@ -89,93 +89,30 @@ "outputSchema": { "type": "object", "additionalProperties": false, - "required": ["executionId", "status", "outputCount"], + "required": ["executionId", "providerStatus", "deliveryStatus", "outputCount", "files"], "properties": { "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, - "status": {"type": "string", "maxLength": 32}, + "providerStatus": {"type": "string", "minLength": 1, "maxLength": 32}, + "deliveryStatus": {"type": "string", "minLength": 1, "maxLength": 32}, "outputCount": {"type": "integer", "minimum": 0, "maximum": 100}, - "pollIntervalSeconds": {"type": "integer", "minimum": 1, "maximum": 300}, - "errorCode": {"type": "string", "minLength": 1, "maxLength": 128} - } - } - }, - { - "name": "game_resource_status", - "label": "Game resource status", - "description": "Read and refresh one hosted game-resource generation job.", - "capabilityId": "game-resource.generate", - "operation": "status", - "roles": ["parent"], - "mutation": "read", - "projectWriteLease": false, - "permissions": ["hosted.game-resource.status"], - "executionMode": "synchronous", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["executionId"], - "properties": { - "executionId": {"type": "string", "minLength": 1, "maxLength": 36} - } - }, - "outputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["executionId", "status", "outputCount", "generationBilling"], - "properties": { - "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, - "status": {"type": "string", "minLength": 1, "maxLength": 32}, - "outputCount": {"type": "integer", "minimum": 0, "maximum": 100}, - "pollIntervalSeconds": {"type": "integer", "minimum": 1, "maximum": 300}, + "phase": {"type": "string", "minLength": 1, "maxLength": 32}, "errorCode": {"type": "string", "minLength": 1, "maxLength": 128}, - "generationBilling": { - "type": "object", - "additionalProperties": false, - "required": ["mode", "status", "reserved_points", "usage_amount", "unit"], - "properties": { - "mode": {"type": "string", "minLength": 1, "maxLength": 32}, - "status": {"type": "string", "maxLength": 32}, - "reserved_points": {"type": "string", "maxLength": 32}, - "actual_points": {"type": "string", "maxLength": 32}, - "usage_amount": {"type": "integer", "minimum": 0, "maximum": 1}, - "unit": {"type": "string", "minLength": 1, "maxLength": 32} + "files": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "bytes"], + "properties": { + "path": {"type": "string", "minLength": 1, "maxLength": 1024}, + "bytes": {"type": "integer", "minimum": 1, "maximum": 33554432} + } } } } } }, - { - "name": "game_resource_save_output", - "label": "Save generated game resource", - "description": "Save one completed hosted output into a new file inside the current project.", - "capabilityId": "game-resource.library", - "operation": "save_output", - "roles": ["parent"], - "mutation": "write", - "projectWriteLease": true, - "permissions": ["hosted.game-resource.save-output"], - "executionMode": "synchronous", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["executionId", "relativePath", "confirmed"], - "properties": { - "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, - "outputIndex": {"type": "integer", "minimum": 0, "maximum": 99}, - "relativePath": {"type": "string", "minLength": 1, "maxLength": 1024}, - "confirmed": {"type": "boolean"} - } - }, - "outputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["savedPath", "bytes"], - "properties": { - "savedPath": {"type": "string", "minLength": 1, "maxLength": 1024}, - "bytes": {"type": "integer", "minimum": 1, "maximum": 33554432} - } - } - }, { "name": "game_resource_cancel", "label": "Cancel game resource", diff --git a/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md b/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md index 767ed63..09b0e17 100644 --- a/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md +++ b/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md @@ -12,15 +12,18 @@ Meowa 凭据、Provider URL、Provider job ID,也不要直接访问第三方 1. 先确认资源用途、像素或高清类型、画面要求、比例和实际需要的参考图;用 `game_resource_templates` 获取当前可用模板,不要猜模板名。 -2. 明确告诉用户生成按 Token Point 用量计费,并等待用户确认本次具体生成请求。 -3. 确认后只调用一次 `game_resource_generate`。结果不确定、超时或 - `pending_review` 时不得换 logical operation 重试;报告状态并使用原 - `executionId` 查询。 -4. 用 `game_resource_status` 查询同一个 execution,直到 succeeded、failed、 - cancelled 或 pending_review。轮询间隔遵守返回的 `pollIntervalSeconds`。 -5. 只有用户明确要求取消时才调用 `game_resource_cancel`。 -6. succeeded 后,只有用户明确给出项目内目标路径并确认保存时,才调用 - `game_resource_save_output`。该工具只创建新文件,不覆盖现有文件。 +2. 明确告诉用户生成按 Token Point 用量计费;本次确认同时授权生成完成后自动 + 保存全部输出到当前项目,不再另行询问路径或保存确认。 +3. 确认后只调用一次 `game_resource_generate`。该工具会在 MakeLore 内部提交、 + 轮询、下载并把全部输出自动保存到当前项目。不要自行轮询,也不要为同一请求 + 再调用生成工具。 +4. 只有用户明确要求取消,且已有 execution ID 时,才调用 + `game_resource_cancel`。 +5. `deliveryStatus=saved` 时,向用户报告工具返回的项目相对路径。 + `deliveryStatus=delivery_failed` 时,说明 Provider 可能已成功且计费可能已完成, + MakeLore 会从本地收据继续交付;不得重新生成或要求用户再次确认。 +6. `pending_review` 或 `submission_unknown` 时只报告现状,不得换 logical operation + 重试,也不得声称已经保存。 ## 浏览与审查 @@ -30,6 +33,7 @@ Provider 地址或通过生成工具上传未获用户同意的文件。 ## 完成标准 -报告必须区分提交、Provider 状态和 Token Point receipt。只引用工具实际返回的 -execution、status、outputCount 与 billing;不要猜测余额、价格、Provider 成本或 -输出路径。没有 settled receipt 时不得声称计费已最终完成。 +报告必须区分 Provider 状态、自动交付状态和 Token Point receipt。只引用工具实际 +返回的 execution、providerStatus、deliveryStatus、files 与 billing;不要猜测余额、 +价格、Provider 成本或输出路径。没有 `deliveryStatus=saved` 时不得声称文件已经写入 +项目;没有 settled receipt 时不得声称计费已最终完成。 diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index e8db3cf..681ed14 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -104,6 +104,30 @@ function capabilityBillingLabel(billing: CapabilityToolDetails['billing']): stri } } +function gameResourceProgressLabel(details: CapabilityToolDetails): string | null { + if (details.plugin_id !== 'makelore.game-resource' || details.operation !== 'generate' + || !details.data || typeof details.data !== 'object' || Array.isArray(details.data)) return null; + const data = details.data as Record; + const phase = typeof data.phase === 'string' ? data.phase : null; + const deliveryStatus = typeof data.deliveryStatus === 'string' ? data.deliveryStatus : null; + const providerStatus = typeof data.providerStatus === 'string' ? data.providerStatus : null; + const outputCount = Number.isSafeInteger(data.outputCount) ? data.outputCount as number : 0; + if (phase === 'saved' || deliveryStatus === 'saved') { + return `游戏资源 · 已保存 ${outputCount} 个文件`; + } + if (phase === 'saving' || deliveryStatus === 'saving') return '游戏资源 · 正在保存到项目'; + if (deliveryStatus === 'delivery_failed') return '游戏资源 · 自动保存失败,等待重试'; + if (phase === 'submitted') return '游戏资源 · 已提交'; + if (phase === 'generating' || ['reserved', 'accepted', 'running'].includes(providerStatus ?? '')) { + return '游戏资源 · 正在生成'; + } + if (providerStatus === 'pending_review') return '游戏资源 · 账单待审核'; + if (providerStatus === 'submission_unknown') return '游戏资源 · 提交状态待确认'; + if (providerStatus === 'cancelled') return '游戏资源 · 已取消'; + if (providerStatus === 'failed') return '游戏资源 · 生成失败'; + return null; +} + type ProcessItem = | { kind: 'thinking'; id: string; block: ThinkingBlock } | { kind: 'assistant-commentary'; id: string; node: ConversationMessageNode; blocks: ConversationContentBlock[] } @@ -618,9 +642,12 @@ const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolD ); } if (details.schema === 'makelore-capability.v1') { + const gameResourceProgress = gameResourceProgressLabel(details); return (
-

插件能力 · {details.operation}

+

+ {gameResourceProgress ?? `插件能力 · ${details.operation}`} +

{details.success ? '成功' : details.error ?? '请求失败'} · HTTP {details.status}

@@ -856,6 +883,8 @@ function toolDetailsProgress(details: KnownToolDetails | undefined): string | nu return `已启用 ${selectedSkills.length} 项技能 · ${details.commands.length} 条命令`; } if (details.schema === 'makelore-capability.v1') { + const gameResourceProgress = gameResourceProgressLabel(details); + if (gameResourceProgress) return gameResourceProgress; return details.success ? `${details.operation} · 成功` : `${details.operation} · ${details.error ?? '请求失败'}`; @@ -879,6 +908,10 @@ function toolDetailsProgress(details: KnownToolDetails | undefined): string | nu } function toolProgressPreview(node: ConversationToolNode): string { + if (node.details?.schema === 'makelore-capability.v1') { + const gameResourceProgress = gameResourceProgressLabel(node.details); + if (gameResourceProgress) return gameResourceProgress; + } for (let index = 0; index < node.output.length; index += 1) { const block = node.output[index]; if (!block) continue; diff --git a/tests/unit/coding-capability-registry.test.ts b/tests/unit/coding-capability-registry.test.ts index 87cb191..a0602a8 100644 --- a/tests/unit/coding-capability-registry.test.ts +++ b/tests/unit/coding-capability-registry.test.ts @@ -5,7 +5,10 @@ import type { PluginPolicyClientState } from '../../electron/services/plugin-pol import { CodingCapabilityRegistryImpl, } from '../../electron/coding-plugins/registry'; -import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry'; +import type { + AdapterInvocationResult, + CodingPluginAdapter, +} from '../../electron/coding-plugins/registry'; import { createDataServicePluginAdapter } from '../../electron/coding-plugins/adapters/data-service'; import { createDataServiceOperations, @@ -277,14 +280,28 @@ describe('CodingCapabilityRegistry', () => { getPolicyState: vi.fn(() => hostedPolicy), getInstalledDefinition: vi.fn(async () => hostedDefinition), } as unknown as EffectivePluginResolver; - const invoke = vi.fn(async () => ({ - success: true as const, status: 202, code: null, error: null, retryable: false as const, - payload_schema: 'game-resource.v1', data: { executionId: 'execution-a', status: 'accepted' }, - billing: { - mode: 'platform_metered' as const, status: 'dispatched' as const, - reserved_points: '2.00', usage_amount: 1, unit: 'generation', - }, - })); + const dispatchedBilling = { + mode: 'platform_metered' as const, status: 'dispatched' as const, + reserved_points: '2.00', usage_amount: 1, unit: 'generation', + }; + const invoke = vi.fn(async ( + _context: unknown, + _tool: unknown, + _value: unknown, + onProgress?: (result: AdapterInvocationResult) => void, + ) => { + onProgress?.({ + success: true, status: 202, code: null, error: null, retryable: false, + payload_schema: 'game-resource.v1', + data: { phase: 'generating', executionId: 'execution-a' }, + billing: dispatchedBilling, + }); + return { + success: true as const, status: 202, code: null, error: null, retryable: false as const, + payload_schema: 'game-resource.v1', data: { executionId: 'execution-a', status: 'accepted' }, + billing: dispatchedBilling, + }; + }); const capabilityRegistry = registry({ definitions: [], effectiveResolver, adapters: [{ pluginId: hostedDefinition.id, inspect: async () => ({ status: 'ready' }), invoke }], @@ -292,10 +309,12 @@ describe('CodingCapabilityRegistry', () => { getEnabledPluginIds: async () => [hostedDefinition.id], }); + const onUpdate = vi.fn(); const result = await capabilityRegistry.invoke({ toolName: 'game_resource_generate', context: { ...context, skillIds: ['game-resource'], effectiveSnapshot: frozenSnapshot }, workerRole: 'parent', effectiveSkillIds: ['game-resource'], value: { kind: 'pixel' }, + onUpdate, }); expect(effectiveResolver.getInstalledDefinition).toHaveBeenCalledWith( @@ -305,7 +324,17 @@ describe('CodingCapabilityRegistry', () => { expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ requestId: 'pi:run-a:resource-a', workerRole: 'parent', effectiveSkillIds: ['game-resource'], pluginReleaseId: hostedDefinition.releaseId, - }), hostedDefinition.tools[0], { kind: 'pixel' }); + }), hostedDefinition.tools[0], { kind: 'pixel' }, expect.any(Function)); + expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ + details: expect.objectContaining({ + schema: 'makelore-capability.v1', + plugin_id: hostedDefinition.id, + operation: 'generate', + status: 202, + data: { phase: 'generating', executionId: 'execution-a' }, + billing: dispatchedBilling, + }), + })); expect(result.details).toMatchObject({ schema: 'makelore-capability.v1', plugin_id: hostedDefinition.id, capability_id: 'game-resource.generate', operation: 'generate', diff --git a/tests/unit/coding-conversation-timeline.test.tsx b/tests/unit/coding-conversation-timeline.test.tsx index 0a17e51..c37e723 100644 --- a/tests/unit/coding-conversation-timeline.test.tsx +++ b/tests/unit/coding-conversation-timeline.test.tsx @@ -12,6 +12,100 @@ vi.mock('@/lib/coding-attachments', () => ({ describe('CodingConversationTimeline', () => { afterEach(() => vi.unstubAllGlobals()); + it('shows one Game Resource progress card from generation through automatic project save', async () => { + const { codingConversationStore } = await import('@/stores/coding-conversations'); + const { CodingConversationTimeline } = await import( + '@/pages/Chat/CodingConversationTimeline' + ); + const base = createProductSnapshot('conversation-game-resource-progress', 1); + const details = (phase: 'saving' | 'saved') => ({ + schema: 'makelore-capability.v1' as const, + plugin_id: 'makelore.game-resource', + plugin_version: '1.0.0', + capability_id: 'game-resource.generate', + operation: 'generate', + request_id: 'pi:game-run:game-tool', + success: true, + status: phase === 'saved' ? 200 : 202, + code: null, + error: null, + retryable: false, + billing: phase === 'saved' ? { + mode: 'platform_metered' as const, + status: 'settled' as const, + reserved_points: '2.00', + actual_points: '2.00', + usage_amount: 1, + unit: 'generation', + } : { + mode: 'platform_metered' as const, + status: 'dispatched' as const, + reserved_points: '2.00', + usage_amount: 1, + unit: 'generation', + }, + payload_schema: 'game-resource.v1', + data: { + phase, + executionId: '11111111-1111-4111-8111-111111111111', + providerStatus: 'succeeded', + deliveryStatus: phase, + outputCount: 2, + files: phase === 'saved' ? [ + { path: 'assets/generated/game-resource/execution/output-1.png', bytes: 128 }, + { path: 'assets/generated/game-resource/execution/output-2.png', bytes: 256 }, + ] : [], + }, + }); + const snapshot = (seq: number, phase: 'saving' | 'saved') => { + const toolDetails = details(phase); + return { + ...base, + cursor: { ...base.cursor, seq }, + nodes: [{ + kind: 'tool' as const, + id: 'tool-game-resource-progress', + toolCallId: 'game-tool', + toolName: 'game_resource_generate', + title: '生成游戏资源', + inputText: '', + status: phase === 'saved' ? 'complete' as const : 'running' as const, + output: [{ + kind: 'text' as const, + id: `tool-output-game-resource-${phase}`, + text: JSON.stringify(toolDetails), + status: phase === 'saved' ? 'complete' as const : 'streaming' as const, + }], + details: toolDetails, + }], + }; + }; + codingConversationStore.getState().applySnapshotEvent({ + type: 'snapshot', + conversationId: 'conversation-game-resource-progress', + workerGeneration: 1, + seq: base.cursor.seq, + snapshot: snapshot(base.cursor.seq, 'saving'), + }); + + render(); + + expect(screen.getByTestId('tool-progress-preview')).toHaveTextContent( + '游戏资源 · 正在保存到项目', + ); + + const nextSeq = base.cursor.seq + 1; + act(() => codingConversationStore.getState().applySnapshotEvent({ + type: 'snapshot', + conversationId: 'conversation-game-resource-progress', + workerGeneration: 1, + seq: nextSeq, + snapshot: snapshot(nextSeq, 'saved'), + })); + await waitFor(() => expect(screen.getByTestId('tool-progress-preview')) + .toHaveTextContent('游戏资源 · 已保存 2 个文件')); + }); + it('resolves attachment refs into temporary object URLs without base64 state', async () => { const createObjectURL = vi.fn(() => 'blob:authenticated-preview'); const revokeObjectURL = vi.fn(); diff --git a/tests/unit/coding-plugin-manifest.test.ts b/tests/unit/coding-plugin-manifest.test.ts index 7ee6cb2..654ba0f 100644 --- a/tests/unit/coding-plugin-manifest.test.ts +++ b/tests/unit/coding-plugin-manifest.test.ts @@ -72,6 +72,20 @@ describe('bundled coding plugin manifests', () => { tools: [], }, ]); + expect(definitions[1]?.tools.map(({ name }) => name)).toEqual([ + 'game_resource_templates', + 'game_resource_generate', + 'game_resource_cancel', + 'game_asset_browser', + 'game_asset_review', + ]); + const gameResourceSkill = await readFile( + path.join(GAME_RESOURCE_ROOT, 'skills/game-resource/SKILL.md'), + 'utf8', + ); + expect(gameResourceSkill).toContain('保存全部输出到当前项目'); + expect(gameResourceSkill).not.toContain('game_resource_status'); + expect(gameResourceSkill).not.toContain('game_resource_save_output'); expect(startupDefinitions).toEqual(definitions); expect(Object.isFrozen(startupDefinitions)).toBe(true); expect(Object.isFrozen(startupDefinitions[0]?.operations)).toBe(true); diff --git a/tests/unit/game-resource-delivery.test.ts b/tests/unit/game-resource-delivery.test.ts new file mode 100644 index 0000000..fd6fe64 --- /dev/null +++ b/tests/unit/game-resource-delivery.test.ts @@ -0,0 +1,494 @@ +// @vitest-environment node + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease'; +import { + GameResourceDeliveryCoordinator, + GameResourceDeliveryReceiptStore, +} from '../../electron/services/game-resource-delivery'; +import type { + GameResourceClient, + GameResourceGeneration, +} from '../../electron/services/game-resource-client'; + +const EXECUTION_ID = '11111111-1111-4111-8111-111111111111'; +const RELEASE_ID = '22222222-2222-4222-8222-222222222222'; +const PROJECT_ID = '33333333-3333-4333-8333-333333333333'; +const roots: string[] = []; + +const dispatchedBilling = { + mode: 'platform_metered' as const, + status: 'dispatched' as const, + reserved_points: '2.00', + usage_amount: 1, + unit: 'generation', +}; + +const settledBilling = { + mode: 'platform_metered' as const, + status: 'settled' as const, + reserved_points: '2.00', + actual_points: '2.00', + usage_amount: 1, + unit: 'generation', +}; + +function generation( + status: GameResourceGeneration['status'], + overrides: Partial = {}, +): GameResourceGeneration { + return { + executionId: EXECUTION_ID, + releaseId: RELEASE_ID, + projectId: PROJECT_ID, + logicalOperationId: 'pi:run-a:resource-a', + kind: 'pixel', + templateName: 'character', + status, + outputCount: status === 'succeeded' ? 2 : 0, + pollIntervalSeconds: 3, + billing: status === 'succeeded' ? settledBilling : dispatchedBilling, + ...overrides, + }; +} + +async function fixture(overrides: { + generate?: () => Promise; + get?: () => Promise; + download?: (executionId: string, outputIndex?: number) => Promise<{ + bytes: Uint8Array; + fileName: string | null; + contentType: string; + }>; + writeOutput?: (filePath: string, bytes: Uint8Array) => Promise; + sleep?: (milliseconds: number, signal?: AbortSignal) => Promise; + acquireBackgroundLease?: () => () => void; +} = {}) { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-game-delivery-')); + const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-game-receipts-')); + roots.push(root, userDataDir); + const generate = vi.fn(overrides.generate ?? (async () => generation('accepted'))); + const get = vi.fn(overrides.get ?? (async () => generation('succeeded'))); + const download = vi.fn(overrides.download ?? (async (_executionId, index = 0) => ({ + bytes: new Uint8Array([index + 1, index + 2]), + fileName: index === 0 ? 'hero.png' : 'portrait.webp', + contentType: index === 0 ? 'image/png' : 'image/webp', + }))); + const client = { generate, get, download } as unknown as Pick< + GameResourceClient, + 'generate' | 'get' | 'download' + >; + const leases = new PiProjectWriteLeaseCoordinator(); + const touched = vi.fn(async () => undefined); + const progress = vi.fn(); + const receiptStore = new GameResourceDeliveryReceiptStore( + path.join(userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'), + ); + const coordinator = new GameResourceDeliveryCoordinator({ + client, + receipts: receiptStore, + leases, + sleep: overrides.sleep ?? (async () => undefined), + recordTouchedPaths: touched, + ...(overrides.writeOutput ? { writeOutput: overrides.writeOutput } : {}), + ...(overrides.acquireBackgroundLease + ? { acquireBackgroundLease: overrides.acquireBackgroundLease } + : {}), + }); + const context = { + conversationId: 'conversation-a', + runId: 'run-a', + localProjectId: 'local-project-a', + durableProjectId: PROJECT_ID, + projectPath: root, + logicalOperationId: 'pi:run-a:resource-a', + }; + const request = { + releaseAdmissionId: 'admission-a', + releaseId: RELEASE_ID, + projectId: PROJECT_ID, + logicalOperationId: context.logicalOperationId, + kind: 'pixel' as const, + templateName: 'character', + templateConfig: {}, + requirement: 'A blue-armored hero', + }; + return { + coordinator, receiptStore, context, request, root, userDataDir, + client, generate, get, download, leases, touched, progress, + }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true }))); +}); + +describe('GameResourceDeliveryCoordinator', () => { + it('submits once, hides polling, saves every output under the frozen project, and reports progress', async () => { + let activeProjectPath: string | undefined; + const states = [generation('running'), generation('succeeded')]; + let fixtureValue: Awaited>; + fixtureValue = await fixture({ + get: async () => { + expect(fixtureValue.leases.activeCount).toBe(0); + activeProjectPath = path.join(fixtureValue.root, '..', 'another-project'); + return states.shift() as GameResourceGeneration; + }, + download: async (_executionId, index = 0) => { + expect(fixtureValue.leases.activeCount).toBe(0); + expect(fixtureValue.progress.mock.calls.at(-1)?.[0].phase).toBe('saving'); + return { + bytes: new Uint8Array([index + 1, index + 2]), + fileName: index === 0 ? 'hero.png' : 'portrait.webp', + contentType: index === 0 ? 'image/png' : 'image/webp', + }; + }, + }); + const result = await fixtureValue.coordinator.generateAndMaterialize({ + context: fixtureValue.context, + request: fixtureValue.request, + onProgress: fixtureValue.progress, + }); + + expect(activeProjectPath).not.toBe(fixtureValue.root); + expect(fixtureValue.generate).toHaveBeenCalledTimes(1); + expect(fixtureValue.get).toHaveBeenCalledTimes(2); + expect(fixtureValue.download.mock.calls.map((call) => call[1])).toEqual([0, 1]); + expect(result).toMatchObject({ + executionId: EXECUTION_ID, + providerStatus: 'succeeded', + deliveryStatus: 'saved', + files: [ + { path: `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`, bytes: 2 }, + { path: `assets/generated/game-resource/${EXECUTION_ID}/output-2.webp`, bytes: 2 }, + ], + billing: settledBilling, + }); + await expect(readFile(path.join( + fixtureValue.root, + 'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-1.png', + ))).resolves.toEqual(Buffer.from([1, 2])); + await expect(readFile(path.join( + fixtureValue.root, + 'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-2.webp', + ))).resolves.toEqual(Buffer.from([2, 3])); + expect(fixtureValue.leases.activeCount).toBe(0); + expect(fixtureValue.touched).toHaveBeenCalledWith( + 'conversation-a', + 'run-a', + result.files.map(({ path: filePath }) => filePath), + ); + expect(fixtureValue.progress.mock.calls.map(([item]) => item.phase)).toEqual([ + 'submitted', 'generating', 'generating', 'saving', 'saved', + ]); + }); + + it('deduplicates concurrent replay and returns persisted saved paths without another submit or download', async () => { + let releaseGenerate: ((value: GameResourceGeneration) => void) | undefined; + const generated = new Promise((resolve) => { releaseGenerate = resolve; }); + const value = await fixture({ generate: async () => await generated }); + + const first = value.coordinator.generateAndMaterialize({ context: value.context, request: value.request }); + const second = value.coordinator.generateAndMaterialize({ context: value.context, request: value.request }); + releaseGenerate?.(generation('succeeded')); + const [left, right] = await Promise.all([first, second]); + + expect(left).toEqual(right); + expect(value.generate).toHaveBeenCalledTimes(1); + expect(value.download).toHaveBeenCalledTimes(2); + + const replay = await value.coordinator.generateAndMaterialize({ + context: value.context, + request: value.request, + }); + expect(replay).toEqual(left); + expect(value.generate).toHaveBeenCalledTimes(1); + expect(value.download).toHaveBeenCalledTimes(2); + }); + + it('keeps a background lifecycle lease through polling and delivery', async () => { + let held = false; + const release = vi.fn(() => { held = false; }); + const value = await fixture({ + acquireBackgroundLease: () => { + held = true; + return release; + }, + generate: async () => { + expect(held).toBe(true); + return generation('accepted'); + }, + get: async () => { + expect(held).toBe(true); + return generation('succeeded'); + }, + download: async (_executionId, index = 0) => { + expect(held).toBe(true); + return { + bytes: new Uint8Array([index + 1]), + fileName: 'hero.png', + contentType: 'image/png', + }; + }, + }); + + await expect(value.coordinator.generateAndMaterialize({ + context: value.context, + request: value.request, + })).resolves.toMatchObject({ deliveryStatus: 'saved' }); + expect(held).toBe(false); + expect(release).toHaveBeenCalledOnce(); + }); + + it('resumes an accepted persisted execution after the original waiter stops', async () => { + const value = await fixture({ + sleep: async () => { throw new Error('waiter stopped'); }, + }); + await expect(value.coordinator.generateAndMaterialize({ + context: value.context, + request: value.request, + })).rejects.toThrow('waiter stopped'); + expect(value.generate).toHaveBeenCalledTimes(1); + + const restarted = new GameResourceDeliveryCoordinator({ + client: value.client, + receipts: new GameResourceDeliveryReceiptStore( + path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'), + ), + leases: value.leases, + sleep: async () => undefined, + }); + await expect(restarted.resumePending()).resolves.toEqual([expect.objectContaining({ + executionId: EXECUTION_ID, + providerStatus: 'succeeded', + deliveryStatus: 'saved', + })]); + expect(value.generate).toHaveBeenCalledTimes(1); + }); + + it('persists provider success and resumes only local delivery after a write failure', async () => { + let failWrite = true; + const writeOutput = vi.fn(async (target: string, bytes: Uint8Array) => { + if (failWrite) throw Object.assign(new Error('disk unavailable'), { code: 'ENOSPC' }); + await import('node:fs/promises').then(async ({ mkdir, writeFile }) => { + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, bytes, { flag: 'wx' }); + }); + }); + const value = await fixture({ + generate: async () => generation('succeeded'), + writeOutput, + }); + + await expect(value.coordinator.generateAndMaterialize({ + context: value.context, + request: value.request, + })).resolves.toMatchObject({ + providerStatus: 'succeeded', + deliveryStatus: 'delivery_failed', + errorCode: 'game_resource_delivery_failed', + files: [], + }); + expect(value.generate).toHaveBeenCalledTimes(1); + + failWrite = false; + const restarted = new GameResourceDeliveryCoordinator({ + client: value.client, + receipts: new GameResourceDeliveryReceiptStore( + path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'), + ), + leases: value.leases, + sleep: async () => undefined, + writeOutput, + recordTouchedPaths: value.touched, + }); + const resumed = await restarted.resumePending(); + + expect(resumed).toEqual([expect.objectContaining({ + executionId: EXECUTION_ID, + providerStatus: 'succeeded', + deliveryStatus: 'saved', + })]); + expect(value.generate).toHaveBeenCalledTimes(1); + expect(value.get).not.toHaveBeenCalled(); + expect(value.download).toHaveBeenCalledTimes(8); + }); + + it('never overwrites an existing project file with different bytes', async () => { + const value = await fixture({ generate: async () => generation('succeeded') }); + const relativePath = `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`; + const target = path.join(value.root, ...relativePath.split('/')); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, Buffer.from([99, 98])); + + await expect(value.coordinator.generateAndMaterialize({ + context: value.context, + request: value.request, + })).resolves.toMatchObject({ + providerStatus: 'succeeded', + deliveryStatus: 'delivery_failed', + errorCode: 'game_resource_delivery_failed', + }); + await expect(readFile(target)).resolves.toEqual(Buffer.from([99, 98])); + expect(value.generate).toHaveBeenCalledTimes(1); + }); + + it('persists provider success and resumes only local delivery after a download failure', async () => { + let failDownload = true; + const value = await fixture({ + generate: async () => generation('succeeded'), + download: async (_executionId, index = 0) => { + if (failDownload) throw new Error('download unavailable'); + return { + bytes: new Uint8Array([index + 1, index + 2]), + fileName: index === 0 ? 'hero.png' : 'portrait.webp', + contentType: index === 0 ? 'image/png' : 'image/webp', + }; + }, + }); + + await expect(value.coordinator.generateAndMaterialize({ + context: value.context, + request: value.request, + })).resolves.toMatchObject({ + providerStatus: 'succeeded', + deliveryStatus: 'delivery_failed', + errorCode: 'game_resource_delivery_failed', + files: [], + }); + expect(value.generate).toHaveBeenCalledTimes(1); + + failDownload = false; + const restarted = new GameResourceDeliveryCoordinator({ + client: value.client, + receipts: new GameResourceDeliveryReceiptStore( + path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'), + ), + leases: value.leases, + sleep: async () => undefined, + recordTouchedPaths: value.touched, + }); + await expect(restarted.resumePending()).resolves.toEqual([expect.objectContaining({ + executionId: EXECUTION_ID, + providerStatus: 'succeeded', + deliveryStatus: 'saved', + })]); + expect(value.generate).toHaveBeenCalledTimes(1); + expect(value.get).not.toHaveBeenCalled(); + }); + + it('retries a transient local delivery failure without submitting another generation', async () => { + let downloadAttempts = 0; + const sleep = vi.fn(async () => undefined); + const value = await fixture({ + generate: async () => generation('succeeded'), + sleep, + download: async (_executionId, index = 0) => { + downloadAttempts += 1; + if (downloadAttempts === 1) throw new Error('temporary download failure'); + return { + bytes: new Uint8Array([index + 1, index + 2]), + fileName: index === 0 ? 'hero.png' : 'portrait.webp', + contentType: index === 0 ? 'image/png' : 'image/webp', + }; + }, + }); + + await expect(value.coordinator.generateAndMaterialize({ + context: value.context, + request: value.request, + })).resolves.toMatchObject({ + providerStatus: 'succeeded', + deliveryStatus: 'saved', + }); + expect(value.generate).toHaveBeenCalledTimes(1); + expect(value.get).not.toHaveBeenCalled(); + expect(sleep).toHaveBeenCalledOnce(); + }); + + it('continues resuming later receipts when an earlier reconciliation is unavailable', async () => { + let generationIndex = 0; + let sleepCalls = 0; + const value = await fixture({ + generate: async () => { + const first = generationIndex++ === 0; + return generation(first ? 'accepted' : 'succeeded', { + executionId: first + ? '11111111-1111-4111-8111-111111111111' + : '44444444-4444-4444-8444-444444444444', + }); + }, + download: async () => { throw new Error('delivery unavailable'); }, + sleep: async () => { + if (sleepCalls++ === 0) throw new Error('waiter stopped'); + }, + }); + const secondContext = { + ...value.context, + logicalOperationId: 'pi:run-a:resource-b', + }; + const secondRequest = { + ...value.request, + logicalOperationId: secondContext.logicalOperationId, + }; + await expect(value.coordinator.generateAndMaterialize({ + context: value.context, + request: value.request, + })).rejects.toThrow('waiter stopped'); + await value.coordinator.generateAndMaterialize({ + context: secondContext, + request: secondRequest, + }); + + const resumedClient = { + generate: vi.fn(async () => { throw new Error('must not regenerate'); }), + get: vi.fn(async () => { throw new Error('first reconciliation unavailable'); }), + download: vi.fn(async (executionId: string, index = 0) => { + if (executionId === EXECUTION_ID) throw new Error('first delivery remains unavailable'); + return { + bytes: new Uint8Array([index + 1]), + fileName: 'result.png', + contentType: 'image/png', + }; + }), + } as unknown as Pick; + const restarted = new GameResourceDeliveryCoordinator({ + client: resumedClient, + receipts: new GameResourceDeliveryReceiptStore( + path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'), + ), + leases: value.leases, + sleep: async () => undefined, + }); + + const results = await restarted.resumePending(); + + expect(results).toEqual([expect.objectContaining({ + executionId: '44444444-4444-4444-8444-444444444444', + deliveryStatus: 'saved', + })]); + expect(resumedClient.generate).not.toHaveBeenCalled(); + expect(resumedClient.get).toHaveBeenCalledOnce(); + }); + + it('does not hold the write lease or create files for a pending-review generation', async () => { + const value = await fixture({ + generate: async () => generation('pending_review', { outputCount: 0 }), + }); + + await expect(value.coordinator.generateAndMaterialize({ + context: value.context, + request: value.request, + })).resolves.toMatchObject({ + providerStatus: 'pending_review', + deliveryStatus: 'not_started', + files: [], + }); + expect(value.download).not.toHaveBeenCalled(); + expect(value.leases.activeCount).toBe(0); + expect(value.touched).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/game-resource-plugin-adapter.test.ts b/tests/unit/game-resource-plugin-adapter.test.ts index eeca4e6..619f11f 100644 --- a/tests/unit/game-resource-plugin-adapter.test.ts +++ b/tests/unit/game-resource-plugin-adapter.test.ts @@ -8,6 +8,11 @@ import type { CodingPluginToolDefinition } from '../../shared/coding-plugins'; import { GameResourcePluginAdapter, } from '../../electron/coding-plugins/adapters/game-resource'; +import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease'; +import { + GameResourceDeliveryCoordinator, + GameResourceDeliveryReceiptStore, +} from '../../electron/services/game-resource-delivery'; import type { GameResourceClient, GameResourceGeneration } from '../../electron/services/game-resource-client'; import type { MarketplacePackageClientPort, PluginPackageStore } from '../../electron/coding-plugins/package-store'; import type { TrustedCodingCapabilityContext } from '../../electron/coding-plugins/registry'; @@ -52,8 +57,8 @@ function tool( capabilityId, operation, roles: ['parent'], - mutation: name === 'game_resource_status' ? 'read' : 'write', - projectWriteLease: name === 'game_resource_save_output', + mutation: operation === 'templates' ? 'read' : 'write', + projectWriteLease: false, permissions: [`hosted.game-resource.${operation.replaceAll('_', '-')}`], inputSchema: { type: 'object' }, }; @@ -61,7 +66,8 @@ function tool( async function fixture() { const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-game-resource-')); - roots.push(projectPath); + const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-game-resource-receipts-')); + roots.push(projectPath, userDataDir); const resolve = vi.fn(async () => ({ resolveRequestId: 'pi:run-a:resource-a', resolveRequestDigest: 'a'.repeat(64), @@ -84,8 +90,15 @@ async function fixture() { bytes: new Uint8Array([1, 2, 3]), fileName: 'hero.png', contentType: 'image/png', })), }; + const delivery = new GameResourceDeliveryCoordinator({ + client: client as unknown as GameResourceClient, + receipts: new GameResourceDeliveryReceiptStore(path.join(userDataDir, 'receipts.json')), + leases: new PiProjectWriteLeaseCoordinator(), + sleep: async () => undefined, + }); const adapter = new GameResourcePluginAdapter({ client: client as unknown as GameResourceClient, + delivery, marketplace: { resolve } as unknown as MarketplacePackageClientPort, packageStore: { getInstalled: vi.fn(async () => ({ @@ -113,15 +126,16 @@ afterEach(async () => { }); describe('GameResourcePluginAdapter', () => { - it('resolves a fresh admission and keeps project reference bytes behind the hosted boundary', async () => { + it('resolves admission, hides polling, and automatically saves generated output in the project', async () => { const { adapter, client, context, projectPath, resolve } = await fixture(); await writeFile(path.join(projectPath, 'reference.png'), new Uint8Array([4, 5, 6])); + const onProgress = vi.fn(); const result = await adapter.invoke(context, tool('game_resource_generate'), { kind: 'pixel', templateName: 'character', requirement: 'Blue-armored hero', confirmed: true, referencePaths: ['reference.png'], - }); + }, onProgress); expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ resolveRequestId: 'pi:run-a:resource-a', channel: 'stable', @@ -133,9 +147,26 @@ describe('GameResourcePluginAdapter', () => { referenceFiles: [{ name: 'reference.png', mimeType: 'image/png', dataBase64: 'BAUG' }], })); expect(result).toMatchObject({ - success: true, status: 202, billing, - data: { executionId: EXECUTION_ID, status: 'accepted', outputCount: 0 }, + success: true, status: 200, billing, + data: { + executionId: EXECUTION_ID, + providerStatus: 'succeeded', + deliveryStatus: 'saved', + files: [{ + path: `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`, + bytes: 3, + }], + }, }); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.download).toHaveBeenCalledWith(EXECUTION_ID, 0); + await expect(readFile(path.join( + projectPath, + 'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-1.png', + ))).resolves.toEqual(Buffer.from([1, 2, 3])); + expect(onProgress.mock.calls.map(([progress]) => progress.data.phase)).toEqual([ + 'submitted', 'generating', 'saving', 'saved', + ]); expect(JSON.stringify(result)).not.toContain(projectPath); }); @@ -151,28 +182,6 @@ describe('GameResourcePluginAdapter', () => { expect(client.generate).not.toHaveBeenCalled(); }); - it('requires explicit confirmation and never overwrites a project file', async () => { - const { adapter, client, context, projectPath } = await fixture(); - const saveTool = tool('game_resource_save_output', 'game-resource.library', 'save_output'); - - await expect(adapter.invoke(context, saveTool, { - executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: false, - })).resolves.toMatchObject({ success: false, code: 'confirmation_required' }); - expect(client.download).not.toHaveBeenCalled(); - - await expect(adapter.invoke(context, saveTool, { - executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: true, - })).resolves.toMatchObject({ - success: true, data: { savedPath: 'assets/hero.png', bytes: 3 }, - }); - await expect(readFile(path.join(projectPath, 'assets/hero.png'))).resolves.toEqual(Buffer.from([1, 2, 3])); - - await expect(adapter.invoke(context, saveTool, { - executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: true, - })).resolves.toMatchObject({ success: false, code: 'game_resource_destination_exists' }); - await expect(readFile(path.join(projectPath, 'assets/hero.png'))).resolves.toEqual(Buffer.from([1, 2, 3])); - }); - it('lists server-owned templates through the same release admission', async () => { const { adapter, context, client } = await fixture(); await expect(adapter.invoke(context, tool('game_resource_templates'), { kind: 'pixel' })) diff --git a/tests/unit/pi-extension-bundle.test.ts b/tests/unit/pi-extension-bundle.test.ts index f50981f..a1718f0 100644 --- a/tests/unit/pi-extension-bundle.test.ts +++ b/tests/unit/pi-extension-bundle.test.ts @@ -320,6 +320,115 @@ describe('Makelore Pi extension bundle', () => { } }); + it('streams job-tool progress through the authenticated product bridge', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-stream-')); + roots.push(root); + const host = new PiManagedExtensionHost(); + hosts.push(host); + const details = (phase: string) => ({ + schema: 'makelore-capability.v1' as const, + plugin_id: 'makelore.game-resource', + plugin_version: '1.0.0', + capability_id: 'game-resource.generate', + operation: 'generate', + request_id: 'pi:stream-run:stream-tool', + success: true, + status: phase === 'saved' ? 200 : 202, + code: null, + error: null, + retryable: false, + billing: { + mode: 'platform_metered' as const, + status: phase === 'saved' ? 'settled' as const : 'dispatched' as const, + reserved_points: '2.00', + ...(phase === 'saved' ? { actual_points: '2.00' } : {}), + usage_amount: 1, + unit: 'generation', + }, + payload_schema: 'game-resource.v1', + data: { phase, executionId: 'execution-a' }, + }); + const invoke = vi.fn(async (input: { + onUpdate?: (result: { content: []; details: ReturnType }) => void; + }) => { + input.onUpdate?.({ content: [], details: details('generating') }); + input.onUpdate?.({ content: [], details: details('saving') }); + return { content: [], details: details('saved') }; + }); + host.configureProductTools(new PiProductTools({ + browser: {} as AgentBrowserModule, + attachments: new CodingAttachmentStore(path.join(root, 'attachments')), + bundledSkillsDir: path.resolve('resources/coding-skills'), + capabilityRegistry: { invoke } as unknown as CodingCapabilityRegistryImpl, + })); + const jobTool: CodingPluginToolDefinition = { + name: 'game_resource_generate', + label: 'Generate game resource', + description: 'Generate and save a game resource.', + capabilityId: 'game-resource.generate', + operation: 'generate', + roles: ['parent'], + mutation: 'write', + projectWriteLease: false, + permissions: ['hosted.game-resource.generate'], + executionMode: 'job', + inputSchema: { type: 'object', additionalProperties: false, properties: {} }, + }; + const registration = await host.registerWorker({ + conversationId: 'stream-conversation', generation: 1, projectId: 'stream-project', + projectPath: root, extensionsDir: root, tools: [jobTool], + }); + await host.bindRun('stream-conversation', 1, 'stream-run'); + const previous = { + bridge: process.env.MAKELORE_PI_BRIDGE_URL, + token: process.env.MAKELORE_PI_WORKER_TOKEN, + context: process.env.MAKELORE_PI_CONTEXT_FILE, + role: process.env.MAKELORE_PI_WORKER_ROLE, + }; + Object.assign(process.env, registration.env); + try { + const module = await import( + /* @vite-ignore */ `${pathToFileURL(registration.extensionPath).href}?stream=${Date.now()}` + ) as { + default(factory: { + registerTool(tool: ExtensionTool): void; + on(event: string, handler: ExtensionHandler): void; + }): void | Promise; + }; + const tools = new Map(); + await module.default({ + registerTool: (tool) => tools.set(tool.name, tool), + on: () => undefined, + }); + const updates: unknown[] = []; + const result = await tools.get('game_resource_generate')?.execute?.( + 'stream-tool', + {}, + new AbortController().signal, + (update: unknown) => updates.push(update), + ); + + expect(updates).toEqual([ + { content: [], details: details('generating') }, + { content: [], details: details('saving') }, + ]); + expect(result).toEqual({ content: [], details: details('saved') }); + expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ + toolName: 'game_resource_generate', + onUpdate: expect.any(Function), + })); + } finally { + for (const [key, value] of Object.entries(previous)) { + const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL' + : key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN' + : key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE' + : 'MAKELORE_PI_WORKER_ROLE'; + if (value === undefined) delete process.env[environmentKey]; + else process.env[environmentKey] = value; + } + } + }); + it('executes versioned product tools through the authenticated real bundle', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-bundle-')); roots.push(root); diff --git a/tests/unit/pi-managed-worker-opener.test.ts b/tests/unit/pi-managed-worker-opener.test.ts index 1b3e6f8..faea2ee 100644 --- a/tests/unit/pi-managed-worker-opener.test.ts +++ b/tests/unit/pi-managed-worker-opener.test.ts @@ -289,7 +289,7 @@ describe('managed Pi worker opener', () => { expect(argv).toContain('grilling'); expect(argv).toContain('--session-id'); expect(argv).toContain('--extension'); - expect(argv).toContain('makelore-runtime-v5.mjs'); + expect(argv).toContain('makelore-runtime-v6.mjs'); expect(options.additionalArgs?.filter((argument) => argument === '--extension')).toHaveLength(2); expect(options.additionalArgs).toEqual(expect.arrayContaining([ '--skill', deviceSkillPath, '--extension', deviceExtensionPath,