@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.
- package/dist/asset-library/provenance.js +169 -0
- package/dist/commands/audio-program.js +82 -0
- package/dist/commands/audio.js +2 -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/freeze.js +15 -2
- package/dist/workspace-runtime/batch/git.js +16 -4
- 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-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/audio-assets.js +49 -3
- package/dist/workspace-runtime/server/dispatch.js +42 -9
- package/dist/workspace-runtime/server/http.js +12 -2
- package/dist/workspace-runtime/verify-artifact.js +2 -1
- package/dist/workspace-runtime/workspaces/archive.js +28 -3
- package/dist/workspace-runtime/workspaces/asset-library-import.js +60 -9
- package/dist/workspace-runtime/workspaces/asset-provenance-verifier.js +36 -0
- package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +6 -2
- package/dist/workspace-runtime/workspaces/audio-assets/library-import.js +149 -0
- package/dist/workspace-runtime/workspaces/audio-assets/library-package.js +26 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-artifact-audit.js +90 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-browser.js +194 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-build-inputs.js +122 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-engine.js +39 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-package.js +289 -0
- package/dist/workspace-runtime/workspaces/audio-assets/program-variant-build.js +217 -0
- package/dist/workspace-runtime/workspaces/audio-assets/references.js +107 -0
- package/dist/workspace-runtime/workspaces/audio-assets/service.js +133 -1
- package/dist/workspace-runtime/workspaces/git.js +7 -0
- package/dist/workspace-runtime/workspaces/registry.js +3 -5
- package/dist/workspace-runtime/workspaces/remote-attach-marker.js +55 -0
- package/package.json +6 -3
- package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
- package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
- package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-template-v2/.playcraft/skills.lock.json +4 -4
- package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
- package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +15 -4
- package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +20 -2
- 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.
|
|
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
|
-
|
|
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;
|
|
@@ -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'
|
|
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
|
-
|
|
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',
|