feat: integrate automatic game resource delivery

This commit is contained in:
2026-09-06 09:50:49 +08:00
parent f721966f3c
commit 4df4bc9624
20 changed files with 1667 additions and 191 deletions

View File

@@ -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<string, unknown>;
export interface GameResourcePluginAdapterOptions {
readonly client: GameResourceClient;
readonly delivery: GameResourceDeliveryCoordinator;
readonly marketplace: MarketplacePackageClientPort;
readonly packageStore: Pick<PluginPackageStore, 'getInstalled' | 'getInstalledRelease'>;
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<AdapterInvocationResult> {
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;

View File

@@ -89,6 +89,7 @@ export interface CodingPluginAdapter {
context: TrustedCodingCapabilityContext,
tool: CodingPluginToolDefinition,
input: unknown,
onProgress?: (result: AdapterInvocationResult) => void,
): Promise<AdapterInvocationResult>;
deactivate?(projectPath: string): Promise<void>;
}
@@ -124,6 +125,7 @@ export interface CodingCapabilityRegistryPort {
workerRole: 'parent' | 'child';
effectiveSkillIds: readonly string[];
value: unknown;
onUpdate?: (result: PiProductToolResult) => void;
}): Promise<PiProductToolResult>;
}
@@ -530,6 +532,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor
workerRole: 'parent' | 'child';
effectiveSkillIds: readonly string[];
value: unknown;
onUpdate?: (result: PiProductToolResult) => void;
}): Promise<PiProductToolResult> {
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);
}