@playcraft/cli 0.1.68 → 0.1.70
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/asset-library/provenance.js +169 -0
- package/dist/commands/library-messages.js +27 -9
- package/dist/commands/library.js +62 -10
- package/dist/model-adaptation/project-template.js +56 -3
- package/dist/model-adaptation/project.js +15 -1
- package/dist/workspace-runtime/batch/content-changes.js +3 -0
- package/dist/workspace-runtime/batch/freeze.js +15 -2
- package/dist/workspace-runtime/batch/generate.js +4 -1
- package/dist/workspace-runtime/batch/git.js +16 -4
- package/dist/workspace-runtime/batch/prepare.js +5 -1
- package/dist/workspace-runtime/batch/publication.js +96 -0
- package/dist/workspace-runtime/batch/source-candidate.js +2 -1
- package/dist/workspace-runtime/batch/source-capture.js +42 -3
- package/dist/workspace-runtime/batch/source-publish.js +26 -15
- package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -1
- package/dist/workspace-runtime/serve.js +50 -18
- package/dist/workspace-runtime/server/dispatch.js +42 -9
- package/dist/workspace-runtime/server/http.js +13 -2
- package/dist/workspace-runtime/verify-artifact.js +3 -1
- package/dist/workspace-runtime/workspaces/archive.js +28 -3
- package/dist/workspace-runtime/workspaces/asset-library-import.js +33 -2
- package/dist/workspace-runtime/workspaces/asset-provenance-verifier.js +36 -0
- package/dist/workspace-runtime/workspaces/registry.js +3 -5
- package/dist/workspace-runtime/workspaces/remix-references.js +119 -2
- package/dist/workspace-runtime/workspaces/remote-attach-marker.js +55 -0
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.playcraft/skills.lock.json +2 -2
- package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
|
@@ -14,6 +14,7 @@ import { processHasExited, runProcessGroup } from './processes.js';
|
|
|
14
14
|
import { createInstallLog } from './install-log.js';
|
|
15
15
|
import { stopMemberWriters } from './writer-proof.js';
|
|
16
16
|
import { restoreBatchSkillArtifacts } from './skill-artifacts.js';
|
|
17
|
+
import { rebindBatchRemixReferences } from '../workspaces/remix-references.js';
|
|
17
18
|
import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
|
|
18
19
|
const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
|
|
19
20
|
function hashCommand(value) {
|
|
@@ -191,11 +192,14 @@ export async function prepareBatchMember(state, body, options) {
|
|
|
191
192
|
const commit = command.resumeFromCommit ?? snapshot.commitHash;
|
|
192
193
|
await checkoutBranch(staging, command.branchName, commit, repositoryUrl);
|
|
193
194
|
await restoreBatchSkillArtifacts(sourceRoot, staging);
|
|
195
|
+
await rebindBatchRemixReferences(staging, snapshot.sourceDirectoryName, command.directoryName, command.remixReferences ?? []);
|
|
194
196
|
const { rename } = await import('node:fs/promises');
|
|
195
197
|
await rename(staging, root);
|
|
196
198
|
}
|
|
197
|
-
else
|
|
199
|
+
else {
|
|
198
200
|
await restoreBatchSkillArtifacts(sourceRoot, root);
|
|
201
|
+
await rebindBatchRemixReferences(root, snapshot.sourceDirectoryName, command.directoryName, command.remixReferences ?? []);
|
|
202
|
+
}
|
|
199
203
|
await registerWorkspaceDirectory(state, {
|
|
200
204
|
workspaceId: command.workspaceId,
|
|
201
205
|
directoryName: command.directoryName,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { markRemoteAttachPending } from '../workspaces/remote-attach-marker.js';
|
|
3
|
+
/** The control plane may promote a standalone binding, but may not substitute another checkout. */
|
|
4
|
+
export function publicationBinding(state, workspaceId, directoryName, publication) {
|
|
5
|
+
const existing = state.journal.getWorkspace(workspaceId);
|
|
6
|
+
if (!publication)
|
|
7
|
+
return existing;
|
|
8
|
+
const binding = publication.binding;
|
|
9
|
+
if (publication.identity.runtimeId !== state.config.runtimeId || publication.identity.generation !== state.config.generation
|
|
10
|
+
|| publication.identity.runtimeEpoch !== state.runtimeEpoch || state.journal.destroyed())
|
|
11
|
+
throw new Error('batch_source_identity_mismatch');
|
|
12
|
+
const promotion = existing?.targetKind === 'standalone' && binding.targetKind === 'main';
|
|
13
|
+
if (!existing || binding.workspaceId !== workspaceId || binding.targetKind === 'standalone'
|
|
14
|
+
|| (!promotion && !sameWorkspaceRegistration(existing, binding)))
|
|
15
|
+
throw new Error('batch_source_identity_mismatch');
|
|
16
|
+
if (directoryName !== 'basic') {
|
|
17
|
+
const local = existing.directories?.find(item => item.directoryName === directoryName);
|
|
18
|
+
const trusted = binding.directories?.find(item => item.directoryName === directoryName);
|
|
19
|
+
if (!local || !trusted || local.workspaceId !== workspaceId || trusted.workspaceId !== workspaceId
|
|
20
|
+
|| local.remixId !== trusted.remixId || local.branchName !== trusted.branchName
|
|
21
|
+
|| local.status === 'reclaimed' || trusted.status === 'reclaimed')
|
|
22
|
+
throw new Error('batch_source_identity_mismatch');
|
|
23
|
+
}
|
|
24
|
+
return binding;
|
|
25
|
+
}
|
|
26
|
+
/** Adopt trusted identity after publication without changing the captured source checkout. */
|
|
27
|
+
export async function adoptPublicationBinding(state, workspaceId, directoryName, publication, leaseFence) {
|
|
28
|
+
await state.journal.exclusive(`workspace:${workspaceId}`, async () => {
|
|
29
|
+
if (state.journal.destroyed())
|
|
30
|
+
throw new Error('runtime_destroyed');
|
|
31
|
+
if (leaseFence < state.acceptedLeaseFence)
|
|
32
|
+
throw new Error('stale_lease');
|
|
33
|
+
const binding = publicationBinding(state, workspaceId, directoryName, publication);
|
|
34
|
+
const current = state.journal.getWorkspace(workspaceId);
|
|
35
|
+
const directories = [...(current.directories ?? [])];
|
|
36
|
+
for (const incoming of binding.directories ?? []) {
|
|
37
|
+
const index = directories.findIndex(item => item.directoryName === incoming.directoryName);
|
|
38
|
+
if (index < 0) {
|
|
39
|
+
directories.push(incoming);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const previous = directories[index];
|
|
43
|
+
if (previous.workspaceId !== incoming.workspaceId || previous.remixId !== incoming.remixId
|
|
44
|
+
|| previous.branchName !== incoming.branchName || (previous.sourceCommit && incoming.sourceCommit
|
|
45
|
+
&& previous.sourceCommit !== incoming.sourceCommit))
|
|
46
|
+
throw new Error('batch_source_identity_mismatch');
|
|
47
|
+
directories[index] = { ...incoming,
|
|
48
|
+
...(previous.status === 'ready' && incoming.status === 'pending' ? { status: 'ready' } : {}),
|
|
49
|
+
...(incoming.sourceCommit ?? previous.sourceCommit ? { sourceCommit: incoming.sourceCommit ?? previous.sourceCommit } : {}) };
|
|
50
|
+
}
|
|
51
|
+
if (current.targetKind === 'standalone' && binding.targetKind === 'main' && !binding.preserveLocalBasic) {
|
|
52
|
+
await markRemoteAttachPending(state, workspaceId, binding.repositoryUrl, binding.branchName);
|
|
53
|
+
}
|
|
54
|
+
await state.journal.putWorkspace({ ...binding, directories });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Supply scoped credentials to Git only through the child process environment, never the checkout. */
|
|
58
|
+
export function publicationGitOptions(state, publication) {
|
|
59
|
+
const { binding, preparation } = publication;
|
|
60
|
+
if (binding.targetKind === 'standalone' || preparation.mode !== 'clone')
|
|
61
|
+
throw new Error('batch_source_identity_mismatch');
|
|
62
|
+
let source;
|
|
63
|
+
try {
|
|
64
|
+
source = new URL(binding.repositoryUrl);
|
|
65
|
+
if (source.username || source.password || source.search || source.hash || (source.protocol !== 'https:'
|
|
66
|
+
&& !(source.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(source.hostname))))
|
|
67
|
+
throw new Error();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error('workspace_git_gateway_unavailable');
|
|
71
|
+
}
|
|
72
|
+
const direct = isLocalWorkspaceGitAuthMode(preparation.authMode);
|
|
73
|
+
if (direct && (!isLocalWorkspaceGitEnvironment(state.config.environment) || !preparation.directAccess
|
|
74
|
+
|| preparation.directAccess.repositoryUrl !== binding.repositoryUrl))
|
|
75
|
+
throw new Error('workspace_git_gateway_unavailable');
|
|
76
|
+
const transport = preparation.transport;
|
|
77
|
+
if (transport) {
|
|
78
|
+
const match = matchWorkspaceGitGatewayCloneUrl(transport.cloneUrl, state.config.gitGatewayOrigins, binding.repositoryUrl);
|
|
79
|
+
if (transport.kind !== 'git-gateway-v2' || !match.ok || match.requestId !== `v2/${binding.workspaceId}` || !preparation.gitAccess)
|
|
80
|
+
throw new Error('workspace_git_gateway_unavailable');
|
|
81
|
+
}
|
|
82
|
+
const access = preparation.gitAccess;
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
if (access && (Date.parse(access.issuedAt) > now || Date.parse(access.expiresAt) <= now + 10_000))
|
|
85
|
+
throw new Error('workspace_git_access_expired');
|
|
86
|
+
const credential = preparation.directAccess ?? access;
|
|
87
|
+
const entries = [['credential.helper', ''], ['http.followRedirects', 'false']];
|
|
88
|
+
if (credential)
|
|
89
|
+
entries.push([`http.${transport?.cloneUrl ?? source.href}.extraHeader`,
|
|
90
|
+
`Authorization: Basic ${Buffer.from(`${credential.username}:${credential.password}`).toString('base64')}`]);
|
|
91
|
+
if (transport)
|
|
92
|
+
entries.push([`url.${transport.cloneUrl}.insteadOf`, binding.repositoryUrl]);
|
|
93
|
+
const env = { GIT_ALLOW_PROTOCOL: 'http:https', GIT_CONFIG_COUNT: String(entries.length) };
|
|
94
|
+
entries.forEach(([key, value], index) => { env[`GIT_CONFIG_KEY_${index}`] = key; env[`GIT_CONFIG_VALUE_${index}`] = value; });
|
|
95
|
+
return { env, deadline: Math.min(now + 90_000, access ? Date.parse(access.expiresAt) : Infinity) };
|
|
96
|
+
}
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { link, mkdir, open, readFile, unlink } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { batchSourceSnapshotRefSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { publicationBinding } from './publication.js';
|
|
5
6
|
export function sourceCandidateFile(state, batchId) {
|
|
6
7
|
return join(state.config.stateDir, 'batch-source-candidates', `${batchId}.json`);
|
|
7
8
|
}
|
|
@@ -72,7 +73,7 @@ export async function writeSourceCandidate(state, candidate) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
export function assertSourceIdentity(state, request, snapshot, candidate) {
|
|
75
|
-
const binding = state.
|
|
76
|
+
const binding = publicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication);
|
|
76
77
|
const branchName = request.sourceDirectoryName === 'basic' ? binding?.branchName
|
|
77
78
|
: binding?.directories?.find(directory => directory.directoryName === request.sourceDirectoryName)?.branchName;
|
|
78
79
|
if (snapshot.batchId !== request.batchId || snapshot.sourceWorkspaceId !== request.workspaceId
|
|
@@ -4,15 +4,19 @@ import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'n
|
|
|
4
4
|
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
5
5
|
import { gzip, gunzip } from 'node:zlib';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
+
import { WORKSPACE_REMIX_REFERENCES_MANIFEST } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
8
|
import { excludeFromGit } from '../workspaces/dependencies.js';
|
|
9
|
+
import { batchReferenceEnvExamples, batchReferenceMetadata } from '../workspaces/remix-references.js';
|
|
8
10
|
import { gitRead, gitWrite } from './git.js';
|
|
9
11
|
const compress = promisify(gzip);
|
|
10
12
|
const decompress = promisify(gunzip);
|
|
11
13
|
const privateNames = new Set(['.git', 'node_modules', '.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store',
|
|
12
14
|
'.config', '.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json', 'credentials', 'secrets',
|
|
13
15
|
'id_rsa', 'id_ed25519', 'settings.local.json', '.playcraft.json', 'playcraft.config.json', '.playcraft-temp', '.playcraft-preview-out']);
|
|
14
|
-
function included(path) {
|
|
15
|
-
|
|
16
|
+
function included(path, referenceExamples) {
|
|
17
|
+
const parts = path.split('/');
|
|
18
|
+
return !parts.some((name, index) => privateNames.has(name) || (/^\.env(?:$|\.)/.test(name) &&
|
|
19
|
+
!(index === parts.length - 1 && referenceExamples.has(path))) || /\.(?:pem|key|p12|pfx)$/.test(name))
|
|
16
20
|
&& !path.startsWith('.playcraft/skills/');
|
|
17
21
|
}
|
|
18
22
|
/** Git keeps only the executable bit, so the digest must not depend on umask or group-write bits. */
|
|
@@ -32,10 +36,12 @@ export function digestFiles(files) {
|
|
|
32
36
|
/** Files Git will accept. Credentials, caches and node_modules are left out. */
|
|
33
37
|
export async function readSourceFiles(root) {
|
|
34
38
|
const source = await realpath(root);
|
|
39
|
+
const referenceExamples = await batchReferenceEnvExamples(source);
|
|
40
|
+
const referenceRecords = await batchReferenceMetadata(source);
|
|
35
41
|
const paths = new Set((await gitRead(source, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean));
|
|
36
42
|
const files = new Map();
|
|
37
43
|
for (const path of [...paths].sort()) {
|
|
38
|
-
if (!included(path))
|
|
44
|
+
if (!included(path, referenceExamples))
|
|
39
45
|
continue;
|
|
40
46
|
const full = join(source, path);
|
|
41
47
|
const stat = await lstat(full).catch(error => {
|
|
@@ -55,6 +61,33 @@ export async function readSourceFiles(root) {
|
|
|
55
61
|
await handle.close();
|
|
56
62
|
}
|
|
57
63
|
}
|
|
64
|
+
const manifestFile = files.get(WORKSPACE_REMIX_REFERENCES_MANIFEST);
|
|
65
|
+
if (manifestFile) {
|
|
66
|
+
try {
|
|
67
|
+
const manifest = JSON.parse(manifestFile.body.toString('utf8'));
|
|
68
|
+
if (manifest?.version === 1 && Array.isArray(manifest.items)) {
|
|
69
|
+
const withheld = [];
|
|
70
|
+
for (const record of referenceRecords) {
|
|
71
|
+
for (const file of record.files) {
|
|
72
|
+
const path = `${record.path}/${file.path}`;
|
|
73
|
+
// A damaged reference may make an otherwise allowed example fail verification. That
|
|
74
|
+
// is a reference-integrity error, not evidence that a protected path was withheld.
|
|
75
|
+
const isExample = /^\.env\.(?:example|sample|template)$/i.test(path.split('/').pop() ?? '');
|
|
76
|
+
if (paths.has(path) && !included(path, referenceExamples) &&
|
|
77
|
+
(!isExample || !included(path, new Set([path]))) && (await lstat(join(source, path)).catch(() => undefined))?.isFile()) {
|
|
78
|
+
withheld.push(record.referenceId);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
delete manifest.batchPolicyWithheldReferenceIds;
|
|
84
|
+
if (withheld.length)
|
|
85
|
+
manifest.batchPolicyWithheldReferenceIds = withheld;
|
|
86
|
+
files.set(WORKSPACE_REMIX_REFERENCES_MANIFEST, { ...manifestFile, body: Buffer.from(JSON.stringify(manifest)) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch { /* Preserve malformed legacy metadata without letting it block an unrelated batch. */ }
|
|
90
|
+
}
|
|
58
91
|
return files;
|
|
59
92
|
}
|
|
60
93
|
/** A changing source must not silently lose files from the captured input. */
|
|
@@ -99,6 +132,12 @@ export async function captureIndependentSource(source, destination) {
|
|
|
99
132
|
for (const entry of ['/.playcraft.json', '/playcraft.config.json', '/.playcraft/skills/', '/.playcraft-temp/'])
|
|
100
133
|
await excludeFromGit(staging, entry);
|
|
101
134
|
await gitWrite(staging, ['add', '-A', '.']);
|
|
135
|
+
// Imported reference files can be tracked in the source despite .gitignore. Preserve only
|
|
136
|
+
// their filtered staging paths, without changing ignore handling for other source files.
|
|
137
|
+
for (const path of ['.playcraft/remix-references.json', 'references/remixes']) {
|
|
138
|
+
if (await lstat(join(staging, path)).catch(() => undefined))
|
|
139
|
+
await gitWrite(staging, ['add', '--force', '-A', '--', path]);
|
|
140
|
+
}
|
|
102
141
|
await gitWrite(staging, ['commit', '--allow-empty', '-m', 'Freeze batch source']);
|
|
103
142
|
const commitHash = (await gitWrite(staging, ['rev-parse', 'HEAD'])).trim();
|
|
104
143
|
await rename(staging, destination);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { lstat, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
|
|
3
|
+
import { clearCredentialHelpers, copyCredentialHelpers, gitRead, gitWrite } from './git.js';
|
|
4
4
|
import { archiveSource, restoreArchive } from './source-capture.js';
|
|
5
5
|
import { sourceCacheDir } from './records.js';
|
|
6
6
|
import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
|
|
7
|
+
import { publicationGitOptions } from './publication.js';
|
|
7
8
|
async function reject(state, candidate, code, stage, retryable, outcome, cause) {
|
|
8
9
|
throw await sourceFreezeError(state, candidate.snapshot.batchId, { code, stage, retryable, outcome }, cause);
|
|
9
10
|
}
|
|
@@ -20,7 +21,7 @@ function definitelyRejected(error) {
|
|
|
20
21
|
return /remote rejected|\[rejected\]|authentication failed|access denied|permission denied|not allowed|not support|hook declined|protected|returned error: (?:401|403)/i
|
|
21
22
|
.test(`${detail?.stderr ?? ''}\n${detail?.stdout ?? ''}`);
|
|
22
23
|
}
|
|
23
|
-
async function publishRemote(state, candidate, source) {
|
|
24
|
+
async function publishRemote(state, candidate, source, publication) {
|
|
24
25
|
const { snapshot, repositoryUrl: remote } = candidate;
|
|
25
26
|
const cache = sourceCacheDir(state, snapshot.batchId);
|
|
26
27
|
if (!remote || !snapshot.remoteRef)
|
|
@@ -29,10 +30,20 @@ async function publishRemote(state, candidate, source) {
|
|
|
29
30
|
await mkdir(cache, { recursive: true, mode: 0o700 });
|
|
30
31
|
if (!await lstat(join(cache, '.git')).catch(() => undefined))
|
|
31
32
|
await gitWrite(cache, ['init', '--initial-branch', 'source']);
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
if (publication)
|
|
34
|
+
await clearCredentialHelpers(cache);
|
|
35
|
+
else
|
|
36
|
+
await copyCredentialHelpers(source, cache);
|
|
37
|
+
const options = publication ? publicationGitOptions(state, publication) : undefined;
|
|
38
|
+
const remoteGit = (args) => {
|
|
39
|
+
const timeout = options ? Math.min(90_000, options.deadline - Date.now()) : 90_000;
|
|
40
|
+
if (timeout <= 0)
|
|
41
|
+
throw new Error('workspace_git_access_expired');
|
|
42
|
+
return gitWrite(cache, args, options ? { env: options.env, timeout } : undefined);
|
|
43
|
+
};
|
|
44
|
+
// For trusted publication, all remote commands use only ephemeral, scoped credentials.
|
|
34
45
|
const remoteHash = async () => {
|
|
35
|
-
const output = await
|
|
46
|
+
const output = await remoteGit(['ls-remote', '--refs', remote, snapshot.remoteRef]);
|
|
36
47
|
const rows = output.trim().split('\n').filter(Boolean).map(line => line.split(/\s+/));
|
|
37
48
|
if (rows.some(([hash, ref]) => !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(hash) || ref !== snapshot.remoteRef) || rows.length > 1) {
|
|
38
49
|
throw new Error('batch_source_remote_response_invalid');
|
|
@@ -44,7 +55,7 @@ async function publishRemote(state, candidate, source) {
|
|
|
44
55
|
found = await remoteHash();
|
|
45
56
|
}
|
|
46
57
|
catch (error) {
|
|
47
|
-
return reject(state, candidate, 'batch_source_verification_failed', 'verify', true, 'unknown', error);
|
|
58
|
+
return reject(state, candidate, 'batch_source_verification_failed', 'verify', true, 'unknown', publication ? undefined : error);
|
|
48
59
|
}
|
|
49
60
|
if (found && found !== snapshot.commitHash)
|
|
50
61
|
return reject(state, candidate, 'batch_source_ref_conflict', 'verify', false, 'unknown');
|
|
@@ -53,7 +64,7 @@ async function publishRemote(state, candidate, source) {
|
|
|
53
64
|
return reject(state, candidate, 'batch_source_candidate_lost', 'capture', false, 'not_published');
|
|
54
65
|
let pushError;
|
|
55
66
|
try {
|
|
56
|
-
await
|
|
67
|
+
await remoteGit(['push', '--porcelain', remote, `${snapshot.commitHash}:${snapshot.remoteRef}`]);
|
|
57
68
|
}
|
|
58
69
|
catch (error) {
|
|
59
70
|
pushError = error;
|
|
@@ -63,25 +74,25 @@ async function publishRemote(state, candidate, source) {
|
|
|
63
74
|
}
|
|
64
75
|
catch (error) {
|
|
65
76
|
const unsupported = pushError !== undefined && pushFailureCode(pushError) === 'batch_source_ref_unsupported';
|
|
66
|
-
return reject(state, candidate, pushError ? pushFailureCode(pushError) : 'batch_source_verification_failed', pushError ? 'publish' : 'verify', !unsupported, unsupported ? 'not_published' : 'unknown', pushError ?? error);
|
|
77
|
+
return reject(state, candidate, pushError ? pushFailureCode(pushError) : 'batch_source_verification_failed', pushError ? 'publish' : 'verify', !unsupported, unsupported ? 'not_published' : 'unknown', publication ? undefined : pushError ?? error);
|
|
67
78
|
}
|
|
68
79
|
if (found && found !== snapshot.commitHash)
|
|
69
|
-
return reject(state, candidate, 'batch_source_ref_conflict', 'verify', false, 'unknown', pushError);
|
|
80
|
+
return reject(state, candidate, 'batch_source_ref_conflict', 'verify', false, 'unknown', publication ? undefined : pushError);
|
|
70
81
|
if (!found) {
|
|
71
82
|
const rejected = pushError !== undefined && definitelyRejected(pushError);
|
|
72
|
-
return reject(state, candidate, pushError ? pushFailureCode(pushError) : 'batch_source_verification_failed', pushError ? 'publish' : 'verify', !rejected, rejected ? 'not_published' : 'unknown', pushError);
|
|
83
|
+
return reject(state, candidate, pushError ? pushFailureCode(pushError) : 'batch_source_verification_failed', pushError ? 'publish' : 'verify', !rejected, rejected ? 'not_published' : 'unknown', publication ? undefined : pushError);
|
|
73
84
|
}
|
|
74
85
|
}
|
|
75
86
|
if (!await verifyCache(cache, candidate)) {
|
|
76
87
|
try {
|
|
77
|
-
await
|
|
88
|
+
await remoteGit(['fetch', '--no-tags', remote, snapshot.remoteRef]);
|
|
78
89
|
if ((await gitRead(cache, ['rev-parse', 'FETCH_HEAD^{commit}'])).trim() !== snapshot.commitHash) {
|
|
79
90
|
return reject(state, candidate, 'batch_source_snapshot_mismatch', 'verify', false, 'unknown');
|
|
80
91
|
}
|
|
81
92
|
await gitWrite(cache, ['checkout', '--detach', snapshot.commitHash]);
|
|
82
93
|
}
|
|
83
94
|
catch (error) {
|
|
84
|
-
return reject(state, candidate, 'batch_source_verification_failed', 'verify', true, 'unknown', error);
|
|
95
|
+
return reject(state, candidate, 'batch_source_verification_failed', 'verify', true, 'unknown', publication ? undefined : error);
|
|
85
96
|
}
|
|
86
97
|
if (!await verifyCache(cache, candidate))
|
|
87
98
|
return reject(state, candidate, 'batch_source_snapshot_mismatch', 'verify', false, 'unknown');
|
|
@@ -118,16 +129,16 @@ async function publishArtifact(state, candidate, store) {
|
|
|
118
129
|
// private
|
|
119
130
|
await writeFile(`${cache}.digest`, snapshot.digest, { mode: 0o600 });
|
|
120
131
|
}
|
|
121
|
-
export async function publishSourceCandidate(state, candidate, source, store) {
|
|
132
|
+
export async function publishSourceCandidate(state, candidate, source, store, publication) {
|
|
122
133
|
try {
|
|
123
134
|
if (candidate.persist === 'remote_ref')
|
|
124
|
-
await publishRemote(state, candidate, source);
|
|
135
|
+
await publishRemote(state, candidate, source, publication);
|
|
125
136
|
else
|
|
126
137
|
await publishArtifact(state, candidate, store);
|
|
127
138
|
}
|
|
128
139
|
catch (error) {
|
|
129
140
|
if (error instanceof BatchSourceFreezeError)
|
|
130
141
|
throw error;
|
|
131
|
-
throw await sourceFreezeError(state, candidate.snapshot.batchId, { code: 'batch_source_verification_failed', stage: 'verify', retryable: true, outcome: 'unknown' }, error);
|
|
142
|
+
throw await sourceFreezeError(state, candidate.snapshot.batchId, { code: 'batch_source_verification_failed', stage: 'verify', retryable: true, outcome: 'unknown' }, publication ? undefined : error);
|
|
132
143
|
}
|
|
133
144
|
}
|
|
@@ -14,7 +14,7 @@ import { parentPort } from 'node:worker_threads';
|
|
|
14
14
|
import Database from 'better-sqlite3-pinned';
|
|
15
15
|
import { clearPendingNativeFollowups, learnNativeSubagents, nativeEventOwner, nativeSubagentThreads } from './native-subagents.js';
|
|
16
16
|
import { settleStoppedExecution, stoppedExecutionEpochs } from './stopped-execution.js';
|
|
17
|
-
import { hashOperationPayload, operationRecordSchema, pendingSourceEventSchema, terminalRecordSchema, workspaceRegistrationSchema, transitionOperation, canTransitionTurn, } from '@playcraft/common/cloud-workspace/runtime';
|
|
17
|
+
import { hashOperationPayload, nativeTurnFailureMessage, operationRecordSchema, pendingSourceEventSchema, terminalRecordSchema, workspaceRegistrationSchema, transitionOperation, canTransitionTurn, } from '@playcraft/common/cloud-workspace/runtime';
|
|
18
18
|
const SCHEMA_VERSION = 1;
|
|
19
19
|
const MIN_SQLITE = [3, 51, 3];
|
|
20
20
|
const BINDING = 'better-sqlite3@13.0.3';
|
|
@@ -726,6 +726,7 @@ function applyNativeToState(database, input, payload) {
|
|
|
726
726
|
putOperation(database, input.operation.scopeKey, transitionOperation(ownedOperation, terminal, {
|
|
727
727
|
nativeTurnId: input.turnId ?? ownedOperation.nativeTurnId,
|
|
728
728
|
nativeThreadId: input.threadId ?? ownedOperation.nativeThreadId,
|
|
729
|
+
error: terminal === 'failed' ? nativeTurnFailureMessage(payload) : undefined,
|
|
729
730
|
}));
|
|
730
731
|
}
|
|
731
732
|
}
|
|
@@ -25,6 +25,7 @@ import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.j
|
|
|
25
25
|
import { workspaceContextFromRoot, workspaceRoot } from './workspaces/registry.js';
|
|
26
26
|
import { recoverInterruptedLibraryImports } from './workspaces/asset-library-import.js';
|
|
27
27
|
import { recoverAssetTransfers } from './workspaces/asset-transfer-publish.js';
|
|
28
|
+
import { readWorkspaceAssetProvenance, WORKSPACE_ASSET_PROVENANCE_PATH } from '../asset-library/provenance.js';
|
|
28
29
|
import { recoverWorkspaceDirectoryMutation } from './workspaces/directory-mutations.js';
|
|
29
30
|
import { observeWorkspaceDirectories } from './workspaces/directory-revisions.js';
|
|
30
31
|
import { observeWorkspaceBinaryFile } from './workspaces/binary-write.js';
|
|
@@ -116,6 +117,54 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
116
117
|
}
|
|
117
118
|
else
|
|
118
119
|
await recoverLibraryImports(config.workspaceRoot);
|
|
120
|
+
// Model-library imports share the existing multi-file transfer journal. Recover it before settling either operation kind.
|
|
121
|
+
const recoveredBundles = new Map();
|
|
122
|
+
const transferScopes = new Set();
|
|
123
|
+
for (const { operation } of journal.listOperations()) {
|
|
124
|
+
if (!['asset.bundle.save', 'asset.library.import'].includes(operation.kind))
|
|
125
|
+
continue;
|
|
126
|
+
const directoryName = operation.directoryName ?? 'basic';
|
|
127
|
+
const key = `${operation.workspaceId ?? ''}:${directoryName}`;
|
|
128
|
+
if (transferScopes.has(key))
|
|
129
|
+
continue;
|
|
130
|
+
transferScopes.add(key);
|
|
131
|
+
const root = await workspaceRoot(state, operation.workspaceId, directoryName).catch(() => undefined);
|
|
132
|
+
if (!root)
|
|
133
|
+
continue;
|
|
134
|
+
const recovered = await recoverAssetTransfers(root, { environment: config.environment, userId: config.userId,
|
|
135
|
+
workspaceId: operation.workspaceId ?? '', directoryName }).catch(() => []);
|
|
136
|
+
for (const item of recovered)
|
|
137
|
+
recoveredBundles.set(item.operationId, item);
|
|
138
|
+
}
|
|
139
|
+
for (const { operation } of journal.listOperations()) {
|
|
140
|
+
if (operation.kind !== 'asset.library.import' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
141
|
+
continue;
|
|
142
|
+
const recovered = recoveredBundles.get(operation.operationId);
|
|
143
|
+
if (!recovered?.receipt.files.some(file => file.path === WORKSPACE_ASSET_PROVENANCE_PATH))
|
|
144
|
+
continue;
|
|
145
|
+
const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName).catch(() => undefined);
|
|
146
|
+
if (!root)
|
|
147
|
+
continue;
|
|
148
|
+
const sources = await readWorkspaceAssetProvenance(root).catch(() => undefined);
|
|
149
|
+
if (!sources)
|
|
150
|
+
continue;
|
|
151
|
+
const files = new Map(recovered.receipt.files.map(file => [file.path, file.contentHash]));
|
|
152
|
+
const models = sources.assets.filter(asset => files.get(asset.path) === asset.provenance.modelSha256);
|
|
153
|
+
if (!models.length || models.length !== recovered.receipt.files.filter(file => file.path.toLowerCase().endsWith('.glb')).length)
|
|
154
|
+
continue;
|
|
155
|
+
let unchanged = true;
|
|
156
|
+
for (const file of recovered.receipt.files) {
|
|
157
|
+
if ((await observeWorkspaceBinaryFile(root, file.path).catch(() => undefined))?.contentHash !== file.contentHash) {
|
|
158
|
+
unchanged = false;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!unchanged)
|
|
163
|
+
continue;
|
|
164
|
+
const previous = recoveredImports.get(operation.operationId) ?? { paths: [], assetIds: [] };
|
|
165
|
+
recoveredImports.set(operation.operationId, { paths: [...new Set([...previous.paths, ...files.keys()])],
|
|
166
|
+
assetIds: [...new Set([...previous.assetIds, ...models.map(model => model.provenance.assetId)])], receipt: recovered.receipt });
|
|
167
|
+
}
|
|
119
168
|
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
120
169
|
if (operation.kind !== 'asset.library.import' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
121
170
|
continue;
|
|
@@ -125,6 +174,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
125
174
|
const result = operation.result && 'paths' in operation.result ? { ...operation.result,
|
|
126
175
|
paths: [...new Set([...operation.result.paths, ...(published?.paths ?? [])])],
|
|
127
176
|
failures: operation.result.failures.filter(failure => !published?.assetIds.includes(failure.assetId)),
|
|
177
|
+
...(published?.receipt ? { bundleReceipt: published.receipt } : {}),
|
|
128
178
|
} : undefined;
|
|
129
179
|
const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName).catch(() => undefined);
|
|
130
180
|
if (root && result)
|
|
@@ -151,24 +201,6 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
151
201
|
recoveredChanges.push({ workspaceId: operation.workspaceId,
|
|
152
202
|
directoryName: operation.directoryName ?? 'basic', paths: result.paths });
|
|
153
203
|
}
|
|
154
|
-
const recoveredBundles = new Map();
|
|
155
|
-
const transferScopes = new Set();
|
|
156
|
-
for (const { operation } of journal.listOperations()) {
|
|
157
|
-
if (operation.kind !== 'asset.bundle.save')
|
|
158
|
-
continue;
|
|
159
|
-
const directoryName = operation.directoryName ?? 'basic';
|
|
160
|
-
const key = `${operation.workspaceId ?? ''}:${directoryName}`;
|
|
161
|
-
if (transferScopes.has(key))
|
|
162
|
-
continue;
|
|
163
|
-
transferScopes.add(key);
|
|
164
|
-
const root = await workspaceRoot(state, operation.workspaceId, directoryName).catch(() => undefined);
|
|
165
|
-
if (!root)
|
|
166
|
-
continue;
|
|
167
|
-
const recovered = await recoverAssetTransfers(root, { environment: config.environment, userId: config.userId,
|
|
168
|
-
workspaceId: operation.workspaceId ?? '', directoryName }).catch(() => []);
|
|
169
|
-
for (const item of recovered)
|
|
170
|
-
recoveredBundles.set(item.operationId, item);
|
|
171
|
-
}
|
|
172
204
|
for (const { scopeKey, operation } of journal.listOperations()) {
|
|
173
205
|
if (operation.kind !== 'asset.bundle.save' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
|
|
174
206
|
continue;
|
|
@@ -5,6 +5,7 @@ import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions,
|
|
|
5
5
|
import { preflightSkillExecution } from '../workspaces/skill-execution-context.js';
|
|
6
6
|
import { abandonTurnChanges, beginTurnChanges, finishTurnChanges, } from '../workspaces/turn-changes.js';
|
|
7
7
|
import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
8
|
+
import { remoteAttachPending } from '../workspaces/remote-attach-marker.js';
|
|
8
9
|
import { cancelPendingDirectoryAccess, holdDirectoryAccess, withOperationDirectoryAccess, } from './directory-access.js';
|
|
9
10
|
import { join } from 'node:path';
|
|
10
11
|
import { z } from 'zod';
|
|
@@ -1127,8 +1128,11 @@ async function executeAssetLibraryImport(state, scopeKey, operation, payload) {
|
|
|
1127
1128
|
await assertCurrent();
|
|
1128
1129
|
const result = await importWorkspaceLibraryAssets(root, input?.selection, input?.snapshot, assertCurrent, {
|
|
1129
1130
|
operationId: operation.operationId,
|
|
1131
|
+
scope: { environment: state.config.environment, userId: state.config.userId,
|
|
1132
|
+
workspaceId: operation.workspaceId ?? '', directoryName: operation.directoryName ?? 'basic' },
|
|
1130
1133
|
onProgress: async (result) => { await state.journal.putOperation(scopeKey, { ...operation, result }); },
|
|
1131
1134
|
});
|
|
1135
|
+
await assertCurrent();
|
|
1132
1136
|
const scope = revisionScope(state, operation.workspaceId, operation.directoryName);
|
|
1133
1137
|
for (const path of result.paths) {
|
|
1134
1138
|
const file = await observeWorkspaceBinaryFile(root, path);
|
|
@@ -1146,10 +1150,14 @@ async function executeAssetLibraryImport(state, scopeKey, operation, payload) {
|
|
|
1146
1150
|
kind: 'discovered', tool: 'asset.library.import', operationId: operation.operationId,
|
|
1147
1151
|
} }).catch(() => undefined);
|
|
1148
1152
|
}
|
|
1149
|
-
|
|
1153
|
+
await assertCurrent();
|
|
1154
|
+
return persist(state, scopeKey, transitionOperation(accepted, result.failures.length ? 'failed' : 'completed', {
|
|
1155
|
+
result, ...(result.failures.length ? { error: 'asset_library_import_failed' } : {}),
|
|
1156
|
+
}));
|
|
1150
1157
|
}
|
|
1151
1158
|
catch (error) {
|
|
1152
|
-
|
|
1159
|
+
const current = state.journal.getOperation(scopeKey) ?? operation;
|
|
1160
|
+
return persist(state, scopeKey, transitionOperation(current, 'failed', {
|
|
1153
1161
|
error: error instanceof Error && ['stale_lease', 'batch_workspace_readonly'].includes(error.message)
|
|
1154
1162
|
? error.message : 'invalid_library_import',
|
|
1155
1163
|
}));
|
|
@@ -1383,6 +1391,17 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
|
|
|
1383
1391
|
* accepts it. A standalone workspace has no remote, so its saves stay local; its project
|
|
1384
1392
|
* directories have their own branch.
|
|
1385
1393
|
*/
|
|
1394
|
+
async function withBasicBindingLock(state, operation, action) {
|
|
1395
|
+
if (state.config.workspaceMode !== 'multi' || !operation.workspaceId || (operation.directoryName ?? 'basic') !== 'basic')
|
|
1396
|
+
return action();
|
|
1397
|
+
return state.journal.exclusive(`workspace:${operation.workspaceId}`, action);
|
|
1398
|
+
}
|
|
1399
|
+
/** A published batch may have adopted project metadata while basic still has a local checkout. */
|
|
1400
|
+
async function basicRemotePending(state, operation) {
|
|
1401
|
+
if (state.config.workspaceMode !== 'multi' || !operation.workspaceId || (operation.directoryName ?? 'basic') !== 'basic')
|
|
1402
|
+
return false;
|
|
1403
|
+
return remoteAttachPending(state, operation.workspaceId);
|
|
1404
|
+
}
|
|
1386
1405
|
export function savePushesBranch(targetKind, directoryName) {
|
|
1387
1406
|
return targetKind === 'main' || targetKind === 'remix' || Boolean(directoryName && directoryName !== 'basic');
|
|
1388
1407
|
}
|
|
@@ -1391,13 +1410,19 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
|
1391
1410
|
if (!parsed.success) {
|
|
1392
1411
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace_save' }));
|
|
1393
1412
|
}
|
|
1394
|
-
const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
|
|
1395
1413
|
try {
|
|
1396
1414
|
// Saves share the directory, and a deferred one runs outside the workspace queue; commit one at a time.
|
|
1397
|
-
const saved = await state.journal.exclusive(`workspace-save:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`,
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1415
|
+
const saved = await state.journal.exclusive(`workspace-save:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, () => withBasicBindingLock(state, operation, async () => {
|
|
1416
|
+
const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
|
|
1417
|
+
const localOnly = await basicRemotePending(state, operation);
|
|
1418
|
+
const saved = await saveWorkspaceGit(await gitWorkspaceRoot(state, operation), {
|
|
1419
|
+
message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
|
|
1420
|
+
push: !localOnly && savePushesBranch(binding?.targetKind, operation.directoryName),
|
|
1421
|
+
boundBranch: localOnly ? undefined : boundBranch(state, operation),
|
|
1422
|
+
});
|
|
1423
|
+
if (localOnly)
|
|
1424
|
+
throw new GitWorkspaceError('remote_not_configured', 'origin');
|
|
1425
|
+
return saved;
|
|
1401
1426
|
}));
|
|
1402
1427
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
1403
1428
|
await recordNativeEvents(state, accepted, [
|
|
@@ -1419,7 +1444,11 @@ async function executeGitPush(state, scopeKey, operation, payload) {
|
|
|
1419
1444
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
|
|
1420
1445
|
}
|
|
1421
1446
|
try {
|
|
1422
|
-
await
|
|
1447
|
+
await withBasicBindingLock(state, operation, async () => {
|
|
1448
|
+
if (await basicRemotePending(state, operation))
|
|
1449
|
+
throw new GitWorkspaceError('remote_not_configured', 'origin');
|
|
1450
|
+
await pushWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
|
|
1451
|
+
});
|
|
1423
1452
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
1424
1453
|
await recordNativeEvents(state, accepted, [
|
|
1425
1454
|
{
|
|
@@ -1440,7 +1469,11 @@ async function executeGitPull(state, scopeKey, operation, payload) {
|
|
|
1440
1469
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
|
|
1441
1470
|
}
|
|
1442
1471
|
try {
|
|
1443
|
-
const pulled = await
|
|
1472
|
+
const pulled = await withBasicBindingLock(state, operation, async () => {
|
|
1473
|
+
if (await basicRemotePending(state, operation))
|
|
1474
|
+
throw new GitWorkspaceError('remote_not_configured', 'origin');
|
|
1475
|
+
return pullWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
|
|
1476
|
+
});
|
|
1444
1477
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
1445
1478
|
await recordNativeEvents(state, accepted, [{
|
|
1446
1479
|
type: 'git.pulled',
|
|
@@ -44,6 +44,7 @@ import { acceptOperationCore, observeLevelEditorTransaction } from './accept-ope
|
|
|
44
44
|
import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
45
45
|
import { searchWorkspaceFiles } from '../workspaces/files-search.js';
|
|
46
46
|
import { serveWorkspaceArchive } from '../workspaces/archive.js';
|
|
47
|
+
import { verifyRuntimeAssetProvenance } from '../workspaces/asset-provenance-verifier.js';
|
|
47
48
|
import { handleWorkspaceTransfer } from './workspace-transfer.js';
|
|
48
49
|
import { handleWorkspaceStorage } from './workspace-storage.js';
|
|
49
50
|
import { serveSessionImage } from '../workspaces/session-image.js';
|
|
@@ -199,6 +200,8 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
199
200
|
levelEditorRetryVersion: 1,
|
|
200
201
|
remixReferencesVersion: 1,
|
|
201
202
|
batchSourceFreezeRevision: 2,
|
|
203
|
+
batchSourcePublicationVersion: 1,
|
|
204
|
+
batchReferenceContextVersion: 1,
|
|
202
205
|
...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
|
|
203
206
|
...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
|
|
204
207
|
...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
|
|
@@ -685,8 +688,16 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
685
688
|
if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/archive') {
|
|
686
689
|
if (writeDestroyed(state, response))
|
|
687
690
|
return;
|
|
688
|
-
const
|
|
689
|
-
await
|
|
691
|
+
const archiveWorkspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
692
|
+
const root = await sessionWorkspaceRoot(state, archiveWorkspaceId, directoryName);
|
|
693
|
+
const expectedModelPaths = state.journal.listOperations().map(({ operation }) => operation).filter(operation => operation.kind === 'asset.library.import'
|
|
694
|
+
&& (state.config.workspaceMode !== 'multi' || operation.workspaceId === archiveWorkspaceId)
|
|
695
|
+
&& (operation.directoryName ?? 'basic') === directoryName)
|
|
696
|
+
.flatMap(operation => operation.result && 'bundleReceipt' in operation.result ? operation.result.bundleReceipt?.files ?? [] : [])
|
|
697
|
+
.map(file => file.path).filter(path => path.toLowerCase().endsWith('.glb'));
|
|
698
|
+
await serveWorkspaceArchive(root, request, response, { excludedRoots: [state.config.stateDir],
|
|
699
|
+
expectedModelPaths: [...new Set(expectedModelPaths)],
|
|
700
|
+
verifyProvenance: provenance => verifyRuntimeAssetProvenance(state, provenance) });
|
|
690
701
|
return;
|
|
691
702
|
}
|
|
692
703
|
if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/file-content') {
|
|
@@ -47,6 +47,8 @@ export async function verifyRuntimeArtifact(expectedVersion) {
|
|
|
47
47
|
const identity = await response.json();
|
|
48
48
|
assert.equal(identity.runtimeId, config.runtimeId);
|
|
49
49
|
assert.equal(identity.batchSourceFreezeRevision, 2, 'built runtime lacks the required batch source freeze implementation');
|
|
50
|
+
assert.equal(identity.batchSourcePublicationVersion, 1, 'built runtime lacks trusted batch source publication');
|
|
51
|
+
assert.equal(identity.batchReferenceContextVersion, 1, 'built runtime lacks batch reference context support');
|
|
50
52
|
}
|
|
51
53
|
finally {
|
|
52
54
|
await runtime?.close();
|
|
@@ -55,5 +57,5 @@ export async function verifyRuntimeArtifact(expectedVersion) {
|
|
|
55
57
|
}
|
|
56
58
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
57
59
|
await verifyRuntimeArtifact(process.argv[2] ?? '');
|
|
58
|
-
console.log('Runtime artifact verified: batchSourceFreezeRevision=2');
|
|
60
|
+
console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1, batchReferenceContextVersion=1');
|
|
59
61
|
}
|