@playcraft/cli 0.1.68 → 0.1.69

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.
Files changed (26) hide show
  1. package/dist/asset-library/provenance.js +169 -0
  2. package/dist/commands/library-messages.js +27 -9
  3. package/dist/commands/library.js +62 -10
  4. package/dist/model-adaptation/project-template.js +56 -3
  5. package/dist/model-adaptation/project.js +15 -1
  6. package/dist/workspace-runtime/batch/freeze.js +15 -2
  7. package/dist/workspace-runtime/batch/git.js +16 -4
  8. package/dist/workspace-runtime/batch/publication.js +96 -0
  9. package/dist/workspace-runtime/batch/source-candidate.js +2 -1
  10. package/dist/workspace-runtime/batch/source-publish.js +26 -15
  11. package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -1
  12. package/dist/workspace-runtime/serve.js +50 -18
  13. package/dist/workspace-runtime/server/dispatch.js +42 -9
  14. package/dist/workspace-runtime/server/http.js +12 -2
  15. package/dist/workspace-runtime/verify-artifact.js +2 -1
  16. package/dist/workspace-runtime/workspaces/archive.js +28 -3
  17. package/dist/workspace-runtime/workspaces/asset-library-import.js +33 -2
  18. package/dist/workspace-runtime/workspaces/asset-provenance-verifier.js +36 -0
  19. package/dist/workspace-runtime/workspaces/registry.js +3 -5
  20. package/dist/workspace-runtime/workspaces/remote-attach-marker.js +55 -0
  21. package/package.json +3 -3
  22. package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  23. package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  24. package/project-template-v2/.playcraft/skills.lock.json +2 -2
  25. package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  26. package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
@@ -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.journal.getWorkspace(request.workspaceId);
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
@@ -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
- await copyCredentialHelpers(source, cache);
33
- // All remote operations share the copied authentication, including read-only verification.
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 gitWrite(cache, ['ls-remote', '--refs', remote, snapshot.remoteRef]);
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 gitWrite(cache, ['push', '--porcelain', remote, `${snapshot.commitHash}:${snapshot.remoteRef}`]);
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 gitWrite(cache, ['fetch', '--no-tags', remote, snapshot.remoteRef]);
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
- return persist(state, scopeKey, transitionOperation(accepted, 'completed', { result }));
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
- return persist(state, scopeKey, transitionOperation(operation, 'failed', {
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'}`, async () => saveWorkspaceGit(await gitWorkspaceRoot(state, operation), {
1398
- message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
1399
- push: savePushesBranch(binding?.targetKind, operation.directoryName),
1400
- boundBranch: boundBranch(state, operation),
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 pushWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
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 pullWorkspaceGit(await gitWorkspaceRoot(state, operation), boundBranch(state, operation));
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,7 @@ async function dispatchRuntimeRequest(state, request, response) {
199
200
  levelEditorRetryVersion: 1,
200
201
  remixReferencesVersion: 1,
201
202
  batchSourceFreezeRevision: 2,
203
+ batchSourcePublicationVersion: 1,
202
204
  ...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
203
205
  ...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
204
206
  ...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
@@ -685,8 +687,16 @@ async function dispatchRuntimeRequest(state, request, response) {
685
687
  if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/archive') {
686
688
  if (writeDestroyed(state, response))
687
689
  return;
688
- const root = await sessionWorkspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName);
689
- await serveWorkspaceArchive(root, request, response, { excludedRoots: [state.config.stateDir] });
690
+ const archiveWorkspaceId = url.searchParams.get('workspaceId') ?? undefined;
691
+ const root = await sessionWorkspaceRoot(state, archiveWorkspaceId, directoryName);
692
+ const expectedModelPaths = state.journal.listOperations().map(({ operation }) => operation).filter(operation => operation.kind === 'asset.library.import'
693
+ && (state.config.workspaceMode !== 'multi' || operation.workspaceId === archiveWorkspaceId)
694
+ && (operation.directoryName ?? 'basic') === directoryName)
695
+ .flatMap(operation => operation.result && 'bundleReceipt' in operation.result ? operation.result.bundleReceipt?.files ?? [] : [])
696
+ .map(file => file.path).filter(path => path.toLowerCase().endsWith('.glb'));
697
+ await serveWorkspaceArchive(root, request, response, { excludedRoots: [state.config.stateDir],
698
+ expectedModelPaths: [...new Set(expectedModelPaths)],
699
+ verifyProvenance: provenance => verifyRuntimeAssetProvenance(state, provenance) });
690
700
  return;
691
701
  }
692
702
  if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/file-content') {
@@ -47,6 +47,7 @@ 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');
50
51
  }
51
52
  finally {
52
53
  await runtime?.close();
@@ -55,5 +56,5 @@ export async function verifyRuntimeArtifact(expectedVersion) {
55
56
  }
56
57
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
57
58
  await verifyRuntimeArtifact(process.argv[2] ?? '');
58
- console.log('Runtime artifact verified: batchSourceFreezeRevision=2');
59
+ console.log('Runtime artifact verified: batchSourceFreezeRevision=2, batchSourcePublicationVersion=1');
59
60
  }
@@ -1,13 +1,21 @@
1
1
  import { constants } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
2
3
  import { lstat, open, opendir, realpath } from 'node:fs/promises';
3
4
  import { join, relative, sep } from 'node:path';
4
5
  import { Readable, Transform } from 'node:stream';
5
6
  import { pipeline } from 'node:stream/promises';
6
7
  import { createDeflateRaw } from 'node:zlib';
7
8
  import { isCredentialFile } from './git.js';
8
- const EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.playcraft.json', '.playcraft-workspace.json']);
9
+ import { verifyWorkspaceAssetProvenance, WORKSPACE_ASSET_PROVENANCE_PATH } from '../../asset-library/provenance.js';
10
+ import { workspaceAssetProvenanceDocumentSchema } from '@playcraft/common/cloud-workspace/runtime';
11
+ const EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.playcraft-temp', '.playcraft.json', '.playcraft-workspace.json']);
9
12
  const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024;
10
13
  const MAX_ENTRIES = 50_000;
14
+ const PUBLIC_ARCHIVE_ERRORS = new Set(['workspace_archive_changed', 'workspace_archive_too_large', 'workspace_archive_invalid_path',
15
+ 'workspace_archive_failed', 'asset_provenance_authority_required', 'asset_provenance_authority_unavailable',
16
+ 'asset_provenance_authority_mismatch', 'asset_provenance_authority_response_bound', 'asset_provenance_model_changed',
17
+ 'asset_provenance_license_changed', 'asset_provenance_record_missing', 'asset_provenance_license_path_mismatch',
18
+ 'asset_provenance_too_large', 'asset_provenance_changed', 'asset_provenance_notice_conflict', 'asset_provenance_expected_path_invalid']);
11
19
  const UTF8_DESCRIPTOR_FLAGS = 0x0808;
12
20
  const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
13
21
  let crc = value;
@@ -18,6 +26,13 @@ const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
18
26
  /** Exports working files, including ignored deliverables, without following symbolic links. */
19
27
  export async function createWorkspaceArchive(root, options = {}) {
20
28
  const rootReal = await realpath(root);
29
+ const provenance = await verifyWorkspaceAssetProvenance(rootReal, { verify: options.verifyProvenance,
30
+ expectedModelPaths: options.expectedModelPaths });
31
+ const expectedHashes = new Map();
32
+ for (const asset of provenance?.assets ?? []) {
33
+ expectedHashes.set(asset.path, asset.provenance.modelSha256);
34
+ expectedHashes.set(asset.licensePath, createHash('sha256').update(asset.provenance.licenseText).digest('hex'));
35
+ }
21
36
  const excludedRoots = await Promise.all((options.excludedRoots ?? []).map(path => realpath(path).catch(() => path)));
22
37
  const entries = [];
23
38
  let bytes = 0;
@@ -52,7 +67,8 @@ export async function createWorkspaceArchive(root, options = {}) {
52
67
  bytes += info.size;
53
68
  if (bytes > (options.maxSourceBytes ?? MAX_SOURCE_BYTES) || entries.length >= (options.maxEntries ?? MAX_ENTRIES))
54
69
  throw new Error('workspace_archive_too_large');
55
- entries.push({ path, target, info });
70
+ entries.push({ path, target, info, expectedHash: expectedHashes.get(path),
71
+ ...(path === WORKSPACE_ASSET_PROVENANCE_PATH && provenance ? { expectedProvenance: JSON.stringify(provenance) } : {}) });
56
72
  }
57
73
  };
58
74
  await walk(rootReal);
@@ -89,6 +105,8 @@ async function* zipFiles(root, entries, signal) {
89
105
  let crc = 0xffffffff;
90
106
  let size = 0;
91
107
  let compressedSize = 0;
108
+ const hash = entry.expectedHash ? createHash('sha256') : undefined;
109
+ const sourceChunks = [];
92
110
  const checksum = new Transform({ transform(chunk, _encoding, done) {
93
111
  size += chunk.length;
94
112
  if (size > info.size) {
@@ -97,6 +115,9 @@ async function* zipFiles(root, entries, signal) {
97
115
  }
98
116
  for (const byte of chunk)
99
117
  crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
118
+ hash?.update(chunk);
119
+ if (entry.expectedProvenance)
120
+ sourceChunks.push(chunk);
100
121
  done(null, chunk);
101
122
  } });
102
123
  const compressed = createDeflateRaw();
@@ -118,6 +139,10 @@ async function* zipFiles(root, entries, signal) {
118
139
  }
119
140
  if (size !== info.size)
120
141
  throw new Error('workspace_archive_changed');
142
+ if (hash && hash.digest('hex') !== entry.expectedHash)
143
+ throw new Error('asset_provenance_model_changed');
144
+ if (entry.expectedProvenance && JSON.stringify(workspaceAssetProvenanceDocumentSchema.parse(JSON.parse(Buffer.concat(sourceChunks).toString('utf8')))) !== entry.expectedProvenance)
145
+ throw new Error('asset_provenance_changed');
121
146
  const after = await handle.stat();
122
147
  if (after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs)
123
148
  throw new Error('workspace_archive_changed');
@@ -188,7 +213,7 @@ export async function serveWorkspaceArchive(root, request, response, options = {
188
213
  else if (!controller.signal.aborted) {
189
214
  const reason = error instanceof Error ? error.message : 'workspace_archive_failed';
190
215
  response.writeHead(reason === 'workspace_archive_too_large' ? 413 : 409, { 'content-type': 'application/json', 'cache-control': 'private, no-store' });
191
- response.end(JSON.stringify({ error: reason.startsWith('workspace_archive_') ? reason : 'workspace_archive_failed' }));
216
+ response.end(JSON.stringify({ error: PUBLIC_ARCHIVE_ERRORS.has(reason) ? reason : 'workspace_archive_failed' }));
192
217
  }
193
218
  }
194
219
  finally {