@playcraft/cli 0.1.48-beta → 0.1.50-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/workspace-runtime/batch/deliver.js +105 -87
- package/dist/workspace-runtime/batch/prepare.js +169 -56
- package/dist/workspace-runtime/batch/source-capture.js +7 -20
- package/dist/workspace-runtime/batch/source-publish.js +2 -4
- package/dist/workspace-runtime/codex/app-server.js +19 -1
- package/dist/workspace-runtime/codex/delivery-tools.js +144 -0
- package/dist/workspace-runtime/codex/tool-response.js +10 -0
- package/dist/workspace-runtime/codex/ui-tools.js +22 -5
- package/dist/workspace-runtime/previews/diagnostics.js +44 -0
- package/dist/workspace-runtime/previews/publish.js +29 -8
- package/dist/workspace-runtime/previews/runtime-preview.js +177 -0
- package/dist/workspace-runtime/serve.js +162 -4
- package/dist/workspace-runtime/server/accept-operation.js +61 -0
- package/dist/workspace-runtime/server/asset-transfers.js +63 -0
- package/dist/workspace-runtime/server/dispatch.js +328 -44
- package/dist/workspace-runtime/server/http.js +193 -137
- package/dist/workspace-runtime/server/operation-record.js +33 -0
- package/dist/workspace-runtime/server/submit-operation.js +41 -0
- package/dist/workspace-runtime/workspaces/append-only-log.js +62 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-dependencies.js +81 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +434 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +389 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +331 -0
- package/dist/workspace-runtime/workspaces/delivery-store.js +168 -0
- package/dist/workspace-runtime/workspaces/delivery.js +64 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +15 -0
- package/dist/workspace-runtime/workspaces/developer-command.js +35 -1
- package/dist/workspace-runtime/workspaces/directories.js +9 -4
- package/dist/workspace-runtime/workspaces/directory-mutations.js +238 -0
- package/dist/workspace-runtime/workspaces/directory-revisions.js +56 -0
- package/dist/workspace-runtime/workspaces/disk-snapshot.js +3 -3
- package/dist/workspace-runtime/workspaces/json5-edit.js +144 -1
- package/dist/workspace-runtime/workspaces/operation-log.js +23 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-inputs.js +87 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +29 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +58 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +282 -0
- package/dist/workspace-runtime/workspaces/parameters.js +77 -54
- package/package.json +3 -3
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, realpath, rm } from 'node:fs/promises';
|
|
3
|
+
import { relative, resolve, sep } from 'node:path';
|
|
4
|
+
import JSON5 from 'json5';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { embedWorkspaceRuntimeProfile, workspaceRelativePathSchema, workspaceRuntimePreviewProfileSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
8
|
+
import { revisionScope } from '../workspaces/registry.js';
|
|
9
|
+
const manifestPath = '.playcraft/workspace.json';
|
|
10
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
11
|
+
const relativePath = z
|
|
12
|
+
.string()
|
|
13
|
+
.refine((path) => workspaceRelativePathSchema.safeParse(path).success);
|
|
14
|
+
const configSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
version: z.literal(1),
|
|
17
|
+
buildScript: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9:_-]{0,63}$/),
|
|
18
|
+
outputPath: relativePath.refine((path) => path.startsWith('.playcraft-editor-preview/') && path.endsWith('.html')),
|
|
19
|
+
reportPath: relativePath.refine((path) => path.startsWith('.playcraft-editor-preview/') && path.endsWith('.json')),
|
|
20
|
+
configContractVersion: z.string().min(1).max(128),
|
|
21
|
+
gameFilePaths: z.array(relativePath).max(256).default([]),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
const reportSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
target: z.literal('editor-static'),
|
|
27
|
+
buildId: z.string(),
|
|
28
|
+
htmlDigest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
29
|
+
schemaDigest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
30
|
+
sourceFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
|
31
|
+
dataHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
32
|
+
dataRevision: z.number().int().nonnegative(),
|
|
33
|
+
schemaRevision: z.number().int().nonnegative(),
|
|
34
|
+
moduleIds: z.array(z.string()).min(1).max(10000),
|
|
35
|
+
})
|
|
36
|
+
.strict();
|
|
37
|
+
export class RuntimePreviewBuildError extends Error {
|
|
38
|
+
code;
|
|
39
|
+
constructor(code) {
|
|
40
|
+
super(code);
|
|
41
|
+
this.code = code;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function insideFile(root, path) {
|
|
45
|
+
const base = await realpath(root);
|
|
46
|
+
const file = await isolatedPath(root, path);
|
|
47
|
+
const real = await realpath(file);
|
|
48
|
+
const rel = relative(base, real);
|
|
49
|
+
const stat = await lstat(file);
|
|
50
|
+
if (!stat.isFile() ||
|
|
51
|
+
stat.isSymbolicLink() ||
|
|
52
|
+
rel === '..' ||
|
|
53
|
+
rel.startsWith(`..${sep}`) ||
|
|
54
|
+
stat.size > 50 * 1024 * 1024)
|
|
55
|
+
throw new RuntimePreviewBuildError('runtime_preview_path_invalid');
|
|
56
|
+
return readFile(file);
|
|
57
|
+
}
|
|
58
|
+
async function isolatedPath(root, path) {
|
|
59
|
+
const normalized = workspaceRelativePathSchema.parse(path);
|
|
60
|
+
const base = await realpath(root);
|
|
61
|
+
let parent = base;
|
|
62
|
+
for (const segment of normalized.split('/').slice(0, -1)) {
|
|
63
|
+
parent = resolve(parent, segment);
|
|
64
|
+
const stat = await lstat(parent).catch((error) => {
|
|
65
|
+
if (error.code === 'ENOENT')
|
|
66
|
+
return undefined;
|
|
67
|
+
throw error;
|
|
68
|
+
});
|
|
69
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
|
|
70
|
+
throw new RuntimePreviewBuildError('runtime_preview_path_invalid');
|
|
71
|
+
}
|
|
72
|
+
return resolve(base, normalized);
|
|
73
|
+
}
|
|
74
|
+
export async function prepareRuntimePreview(state, operation, root) {
|
|
75
|
+
let manifest;
|
|
76
|
+
try {
|
|
77
|
+
manifest = await insideFile(root, manifestPath);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code === 'ENOENT')
|
|
81
|
+
return undefined;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
let declared;
|
|
85
|
+
try {
|
|
86
|
+
declared = JSON5.parse(manifest.toString()).previewRuntime;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new RuntimePreviewBuildError('runtime_preview_manifest_invalid');
|
|
90
|
+
}
|
|
91
|
+
if (declared === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
const parsed = configSchema.safeParse(declared);
|
|
94
|
+
if (!parsed.success)
|
|
95
|
+
throw new RuntimePreviewBuildError('runtime_preview_manifest_invalid');
|
|
96
|
+
const config = parsed.data;
|
|
97
|
+
const packageData = JSON.parse((await insideFile(root, 'package.json')).toString());
|
|
98
|
+
if (typeof packageData.scripts?.[config.buildScript] !== 'string')
|
|
99
|
+
throw new RuntimePreviewBuildError('runtime_preview_script_missing');
|
|
100
|
+
const scope = revisionScope(state, operation.workspaceId, operation.directoryName);
|
|
101
|
+
const read = () => readWorkspaceParameters(root, state.journal.fileRevisions(scope));
|
|
102
|
+
const before = await read();
|
|
103
|
+
if (before.status !== 'ready' ||
|
|
104
|
+
!before.schemaHash ||
|
|
105
|
+
!before.dataHash ||
|
|
106
|
+
!before.sourceFingerprint)
|
|
107
|
+
throw new RuntimePreviewBuildError('runtime_preview_parameters_missing');
|
|
108
|
+
const gameHashes = await Promise.all([...new Set(['package.json', ...config.gameFilePaths])].map(async (path) => [path, digest(await insideFile(root, path))]));
|
|
109
|
+
const buildId = randomUUID();
|
|
110
|
+
const profileBase = {
|
|
111
|
+
version: 1,
|
|
112
|
+
target: 'editor-static',
|
|
113
|
+
protocolVersion: 1,
|
|
114
|
+
binding: {
|
|
115
|
+
scope: {
|
|
116
|
+
projectId: operation.workspaceId,
|
|
117
|
+
branchName: operation.directoryName ?? 'basic',
|
|
118
|
+
themeId: before.themeId ?? 'default',
|
|
119
|
+
},
|
|
120
|
+
schemaVersion: before.schemaHash,
|
|
121
|
+
configContractVersion: config.configContractVersion,
|
|
122
|
+
locale: 'en',
|
|
123
|
+
},
|
|
124
|
+
sourceFingerprint: before.sourceFingerprint,
|
|
125
|
+
baseline: {
|
|
126
|
+
dataRevision: before.dataRevision ?? 0,
|
|
127
|
+
schemaRevision: before.schemaRevision ?? 0,
|
|
128
|
+
dataHash: before.dataHash,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const context = { buildId, ...profileBase };
|
|
132
|
+
// Remove stale outputs before the script runs; a stale report cannot satisfy this build's identity.
|
|
133
|
+
for (const path of [config.outputPath, config.reportPath]) {
|
|
134
|
+
await rm(await isolatedPath(root, path), { force: true });
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
command: `PLAYCRAFT_RUNTIME_PREVIEW_CONTEXT=${shellQuote(JSON.stringify(context))} pnpm run ${shellQuote(config.buildScript)}`,
|
|
138
|
+
async finish() {
|
|
139
|
+
const after = await read();
|
|
140
|
+
if (after.status !== 'ready' ||
|
|
141
|
+
after.sourceFingerprint !== before.sourceFingerprint ||
|
|
142
|
+
after.dataHash !== before.dataHash ||
|
|
143
|
+
after.schemaHash !== before.schemaHash ||
|
|
144
|
+
after.dataRevision !== before.dataRevision ||
|
|
145
|
+
after.schemaRevision !== before.schemaRevision ||
|
|
146
|
+
digest(await insideFile(root, manifestPath)) !== digest(manifest))
|
|
147
|
+
throw new RuntimePreviewBuildError('runtime_preview_source_changed');
|
|
148
|
+
for (const [path, hash] of gameHashes) {
|
|
149
|
+
if (digest(await insideFile(root, path)) !== hash)
|
|
150
|
+
throw new RuntimePreviewBuildError('runtime_preview_source_changed');
|
|
151
|
+
}
|
|
152
|
+
const bytes = await insideFile(root, config.outputPath);
|
|
153
|
+
const report = reportSchema.safeParse(JSON.parse((await insideFile(root, config.reportPath)).toString()));
|
|
154
|
+
if (!report.success ||
|
|
155
|
+
report.data.buildId !== buildId ||
|
|
156
|
+
report.data.htmlDigest !== digest(bytes) ||
|
|
157
|
+
report.data.schemaDigest !== before.schemaHash ||
|
|
158
|
+
report.data.sourceFingerprint !== before.sourceFingerprint ||
|
|
159
|
+
report.data.dataHash !== before.dataHash ||
|
|
160
|
+
report.data.dataRevision !== before.dataRevision ||
|
|
161
|
+
report.data.schemaRevision !== before.schemaRevision ||
|
|
162
|
+
!report.data.moduleIds.some((id) => /(?:@playcraft\/preview-runtime|\/preview-runtime\/src\/(?:preview|runtime)\.ts)/.test(id)))
|
|
163
|
+
throw new RuntimePreviewBuildError('runtime_preview_report_invalid');
|
|
164
|
+
const profile = workspaceRuntimePreviewProfileSchema.parse({
|
|
165
|
+
...profileBase,
|
|
166
|
+
buildDigest: report.data.htmlDigest,
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
bytes: Buffer.from(embedWorkspaceRuntimeProfile(bytes.toString(), profile)),
|
|
170
|
+
profile,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function shellQuote(value) {
|
|
176
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
177
|
+
}
|
|
@@ -2,12 +2,14 @@ import { closeCliCredentials } from './workspaces/cli-credentials.js';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { mkdir, readdir } from 'node:fs/promises';
|
|
4
4
|
import { createServer } from 'node:http';
|
|
5
|
-
import {
|
|
5
|
+
import { createPlatformAdapter } from '@playcraft/build';
|
|
6
|
+
import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, directoryMutationReceiptSchema, productionSessionAttemptId, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
7
|
import { RuntimeJournal } from './persistence/journal.js';
|
|
7
8
|
import { createCodexAppServerAdapter } from './codex/app-server.js';
|
|
8
9
|
import { trustedMemberPolicy } from './batch/policy.js';
|
|
10
|
+
import { batchDirectoryHeld } from './batch/occupancy.js';
|
|
9
11
|
import { createLoopbackNativeAdapter } from './codex/loopback.js';
|
|
10
|
-
import { attachNativeCollector } from './server/dispatch.js';
|
|
12
|
+
import { attachNativeCollector, recoverInterruptedParameterVerifications, stopParameterVerifications } from './server/dispatch.js';
|
|
11
13
|
import { handleRuntimeRequest } from './server/http.js';
|
|
12
14
|
import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
|
|
13
15
|
import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
|
|
@@ -18,14 +20,19 @@ import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker
|
|
|
18
20
|
import { WorkspaceModelBroker } from './codex/model-broker.js';
|
|
19
21
|
import { recoverInterruptedPreviews } from './previews/publish.js';
|
|
20
22
|
import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
|
|
23
|
+
import { DeliveryStore, recoverInterruptedDeliveries } from './workspaces/delivery-store.js';
|
|
21
24
|
import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.js';
|
|
22
25
|
import { workspaceContextFromRoot, workspaceRoot } from './workspaces/registry.js';
|
|
23
26
|
import { recoverInterruptedLibraryImports } from './workspaces/asset-library-import.js';
|
|
27
|
+
import { recoverAssetTransfers } from './workspaces/asset-transfer-publish.js';
|
|
28
|
+
import { recoverWorkspaceDirectoryMutation } from './workspaces/directory-mutations.js';
|
|
29
|
+
import { observeWorkspaceDirectories } from './workspaces/directory-revisions.js';
|
|
24
30
|
import { observeWorkspaceBinaryFile } from './workspaces/binary-write.js';
|
|
25
31
|
import { revisionScope } from './workspaces/registry.js';
|
|
26
32
|
import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
|
|
27
33
|
import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
|
|
28
34
|
import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
|
|
35
|
+
import { submitTrustedOperation } from './server/submit-operation.js';
|
|
29
36
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
30
37
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
31
38
|
const negotiated = negotiateProtocol(config);
|
|
@@ -49,6 +56,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
49
56
|
const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
|
|
50
57
|
await recoverInterruptedInstalls(journal);
|
|
51
58
|
await recoverInterruptedPreviews(journal);
|
|
59
|
+
await recoverInterruptedParameterVerifications(journal);
|
|
60
|
+
await recoverInterruptedDeliveries(config.stateDir);
|
|
52
61
|
await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
|
|
53
62
|
const state = runtimeState = {
|
|
54
63
|
config: { ...config, protocolMinor: negotiated.protocolMinor },
|
|
@@ -63,6 +72,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
63
72
|
};
|
|
64
73
|
const recoveredImports = new Map();
|
|
65
74
|
const recoveredChanges = [];
|
|
75
|
+
const recoveredWrites = [];
|
|
66
76
|
const recoverLibraryImports = async (root) => {
|
|
67
77
|
const result = await recoverInterruptedLibraryImports(root).catch(() => undefined);
|
|
68
78
|
for (const published of result?.published ?? []) {
|
|
@@ -91,7 +101,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
91
101
|
if (operation.kind !== 'asset.library.import' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
92
102
|
continue;
|
|
93
103
|
const published = recoveredImports.get(operation.operationId);
|
|
94
|
-
const result = operation.result ? { ...operation.result,
|
|
104
|
+
const result = operation.result && 'paths' in operation.result ? { ...operation.result,
|
|
95
105
|
paths: [...new Set([...operation.result.paths, ...(published?.paths ?? [])])],
|
|
96
106
|
failures: operation.result.failures.filter(failure => !published?.assetIds.includes(failure.assetId)),
|
|
97
107
|
} : undefined;
|
|
@@ -120,16 +130,107 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
120
130
|
recoveredChanges.push({ workspaceId: operation.workspaceId,
|
|
121
131
|
directoryName: operation.directoryName ?? 'basic', paths: result.paths });
|
|
122
132
|
}
|
|
133
|
+
const recoveredBundles = new Map();
|
|
134
|
+
const transferScopes = new Set();
|
|
135
|
+
for (const { operation } of journal.listOperations()) {
|
|
136
|
+
if (operation.kind !== 'asset.bundle.save')
|
|
137
|
+
continue;
|
|
138
|
+
const directoryName = operation.directoryName ?? 'basic';
|
|
139
|
+
const key = `${operation.workspaceId ?? ''}:${directoryName}`;
|
|
140
|
+
if (transferScopes.has(key))
|
|
141
|
+
continue;
|
|
142
|
+
transferScopes.add(key);
|
|
143
|
+
const root = await workspaceRoot(state, operation.workspaceId, directoryName).catch(() => undefined);
|
|
144
|
+
if (!root)
|
|
145
|
+
continue;
|
|
146
|
+
const recovered = await recoverAssetTransfers(root, { environment: config.environment, userId: config.userId,
|
|
147
|
+
workspaceId: operation.workspaceId ?? '', directoryName }).catch(() => []);
|
|
148
|
+
for (const item of recovered)
|
|
149
|
+
recoveredBundles.set(item.operationId, item);
|
|
150
|
+
}
|
|
151
|
+
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
152
|
+
if (operation.kind !== 'asset.bundle.save' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
153
|
+
continue;
|
|
154
|
+
const recovered = recoveredBundles.get(operation.operationId), receipt = recovered?.receipt;
|
|
155
|
+
let next = operation;
|
|
156
|
+
if (receipt) {
|
|
157
|
+
const paths = [...receipt.files.map(file => file.path), ...(receipt.directories ?? [])];
|
|
158
|
+
const scope = revisionScope(state, operation.workspaceId, operation.directoryName);
|
|
159
|
+
for (const file of receipt.files) {
|
|
160
|
+
const previous = journal.fileRevisions(scope)[file.path];
|
|
161
|
+
const revision = previous ? previous.revision + (previous.contentHash === file.contentHash ? 0 : 1) : 1;
|
|
162
|
+
await journal.putFileRevision(file.path, { revision,
|
|
163
|
+
contentHash: file.contentHash }, scope);
|
|
164
|
+
const metadata = recovered?.manifest.files.find(item => item.path === file.path);
|
|
165
|
+
recoveredWrites.push({ ...file, revision, writer: 'manual', workspaceId: operation.workspaceId,
|
|
166
|
+
directoryName: operation.directoryName ?? 'basic', operationId: operation.operationId, transferId: receipt.transferId,
|
|
167
|
+
...(metadata?.processing ? { processing: metadata.processing } : {}), recovered: true });
|
|
168
|
+
}
|
|
169
|
+
if (next.status === 'queued')
|
|
170
|
+
next = transitionOperation(next, 'dispatched');
|
|
171
|
+
if (next.status === 'dispatched')
|
|
172
|
+
next = transitionOperation(next, 'accepted');
|
|
173
|
+
next = transitionOperation(next, 'completed', { result: { paths, skippedPaths: [], failures: [], bundleReceipt: receipt } });
|
|
174
|
+
if (operation.workspaceId)
|
|
175
|
+
recoveredChanges.push({ workspaceId: operation.workspaceId, directoryName: operation.directoryName ?? 'basic', paths, source: 'asset.bundle.save' });
|
|
176
|
+
}
|
|
177
|
+
else
|
|
178
|
+
next = transitionOperation(next, 'failed', { error: 'asset_bundle_save_interrupted' });
|
|
179
|
+
await journal.putOperation(scopeKey, next);
|
|
180
|
+
}
|
|
181
|
+
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
182
|
+
if (!['directory.rename', 'directory.delete'].includes(operation.kind)
|
|
183
|
+
|| !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
184
|
+
continue;
|
|
185
|
+
const kind = operation.kind;
|
|
186
|
+
let next = operation;
|
|
187
|
+
try {
|
|
188
|
+
const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
189
|
+
const receipt = await recoverWorkspaceDirectoryMutation(root, kind, { operationId: operation.operationId,
|
|
190
|
+
operationPayloadHash: operation.payloadHash, scope: scopeKey, assertCurrent: async () => {
|
|
191
|
+
if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed())
|
|
192
|
+
throw new Error('stale_lease');
|
|
193
|
+
if (operation.sessionId && productionSessionAttemptId(operation.sessionId))
|
|
194
|
+
throw new Error('production_session_readonly');
|
|
195
|
+
if (await batchDirectoryHeld(state, operation.workspaceId, operation.directoryName))
|
|
196
|
+
throw new Error('batch_workspace_readonly');
|
|
197
|
+
} });
|
|
198
|
+
if (!receipt)
|
|
199
|
+
throw new Error('directory_mutation_interrupted');
|
|
200
|
+
await observeWorkspaceDirectories(root, journal, revisionScope(state, operation.workspaceId, operation.directoryName));
|
|
201
|
+
const paths = [receipt.path, ...(receipt.newPath ? [receipt.newPath] : [])];
|
|
202
|
+
if (next.status === 'queued')
|
|
203
|
+
next = transitionOperation(next, 'dispatched');
|
|
204
|
+
if (next.status === 'dispatched')
|
|
205
|
+
next = transitionOperation(next, 'accepted');
|
|
206
|
+
next = transitionOperation(next, 'completed', { result: { paths, skippedPaths: [], failures: [], directoryMutation: directoryMutationReceiptSchema.parse(receipt) } });
|
|
207
|
+
if (operation.workspaceId)
|
|
208
|
+
recoveredChanges.push({ workspaceId: operation.workspaceId,
|
|
209
|
+
directoryName: operation.directoryName ?? 'basic', paths, source: kind });
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
const reason = String(error?.code ?? (error instanceof Error ? error.message : ''));
|
|
213
|
+
const code = ['directory_mutation_interrupted', 'stale_lease', 'production_session_readonly', 'batch_workspace_readonly',
|
|
214
|
+
'directory_exists', 'directory_not_found', 'revision_conflict', 'directory_conflict', 'invalid_workspace_path', 'workspace_permission_denied']
|
|
215
|
+
.includes(reason) ? reason : 'directory_recovery_required';
|
|
216
|
+
next = transitionOperation(next, code === 'directory_mutation_interrupted' ? 'failed' : 'uncertain', { error: code });
|
|
217
|
+
}
|
|
218
|
+
await journal.putOperation(scopeKey, next);
|
|
219
|
+
}
|
|
123
220
|
const native = state.native ??= defaultNativeAdapter(state);
|
|
124
221
|
await journal.bindRuntime({
|
|
125
222
|
runtimeId: config.runtimeId,
|
|
126
223
|
generation: config.generation,
|
|
127
224
|
runtimeEpoch: state.runtimeEpoch,
|
|
128
225
|
});
|
|
226
|
+
for (const payload of recoveredWrites)
|
|
227
|
+
await journal.allocateSourceEvent({ runtimeId: config.runtimeId,
|
|
228
|
+
generation: config.generation, runtimeEpoch: state.runtimeEpoch, workspaceId: payload.workspaceId,
|
|
229
|
+
type: 'file.written', occurredAt: new Date().toISOString(), payload });
|
|
129
230
|
for (const change of recoveredChanges)
|
|
130
231
|
await journal.allocateSourceEvent({ runtimeId: config.runtimeId,
|
|
131
232
|
generation: config.generation, runtimeEpoch: state.runtimeEpoch, workspaceId: change.workspaceId,
|
|
132
|
-
type: 'files.changed', occurredAt: new Date().toISOString(), payload: { ...change, source: 'asset.library.import', recovered: true },
|
|
233
|
+
type: 'files.changed', occurredAt: new Date().toISOString(), payload: { ...change, source: change.source ?? 'asset.library.import', recovered: true },
|
|
133
234
|
});
|
|
134
235
|
startSourceEventUploader(state);
|
|
135
236
|
if (config.workspaceMode === 'multi') {
|
|
@@ -194,6 +295,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
194
295
|
url: `http://${formatHost(config.listen.host)}:${port}`,
|
|
195
296
|
async close() {
|
|
196
297
|
const serverClosed = closeServer(server);
|
|
298
|
+
await stopParameterVerifications(state);
|
|
197
299
|
await closeCliCredentials(state);
|
|
198
300
|
await gitCredentials?.close();
|
|
199
301
|
try {
|
|
@@ -253,6 +355,61 @@ function closeServer(server) {
|
|
|
253
355
|
function formatHost(host) {
|
|
254
356
|
return host.includes(':') ? `[${host}]` : host;
|
|
255
357
|
}
|
|
358
|
+
function deliveryToolHandlers(state) {
|
|
359
|
+
const contextFor = async (input) => {
|
|
360
|
+
if (input.workspaceId)
|
|
361
|
+
return { workspaceId: input.workspaceId, directoryName: input.directoryName ?? 'basic' };
|
|
362
|
+
if (state.config.workspaceMode !== 'multi' || !input.cwd)
|
|
363
|
+
throw new Error('feature_unavailable');
|
|
364
|
+
return workspaceContextFromRoot(state, input.cwd);
|
|
365
|
+
};
|
|
366
|
+
return {
|
|
367
|
+
async build(input) {
|
|
368
|
+
const { workspaceId, directoryName } = await contextFor(input);
|
|
369
|
+
if (directoryName === 'root')
|
|
370
|
+
throw new Error('invalid_workspace_directory');
|
|
371
|
+
const builds = [];
|
|
372
|
+
for (const channel of input.channels) {
|
|
373
|
+
const operation = await submitTrustedOperation(state, {
|
|
374
|
+
kind: 'delivery.build',
|
|
375
|
+
workspaceId,
|
|
376
|
+
directoryName,
|
|
377
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
378
|
+
clientRequestId: `${input.clientRequestId.slice(0, 80)}:${channel}`,
|
|
379
|
+
payload: {
|
|
380
|
+
channel,
|
|
381
|
+
format: input.format ?? createPlatformAdapter({ platform: channel, format: 'html' }).getDefaultFormat(),
|
|
382
|
+
actor: 'agent',
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
let record;
|
|
386
|
+
for (let attempt = 0; attempt < 40 && !record; attempt++) {
|
|
387
|
+
record = await new DeliveryStore(state.config.stateDir, workspaceId).read(operation.operationId);
|
|
388
|
+
if (!record)
|
|
389
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
390
|
+
}
|
|
391
|
+
builds.push({
|
|
392
|
+
buildId: operation.operationId,
|
|
393
|
+
channel,
|
|
394
|
+
status: record?.status ?? operation.status,
|
|
395
|
+
...(record?.queuePosition ? { queuePosition: record.queuePosition } : {}),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return { builds };
|
|
399
|
+
},
|
|
400
|
+
async get(input) {
|
|
401
|
+
const { workspaceId, directoryName } = await contextFor(input);
|
|
402
|
+
const store = new DeliveryStore(state.config.stateDir, workspaceId);
|
|
403
|
+
const record = input.buildId
|
|
404
|
+
? await store.read(input.buildId)
|
|
405
|
+
: (await store.list(directoryName, input.channel, 20))[0];
|
|
406
|
+
if (!record || record.directoryName !== directoryName)
|
|
407
|
+
throw new Error('delivery_build_not_found');
|
|
408
|
+
const log = await store.readLog(record.buildId, 0).catch(() => undefined);
|
|
409
|
+
return { build: record, ...(log ? { logTail: log.text.slice(-4_000) } : {}) };
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
256
413
|
function defaultNativeAdapter(state) {
|
|
257
414
|
const config = state.config;
|
|
258
415
|
if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'loopback')
|
|
@@ -266,6 +423,7 @@ function defaultNativeAdapter(state) {
|
|
|
266
423
|
return createCodexAppServerAdapter({
|
|
267
424
|
config,
|
|
268
425
|
workspaceUi: true,
|
|
426
|
+
deliveryTools: deliveryToolHandlers(state),
|
|
269
427
|
...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
|
|
270
428
|
...(multi ? { loadMcpConfig: async (cwd) => {
|
|
271
429
|
const { workspaceId } = await workspaceContextFromRoot(state, cwd);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { assertLeaseFence, hashOperationPayload, isLostThreadError, operationScopeKey, resolveIdempotentAccept, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
3
|
+
import { buildOperationRecord } from './operation-record.js';
|
|
4
|
+
/**
|
|
5
|
+
* Shared operation acceptance: lease fence, idempotency, capability, record creation and
|
|
6
|
+
* execution. HTTP supplies its transport/session checks through `validate`; in-sandbox Agent
|
|
7
|
+
* tools pass none. The callback runs only when a new operation is about to be created.
|
|
8
|
+
*/
|
|
9
|
+
export async function acceptOperationCore(state, parsed, options) {
|
|
10
|
+
const payloadHash = hashOperationPayload(parsed.payload);
|
|
11
|
+
const scopeKey = operationScopeKey({
|
|
12
|
+
environment: state.config.environment,
|
|
13
|
+
userId: state.config.userId,
|
|
14
|
+
kind: parsed.kind,
|
|
15
|
+
clientRequestId: parsed.clientRequestId,
|
|
16
|
+
workspaceId: parsed.workspaceId,
|
|
17
|
+
directoryName: parsed.directoryName,
|
|
18
|
+
sessionId: parsed.sessionId,
|
|
19
|
+
});
|
|
20
|
+
return state.journal.exclusive(scopeKey, async () => {
|
|
21
|
+
// Recheck after a concurrent dispatcher may have advanced the fence while this request waited.
|
|
22
|
+
if (assertLeaseFence(options.leaseFence, state.acceptedLeaseFence) !== 'ok') {
|
|
23
|
+
return rejected(409, runtimeError('stale_lease', 'leaseFence is behind the accepted dispatcher'));
|
|
24
|
+
}
|
|
25
|
+
const existing = state.journal.getOperation(scopeKey);
|
|
26
|
+
const decision = resolveIdempotentAccept(existing, payloadHash);
|
|
27
|
+
if (decision.action === 'conflict') {
|
|
28
|
+
return rejected(409, runtimeError('idempotency_conflict', 'clientRequestId reused with a different payload', {
|
|
29
|
+
operationId: decision.operation.operationId,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
const retryWithRecovery = decision.action === 'reuse' && parsed.kind === 'session.turn'
|
|
33
|
+
&& Boolean(parsed.sessionRecovery) && decision.operation.status === 'failed'
|
|
34
|
+
&& isLostThreadError(decision.operation.error);
|
|
35
|
+
if (decision.action === 'reuse' && !retryWithRecovery) {
|
|
36
|
+
if (decision.operation.status === 'uncertain') {
|
|
37
|
+
return rejected(409, runtimeError('uncertain_operation', decision.operation.error ?? 'native_receipt_lost', {
|
|
38
|
+
operationId: decision.operation.operationId,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
return { rejected: false, operation: decision.operation, executed: decision.operation, reused: true };
|
|
42
|
+
}
|
|
43
|
+
const validation = await options.validate?.(scopeKey);
|
|
44
|
+
if (validation)
|
|
45
|
+
return { rejected: true, refusal: validation };
|
|
46
|
+
const executable = canExecuteKind(state, parsed.kind);
|
|
47
|
+
const operation = buildOperationRecord(state, parsed, payloadHash, options.leaseFence, executable);
|
|
48
|
+
if (!executable) {
|
|
49
|
+
const saved = await state.journal.putOperation(scopeKey, operation);
|
|
50
|
+
return rejected(409, runtimeError('capability_unavailable', state.journal.destroyed() ? 'runtime_destroyed' : state.nativeError ?? 'adapter_not_started', { operationId: saved.operationId }));
|
|
51
|
+
}
|
|
52
|
+
state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, options.leaseFence);
|
|
53
|
+
await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
|
|
54
|
+
const queued = await state.journal.putOperation(scopeKey, operation);
|
|
55
|
+
const executed = await executeOperation(state, scopeKey, queued, options.executionPayload, parsed.sessionRecovery);
|
|
56
|
+
return { rejected: false, operation: queued, executed, reused: false };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function rejected(status, body) {
|
|
60
|
+
return { rejected: true, refusal: { status, body } };
|
|
61
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ASSET_TRANSFER_BODY_MAX_BYTES, ASSET_TRANSFER_CHUNK_BODY_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
3
|
+
import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
|
|
4
|
+
import { AssetTransferError, cancelAssetTransfer, createAssetTransfer, getAssetTransfer, putAssetTransferChunk } from '../workspaces/asset-transfer-storage.js';
|
|
5
|
+
export async function handleAssetTransferRequest(state, request, url) {
|
|
6
|
+
const route = /^\/runtime\/asset-transfers(?:\/([^/]+)(?:\/(chunks))?)?$/.exec(url.pathname);
|
|
7
|
+
if (!route)
|
|
8
|
+
return undefined;
|
|
9
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
10
|
+
const directoryName = url.searchParams.get('directoryName') ?? 'basic';
|
|
11
|
+
try {
|
|
12
|
+
if (state.journal.destroyed())
|
|
13
|
+
throw new AssetTransferError('runtime_destroyed');
|
|
14
|
+
if (!/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
|
|
15
|
+
throw new AssetTransferError('invalid_workspace_directory', 400);
|
|
16
|
+
const [, id, action] = route;
|
|
17
|
+
const valid = !id && request.method === 'POST' || id && !action && ['GET', 'DELETE'].includes(request.method ?? '')
|
|
18
|
+
|| id && action === 'chunks' && request.method === 'PUT';
|
|
19
|
+
if (!valid)
|
|
20
|
+
return { status: 405, body: { error: 'asset_transfer_method_not_allowed' } };
|
|
21
|
+
const scope = { workspaceId, directoryName, environment: state.config.environment, userId: state.config.userId };
|
|
22
|
+
const body = ['POST', 'PUT'].includes(request.method ?? '')
|
|
23
|
+
? await readTransferJson(request, action ? ASSET_TRANSFER_CHUNK_BODY_MAX_BYTES : ASSET_TRANSFER_BODY_MAX_BYTES) : undefined;
|
|
24
|
+
return await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => state.journal.exclusive(`workspace:${revisionScope(state, workspaceId, directoryName) ?? 'single'}`, async () => {
|
|
25
|
+
if (state.journal.destroyed())
|
|
26
|
+
throw new AssetTransferError('runtime_destroyed');
|
|
27
|
+
if (request.method !== 'GET' && await batchDirectoryHeld(state, workspaceId, directoryName))
|
|
28
|
+
throw new AssetTransferError('batch_workspace_readonly');
|
|
29
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
30
|
+
const result = !id ? await createAssetTransfer(root, scope, body)
|
|
31
|
+
: action ? await putAssetTransferChunk(root, scope, id, body)
|
|
32
|
+
: request.method === 'GET' ? await getAssetTransfer(root, scope, id) : await cancelAssetTransfer(root, scope, id);
|
|
33
|
+
return { status: 200, body: result };
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof AssetTransferError)
|
|
38
|
+
return { status: error.status, body: { error: error.code } };
|
|
39
|
+
if (error.name === 'ZodError')
|
|
40
|
+
return { status: 400, body: { error: 'invalid_asset_transfer' } };
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function readTransferJson(request, limit) {
|
|
45
|
+
const length = request.headers['content-length'];
|
|
46
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > limit))
|
|
47
|
+
throw new AssetTransferError('asset_transfer_too_large', 413);
|
|
48
|
+
const parts = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
for await (const data of request) {
|
|
51
|
+
const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
52
|
+
size += bytes.length;
|
|
53
|
+
if (size > limit)
|
|
54
|
+
throw new AssetTransferError('asset_transfer_too_large', 413);
|
|
55
|
+
parts.push(bytes);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(Buffer.concat(parts).toString('utf8'));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new AssetTransferError('invalid_asset_transfer', 400);
|
|
62
|
+
}
|
|
63
|
+
}
|