feat: integrate automatic game resource delivery
This commit is contained in:
@@ -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<string, ReturnType<typeof createCodingConversationStore>>();
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ export class PiProductTools {
|
||||
toolName: PiProductToolName | string,
|
||||
context: PiProductToolContext,
|
||||
input: unknown,
|
||||
onUpdate?: (result: PiProductToolResult) => void,
|
||||
): Promise<PiProductToolResult> {
|
||||
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');
|
||||
|
||||
@@ -1416,7 +1416,7 @@ export async function runFinalAsarExtensionProof(): Promise<PiReleaseExtensionPr
|
||||
providerRequests: requestCounts,
|
||||
subagentStatus: 'complete',
|
||||
subagentSummary: 'REAL_CHILD_COMPLETE',
|
||||
materializedExtension: 'makelore-runtime-v5.mjs',
|
||||
materializedExtension: 'makelore-runtime-v6.mjs',
|
||||
providerFirstEventDelayMs: PROOF_PROVIDER_FIRST_EVENT_DELAY_MS,
|
||||
managedTurns,
|
||||
managedWorkerMilestones: composition.telemetry,
|
||||
|
||||
471
electron/services/game-resource-delivery.ts
Normal file
471
electron/services/game-resource-delivery.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CapabilityBillingReceiptV1 } from '../../shared/data-service';
|
||||
import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json';
|
||||
import type { PiProjectWriteLeaseCoordinator } from '../coding-runtime/pi/write-lease';
|
||||
import type {
|
||||
GameResourceClient,
|
||||
GameResourceGenerateInput,
|
||||
GameResourceGeneration,
|
||||
} from './game-resource-client';
|
||||
|
||||
const RECEIPT_SCHEMA_VERSION = 1;
|
||||
const MAX_RECEIPTS = 1_000;
|
||||
const MAX_LOCAL_DELIVERY_ATTEMPTS = 3;
|
||||
const LOCAL_DELIVERY_RETRY_DELAY_MS = 1_000;
|
||||
const ACTIVE_PROVIDER_STATUSES = new Set<GameResourceGeneration['status']>([
|
||||
'reserved',
|
||||
'accepted',
|
||||
'running',
|
||||
]);
|
||||
|
||||
type HostedBillingReceipt = Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }>;
|
||||
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<void>;
|
||||
type RecordTouchedPaths = (
|
||||
conversationId: string,
|
||||
runId: string,
|
||||
paths: readonly string[],
|
||||
) => Promise<void>;
|
||||
type Sleep = (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<Record<string, string>> = {
|
||||
'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<void> {
|
||||
await writeFile(filePath, bytes, { flag: 'wx' });
|
||||
}
|
||||
|
||||
async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
await new Promise<void>((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<void> = Promise.resolve();
|
||||
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
async get(logicalOperationId: string): Promise<DeliveryReceipt | null> {
|
||||
return (await this.read()).receipts.find(
|
||||
(receipt) => receipt.logicalOperationId === logicalOperationId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
async list(): Promise<readonly DeliveryReceipt[]> {
|
||||
return (await this.read()).receipts;
|
||||
}
|
||||
|
||||
async put(receipt: DeliveryReceipt): Promise<void> {
|
||||
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<ReceiptFile> {
|
||||
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<GameResourceClient, 'generate' | 'get' | 'download'>;
|
||||
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<string, Promise<GameResourceDeliveryResult>>();
|
||||
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<GameResourceDeliveryResult> {
|
||||
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<readonly GameResourceDeliveryResult[]> {
|
||||
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<GameResourceDeliveryResult> {
|
||||
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<GameResourceDeliveryResult> {
|
||||
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<GameResourceDeliveryResult> {
|
||||
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<GameResourceDeliveryResult> {
|
||||
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<ReturnType<GameResourceDeliveryCoordinatorOptions['client']['download']>>[];
|
||||
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<GameResourceDeliveryResult> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user