@playcraft/cli 0.1.67 → 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 (45) hide show
  1. package/dist/asset-library/provenance.js +169 -0
  2. package/dist/commands/audio-program.js +82 -0
  3. package/dist/commands/audio.js +2 -0
  4. package/dist/commands/library-messages.js +27 -9
  5. package/dist/commands/library.js +62 -10
  6. package/dist/model-adaptation/project-template.js +56 -3
  7. package/dist/model-adaptation/project.js +15 -1
  8. package/dist/workspace-runtime/batch/freeze.js +15 -2
  9. package/dist/workspace-runtime/batch/git.js +16 -4
  10. package/dist/workspace-runtime/batch/publication.js +96 -0
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -1
  12. package/dist/workspace-runtime/batch/source-publish.js +26 -15
  13. package/dist/workspace-runtime/persistence/sqlite-worker.js +2 -1
  14. package/dist/workspace-runtime/serve.js +50 -18
  15. package/dist/workspace-runtime/server/audio-assets.js +49 -3
  16. package/dist/workspace-runtime/server/dispatch.js +42 -9
  17. package/dist/workspace-runtime/server/http.js +12 -2
  18. package/dist/workspace-runtime/verify-artifact.js +2 -1
  19. package/dist/workspace-runtime/workspaces/archive.js +28 -3
  20. package/dist/workspace-runtime/workspaces/asset-library-import.js +60 -9
  21. package/dist/workspace-runtime/workspaces/asset-provenance-verifier.js +36 -0
  22. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +6 -2
  23. package/dist/workspace-runtime/workspaces/audio-assets/library-import.js +149 -0
  24. package/dist/workspace-runtime/workspaces/audio-assets/library-package.js +26 -0
  25. package/dist/workspace-runtime/workspaces/audio-assets/program-artifact-audit.js +90 -0
  26. package/dist/workspace-runtime/workspaces/audio-assets/program-browser.js +194 -0
  27. package/dist/workspace-runtime/workspaces/audio-assets/program-build-inputs.js +122 -0
  28. package/dist/workspace-runtime/workspaces/audio-assets/program-engine.js +39 -0
  29. package/dist/workspace-runtime/workspaces/audio-assets/program-package.js +289 -0
  30. package/dist/workspace-runtime/workspaces/audio-assets/program-variant-build.js +217 -0
  31. package/dist/workspace-runtime/workspaces/audio-assets/references.js +107 -0
  32. package/dist/workspace-runtime/workspaces/audio-assets/service.js +133 -1
  33. package/dist/workspace-runtime/workspaces/git.js +7 -0
  34. package/dist/workspace-runtime/workspaces/registry.js +3 -5
  35. package/dist/workspace-runtime/workspaces/remote-attach-marker.js +55 -0
  36. package/package.json +6 -3
  37. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  38. package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  39. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  40. package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  41. package/project-template-v2/.playcraft/skills.lock.json +4 -4
  42. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  43. package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
  44. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
  45. package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
@@ -10,6 +10,7 @@ import { assertSourceIdentity, readSourceCandidate, writeSourceCandidate } from
10
10
  import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
11
11
  import { publishSourceCandidate } from './source-publish.js';
12
12
  import { clearDirectoryFreeze, withDirectoryFreeze } from '../server/directory-access.js';
13
+ import { adoptPublicationBinding, publicationBinding, publicationGitOptions } from './publication.js';
13
14
  async function usagePercent(path) {
14
15
  const disk = await statfs(path);
15
16
  if (disk.blocks <= 0)
@@ -32,9 +33,12 @@ export async function freezeBatchSource(state, body, options) {
32
33
  if (!parsed.success)
33
34
  throw new Error('invalid_batch_request');
34
35
  const request = parsed.data;
36
+ const leaseFence = state.acceptedLeaseFence;
35
37
  const frozen = await readSnapshot(state, request.batchId);
36
38
  if (frozen) {
37
39
  assertSourceIdentity(state, request, frozen, await readSourceCandidate(state, request.batchId));
40
+ if (request.publication)
41
+ await adoptPublicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication, leaseFence);
38
42
  await clearDirectoryFreeze(state, request.batchId);
39
43
  return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: frozen });
40
44
  }
@@ -47,6 +51,8 @@ export async function freezeBatchSource(state, body, options) {
47
51
  const existing = await readSnapshot(state, request.batchId);
48
52
  if (existing) {
49
53
  assertSourceIdentity(state, request, existing, pending);
54
+ if (request.publication)
55
+ await adoptPublicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication, leaseFence);
50
56
  return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: existing });
51
57
  }
52
58
  if (!pending && 'resumeOnly' in request && request.resumeOnly === true) {
@@ -55,6 +61,12 @@ export async function freezeBatchSource(state, body, options) {
55
61
  if (pending)
56
62
  assertSourceIdentity(state, request, pending.snapshot, pending);
57
63
  source = await workspaceRoot(state, request.workspaceId, request.sourceDirectoryName);
64
+ const binding = publicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication);
65
+ if (request.publication) {
66
+ if (request.persist !== 'remote_ref')
67
+ throw new Error('batch_source_identity_mismatch');
68
+ publicationGitOptions(state, request.publication);
69
+ }
58
70
  if (pending)
59
71
  candidate = pending;
60
72
  else {
@@ -67,7 +79,6 @@ export async function freezeBatchSource(state, body, options) {
67
79
  const captured = await captureIndependentSource(source, cache);
68
80
  // private
69
81
  await writeFile(`${cache}.digest`, captured.digest, { mode: 0o600 });
70
- const binding = state.journal.getWorkspace(request.workspaceId);
71
82
  const snapshot = {
72
83
  batchId: request.batchId, commitHash: captured.commitHash, digest: captured.digest,
73
84
  sourceWorkspaceId: request.workspaceId, sourceDirectoryName: request.sourceDirectoryName,
@@ -95,7 +106,9 @@ export async function freezeBatchSource(state, body, options) {
95
106
  outcome: code === 'batch_source_identity_mismatch' || code === 'batch_source_snapshot_mismatch' ? 'unknown' : 'not_published',
96
107
  }, error);
97
108
  }
98
- await publishSourceCandidate(state, candidate, source, options?.objectStore ?? filesystemObjectStore(state));
109
+ await publishSourceCandidate(state, candidate, source, options?.objectStore ?? filesystemObjectStore(state), request.publication);
110
+ if (request.publication)
111
+ await adoptPublicationBinding(state, request.workspaceId, request.sourceDirectoryName, request.publication, leaseFence);
99
112
  try {
100
113
  await writeSnapshot(state, candidate.snapshot);
101
114
  }
@@ -7,17 +7,18 @@ export async function gitRead(root, args) {
7
7
  return runGit(root, ['-c', 'credential.helper=', ...args]);
8
8
  }
9
9
  /** Push and fetch. Local credential helpers copied onto this repo stay in effect. */
10
- export async function gitWrite(root, args) {
11
- return runGit(root, args);
10
+ export async function gitWrite(root, args, options) {
11
+ return runGit(root, args, options);
12
12
  }
13
- async function runGit(root, args) {
13
+ async function runGit(root, args, options) {
14
14
  const { stdout } = await execute('git', [
15
15
  '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=', '-c', 'core.fsmonitor=false', '-C', root, ...args,
16
16
  ], {
17
- timeout: 90_000, maxBuffer: 64 * 1024 * 1024,
17
+ timeout: options?.timeout ?? 90_000, maxBuffer: 64 * 1024 * 1024,
18
18
  env: {
19
19
  PATH: process.env.PATH, HOME: dirname(root), GIT_OPTIONAL_LOCKS: '0',
20
20
  GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0',
21
+ ...options?.env,
21
22
  },
22
23
  });
23
24
  return stdout;
@@ -38,3 +39,14 @@ export async function copyCredentialHelpers(sourceRoot, destinationRoot) {
38
39
  await gitWrite(destinationRoot, ['config', '--local', key, value]);
39
40
  }
40
41
  }
42
+ /** Remove helpers and URL rewrites copied by an older attempt before using trusted publication credentials. */
43
+ export async function clearCredentialHelpers(root) {
44
+ const listed = await gitRead(root, ['config', '--local', '--name-only', '--get-regexp', '^(credential\\.|http\\.|url\\.)'])
45
+ .catch((error) => { if (error.code === 1)
46
+ return ''; throw error; });
47
+ for (const key of new Set(listed.split('\n').filter(Boolean))) {
48
+ if (!/^(credential\.|http\.|url\.)/i.test(key) || /[\r\n\0]/.test(key))
49
+ throw new Error('batch_source_git_config_invalid');
50
+ await gitWrite(root, ['config', '--local', '--unset-all', key]);
51
+ }
52
+ }
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { workspaceAudioTargetSchema, workspaceRelativePathSchema, workspaceAudioProtectionSchema, isSoundDocumentPath } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { workspaceAudioTargetSchema, workspaceRelativePathSchema, workspaceAudioProtectionSchema, isSoundDocumentPath, audioReferenceActionSchema } from '@playcraft/common/cloud-workspace/runtime';
3
3
  import { WorkspaceAudioAssets } from '../workspaces/audio-assets/service.js';
4
4
  import { audioRendererScript, workspaceAudioRenderer } from '../workspaces/audio-assets/renderer.js';
5
5
  import { audioError } from '../workspaces/audio-assets/storage.js';
@@ -116,7 +116,8 @@ async function rendererProbe(state, root, scope, kind = 'synth-sfx') {
116
116
  }
117
117
  /** Audio protocol negotiation is independent of legacy binary audio export. */
118
118
  export async function handleAudioAssetRequest(state, request, response, url) {
119
- if (!['/runtime/audio/capabilities', '/runtime/audio/assets', '/runtime/audio/content', '/runtime/audio/actions'].includes(url.pathname))
119
+ if (!['/runtime/audio/capabilities', '/runtime/audio/assets', '/runtime/audio/content', '/runtime/audio/actions',
120
+ '/runtime/audio/library-snapshot', '/runtime/audio/references'].includes(url.pathname))
120
121
  return false;
121
122
  try {
122
123
  if (request.headers['x-playcraft-audio-protocol'] !== '1')
@@ -127,7 +128,7 @@ export async function handleAudioAssetRequest(state, request, response, url) {
127
128
  const renderer = enabled ? await rendererProbe(state, root, `${workspaceId ?? 'single'}:${directoryName}`) : { ready: false, reason: 'feature_disabled' };
128
129
  const scoreRenderer = enabled && request.headers['x-playcraft-audio-score-protocol'] === '1'
129
130
  ? await rendererProbe(state, root, `${workspaceId ?? 'single'}:${directoryName}`, 'score') : { ready: false, reason: enabled ? 'audio_score_protocol_required' : 'feature_disabled' };
130
- send(response, 200, { version: 1, audioAssetVersion: 1, audioScoreVersion: 1, audioScoreContextVersion: 1,
131
+ send(response, 200, { version: 1, audioAssetVersion: 1, audioScoreVersion: 1, audioScoreContextVersion: 1, audioLibraryVersion: 1, audioReferencesVersion: 1,
131
132
  enabled, writable: enabled, documentVersions: [1], contentKinds: request.headers['x-playcraft-audio-score-protocol'] === '1' ? ['synth-sfx', 'score'] : ['synth-sfx'],
132
133
  renderer: { ...renderer, engineId: 'playcraft-webaudio-sfx', engineVersion: '1.0.0' },
133
134
  scoreRenderer: { ...scoreRenderer, engineId: 'playcraft-webaudio-score', engineVersion: '1.0.0' },
@@ -144,6 +145,51 @@ export async function handleAudioAssetRequest(state, request, response, url) {
144
145
  send(response, 200, result);
145
146
  return true;
146
147
  }
148
+ if (request.method === 'GET' && url.pathname === '/runtime/audio/library-snapshot') {
149
+ if (!enabled)
150
+ throw audioError('audio_feature_disabled', 403);
151
+ const target = targetFrom(url), revision = Number(url.searchParams.get('revision')), hash = url.searchParams.get('contentHash');
152
+ if (!Number.isSafeInteger(revision) || revision < 1 || !hash || !/^[a-f0-9]{64}$/.test(hash))
153
+ throw audioError('invalid_audio_request', 400);
154
+ const current = await assets.read(target);
155
+ requireScore(current.document.format === 'playcraft.score');
156
+ send(response, 200, await assets.librarySnapshot(target, revision, hash));
157
+ return true;
158
+ }
159
+ if (request.method === 'GET' && url.pathname === '/runtime/audio/references') {
160
+ const target = targetFrom(url);
161
+ if (target.kind !== 'workspace-file')
162
+ throw audioError('invalid_audio_target', 400);
163
+ const current = await assets.read(target);
164
+ requireScore(current.document.format === 'playcraft.score');
165
+ send(response, 200, await assets.references(target));
166
+ return true;
167
+ }
168
+ if (request.method === 'POST' && url.pathname === '/runtime/audio/references') {
169
+ if (!enabled)
170
+ throw audioError('audio_feature_disabled', 403);
171
+ if (state.journal.destroyed())
172
+ throw audioError('runtime_destroyed');
173
+ const input = audioReferenceActionSchema.safeParse(await body(request));
174
+ if (!input.success)
175
+ throw audioError('invalid_audio_request', 400);
176
+ requireScore(input.data.target.path.endsWith('.pcscore.json'));
177
+ const sessionId = url.searchParams.get('sessionId');
178
+ if (sessionId) {
179
+ const session = state.journal.getSession(sessionId);
180
+ if (!session || session.workspaceId !== workspaceId || (session.directoryName ?? 'basic') !== directoryName)
181
+ throw audioError('audio_scope_mismatch', 403);
182
+ if (session.permissionMode === 'read-only')
183
+ throw audioError('plan_mode_write_forbidden', 403);
184
+ }
185
+ const result = await withDirectoryWrite(state, { workspaceId, directoryName, wait: false, sessionId: sessionId ?? undefined }, async () => {
186
+ if (await batchDirectoryHeld(state, workspaceId, directoryName))
187
+ throw audioError('batch_workspace_readonly', 403);
188
+ return state.journal.exclusive(`file:${revisionScope(state, workspaceId, directoryName) ?? 'single'}:source/audio-bindings/${input.data.eventName}.json`, () => assets.referenceAction(input.data));
189
+ });
190
+ send(response, 200, result);
191
+ return true;
192
+ }
147
193
  if (request.method === 'GET' && url.pathname === '/runtime/audio/content') {
148
194
  const revision = url.searchParams.has('revision') ? Number(url.searchParams.get('revision')) : undefined;
149
195
  if (revision !== undefined && (!Number.isSafeInteger(revision) || revision < 1))
@@ -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',