@playcraft/cli 0.1.20 → 0.1.22
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/commands/tools-batch.js +59 -0
- package/dist/commands/tools.js +2 -0
- package/dist/workspace-runtime/codex/app-server.js +16 -5
- package/dist/workspace-runtime/codex/model-broker.js +5 -5
- package/dist/workspace-runtime/previews/publish.js +7 -4
- package/dist/workspace-runtime/production/contracts.js +17 -0
- package/dist/workspace-runtime/production/controller.js +207 -0
- package/dist/workspace-runtime/production/dependencies.js +60 -0
- package/dist/workspace-runtime/production/native.js +70 -0
- package/dist/workspace-runtime/production/processes.js +86 -0
- package/dist/workspace-runtime/production/save-command.js +20 -0
- package/dist/workspace-runtime/production/stages.js +175 -0
- package/dist/workspace-runtime/production/storage.js +32 -0
- package/dist/workspace-runtime/serve.js +3 -6
- package/dist/workspace-runtime/server/desktop-host.js +1 -1
- package/dist/workspace-runtime/server/desktop-preview.js +5 -5
- package/dist/workspace-runtime/server/dispatch.js +38 -24
- package/dist/workspace-runtime/server/http.js +117 -33
- package/dist/workspace-runtime/server/terminal-host.js +6 -1
- package/dist/workspace-runtime/workspaces/batch-commit.js +142 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +5 -3
- package/dist/workspace-runtime/workspaces/directories.js +106 -0
- package/dist/workspace-runtime/workspaces/directory-access.js +20 -0
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +111 -0
- package/dist/workspace-runtime/workspaces/directory-stats.js +49 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +63 -30
- package/dist/workspace-runtime/workspaces/git-credential-helper.js +4 -2
- package/dist/workspace-runtime/workspaces/git-direct-store.js +12 -11
- package/dist/workspace-runtime/workspaces/prepare.js +20 -5
- package/dist/workspace-runtime/workspaces/registry.js +63 -12
- package/dist/workspace-runtime/workspaces/revisions.js +5 -5
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -2
- package/package.json +3 -3
- package/project-templates/batch-locks/phaser.yaml +922 -0
- package/project-templates/batch-locks/threejs.yaml +978 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
6
|
+
export async function processBirth(pid) {
|
|
7
|
+
if (process.platform !== 'linux') {
|
|
8
|
+
return promisify(execFile)('ps', ['-p', String(pid), '-o', 'lstart='], { timeout: 2000 }).then(value => value.stdout.trim() || undefined, () => undefined);
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const stat = await readFile(`/proc/${pid}/stat`, 'utf8');
|
|
12
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function groupExited(pid) {
|
|
19
|
+
try {
|
|
20
|
+
process.kill(-pid, 0);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return error.code === 'ESRCH';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function stopProductionGroup(record) {
|
|
28
|
+
if (record.stopped || groupExited(record.pid))
|
|
29
|
+
return true;
|
|
30
|
+
if (record.birth && await processBirth(record.pid) !== record.birth)
|
|
31
|
+
return false;
|
|
32
|
+
for (const signal of ['SIGTERM', 'SIGKILL']) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(-record.pid, signal);
|
|
35
|
+
}
|
|
36
|
+
catch { /* Recheck whether the whole group exited. */ }
|
|
37
|
+
const deadline = Date.now() + 2000;
|
|
38
|
+
while (Date.now() < deadline) {
|
|
39
|
+
if (groupExited(record.pid))
|
|
40
|
+
return true;
|
|
41
|
+
await delay(25);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return groupExited(record.pid);
|
|
45
|
+
}
|
|
46
|
+
/** Only the save stage receives repository credentials; build and install receive none. */
|
|
47
|
+
export async function runProductionCommand(state, input) {
|
|
48
|
+
const launcher = !input.trusted ? process.env.PLAYCRAFT_LAUNCHER_SOCKET : undefined;
|
|
49
|
+
const launched = launcher ? await startLauncherProcess({ socketPath: launcher, processId: input.processId,
|
|
50
|
+
runtimeEpoch: state.runtimeEpoch, argv: ['/usr/bin/bash', '-lc', input.command], cwd: input.cwd, env: input.env ?? {} }) : undefined;
|
|
51
|
+
const local = launched ? undefined : spawn('bash', ['-lc', input.command], { cwd: input.cwd,
|
|
52
|
+
env: { PATH: process.env.PATH, HOME: state.config.stateDir, ...input.env }, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
53
|
+
const pid = launched?.pid ?? local?.pid;
|
|
54
|
+
if (!pid)
|
|
55
|
+
throw new Error('production_process_not_started');
|
|
56
|
+
const record = { pid, birth: await processBirth(pid), stopped: false };
|
|
57
|
+
try {
|
|
58
|
+
await input.record(record);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
await stopProductionGroup(record);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
let output = '';
|
|
65
|
+
const collect = (chunk) => { output = (output + String(chunk)).slice(-64 * 1024); };
|
|
66
|
+
(launched?.stdout ?? local.stdout).on('data', collect);
|
|
67
|
+
(launched?.stderr ?? local.stderr).on('data', collect);
|
|
68
|
+
launched?.stdin?.end();
|
|
69
|
+
let timedOut = false;
|
|
70
|
+
const code = await new Promise((resolve) => {
|
|
71
|
+
const timeout = setTimeout(() => {
|
|
72
|
+
timedOut = true;
|
|
73
|
+
void (launched ? launched.stop() : stopProductionGroup(record)).catch(() => undefined).finally(() => resolve(undefined));
|
|
74
|
+
}, input.timeoutMs);
|
|
75
|
+
const finish = (code) => { clearTimeout(timeout); resolve(code); };
|
|
76
|
+
if (launched)
|
|
77
|
+
launched.onExit(finish);
|
|
78
|
+
else {
|
|
79
|
+
local.once('error', () => finish(undefined));
|
|
80
|
+
local.once('close', finish);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
const stopped = await stopProductionGroup(record);
|
|
84
|
+
await input.record({ ...record, stopped });
|
|
85
|
+
return { code: timedOut ? undefined : code, output, stopped };
|
|
86
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function productionSaveCommand() {
|
|
2
|
+
return `set -eu
|
|
3
|
+
status=0
|
|
4
|
+
remote=$(git -c protocol.version=0 -c core.hooksPath=/dev/null ls-remote --exit-code --heads "$PRODUCTION_REPOSITORY" "refs/heads/$PRODUCTION_BRANCH") || status=$?
|
|
5
|
+
if [ "$status" != 0 ] && [ "$status" != 2 ]; then exit "$status"; fi
|
|
6
|
+
if [ "\${PRODUCTION_EXPECTED_PARENT+x}" = x ]; then
|
|
7
|
+
actual=$(printf '%s' "$remote" | cut -f1)
|
|
8
|
+
test "$actual" = "$PRODUCTION_EXPECTED_PARENT" || { printf 'production_git_parent_changed\\n' >&2; exit 1; }
|
|
9
|
+
fi
|
|
10
|
+
if [ -n "$remote" ]; then
|
|
11
|
+
parent=$(printf '%s' "$remote" | cut -f1)
|
|
12
|
+
git -c protocol.version=0 -c core.hooksPath=/dev/null fetch --no-tags --no-recurse-submodules "$PRODUCTION_REPOSITORY" "refs/heads/$PRODUCTION_BRANCH"
|
|
13
|
+
test "$(git rev-parse FETCH_HEAD)" = "$parent"
|
|
14
|
+
git -c core.hooksPath=/dev/null -c core.fsmonitor=false reset --soft FETCH_HEAD
|
|
15
|
+
fi
|
|
16
|
+
git -c core.hooksPath=/dev/null -c core.fsmonitor=false add -A .
|
|
17
|
+
git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Save generated engineering"
|
|
18
|
+
git -c protocol.version=0 -c core.hooksPath=/dev/null push "$PRODUCTION_REPOSITORY" HEAD:"refs/heads/$PRODUCTION_BRANCH"
|
|
19
|
+
git rev-parse HEAD`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { batchBaselineSnapshotSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { checkoutBatchCommit, freezeBatchCommit } from '../workspaces/batch-commit.js';
|
|
8
|
+
import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
|
|
9
|
+
import { registerWorkspaceDirectory } from '../workspaces/directories.js';
|
|
10
|
+
import { workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
11
|
+
import { excludeFromGit, settleDependencyInstalls } from '../workspaces/dependencies.js';
|
|
12
|
+
import { resolveProductionDependencies, verifyProductionDependencyLocks } from './dependencies.js';
|
|
13
|
+
import { productionSaveCommand } from './save-command.js';
|
|
14
|
+
import { productionOutputStore, verifyProductionLease } from './storage.js';
|
|
15
|
+
import { runProductionCommand } from './processes.js';
|
|
16
|
+
import { runProductionNative } from './native.js';
|
|
17
|
+
import { classifyPreviewOutput, findExternalLocalReferences } from '../previews/single-file.js';
|
|
18
|
+
const execute = promisify(execFile);
|
|
19
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
20
|
+
const hash = (body) => createHash('sha256').update(body).digest('hex');
|
|
21
|
+
const record = (value) => value && typeof value === 'object' ? value : {};
|
|
22
|
+
export async function executeProductionStage(state, input, processRecord, track) {
|
|
23
|
+
await verifyProductionLease(input);
|
|
24
|
+
const store = productionOutputStore(input);
|
|
25
|
+
const stage = input.stage;
|
|
26
|
+
const workspaceId = input.task.workspaceId;
|
|
27
|
+
const resultId = String(input.payload.resultVersionId ?? input.task.payload.resultVersionId ?? input.task.attemptId);
|
|
28
|
+
const previous = (name) => input.stages?.find(value => value.stage === name && value.status === 'completed')?.result;
|
|
29
|
+
const run = async (cwd, command, env, trusted = false) => {
|
|
30
|
+
const outcome = await runProductionCommand(state, { processId: `production-${input.task.attemptId}-${stage}`, cwd, command, env, trusted,
|
|
31
|
+
timeoutMs: 10 * 60_000, record: processRecord });
|
|
32
|
+
if (!outcome.stopped || outcome.code === undefined)
|
|
33
|
+
throw new Error('production_process_unknown');
|
|
34
|
+
if (outcome.code !== 0)
|
|
35
|
+
throw new Error(`production_${stage}_failed`);
|
|
36
|
+
return outcome.output;
|
|
37
|
+
};
|
|
38
|
+
const git = async (root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
|
|
39
|
+
timeout: 20_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' },
|
|
40
|
+
})).stdout.trim();
|
|
41
|
+
if (stage === 'prepare' && input.directoryName !== 'basic') {
|
|
42
|
+
const baseline = record(input.payload.baseline ?? input.task.payload.baseline);
|
|
43
|
+
const authority = record(input.payload.workspace);
|
|
44
|
+
const sourceCommit = baseline.commitHash;
|
|
45
|
+
if (typeof sourceCommit !== 'string')
|
|
46
|
+
throw new Error('production_approved_commit_required');
|
|
47
|
+
const directory = { workspaceId, directoryName: input.directoryName,
|
|
48
|
+
remixId: String(authority.remixId), branchName: String(authority.branchName), sourceCommit };
|
|
49
|
+
await registerWorkspaceDirectory(state, directory, state.acceptedLeaseFence);
|
|
50
|
+
}
|
|
51
|
+
const root = await workspaceRoot(state, workspaceId, input.directoryName);
|
|
52
|
+
if (stage === 'capture-source') {
|
|
53
|
+
if (input.directoryName !== 'basic')
|
|
54
|
+
throw new Error('production_source_directory_mismatch');
|
|
55
|
+
if (workspaceSessions(state, workspaceId, 'basic').some(session => session.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? '')))
|
|
56
|
+
throw new Error('production_source_busy');
|
|
57
|
+
await settleDependencyInstalls(state);
|
|
58
|
+
await resolveProductionDependencies(root);
|
|
59
|
+
await excludeFromGit(root);
|
|
60
|
+
await excludeFromGit(root, '/.env*');
|
|
61
|
+
await git(root, 'config', 'user.name', 'PlayCraft');
|
|
62
|
+
await git(root, 'config', 'user.email', 'playcraft@localhost');
|
|
63
|
+
await run(root, 'git -c core.hooksPath=/dev/null add -A . && (git diff --cached --quiet || git -c core.hooksPath=/dev/null commit -m "Preserve basic before common repair")', undefined, true);
|
|
64
|
+
const commitHash = await git(root, 'rev-parse', 'HEAD');
|
|
65
|
+
const snapshot = await freezeBatchCommit({ source: { workspaceId, root }, commitHash, snapshotId: `${input.task.attemptId}-capture-source` });
|
|
66
|
+
return { sourceSnapshot: snapshot, commitHash };
|
|
67
|
+
}
|
|
68
|
+
if (stage === 'prepare') {
|
|
69
|
+
await settleDependencyInstalls(state);
|
|
70
|
+
const policy = await resolveProductionDependencies(root);
|
|
71
|
+
await run(root, process.env.PLAYCRAFT_PRODUCTION_INSTALL_COMMAND ?? 'pnpm install --frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --ignore-scripts');
|
|
72
|
+
await verifyProductionDependencyLocks(root, policy);
|
|
73
|
+
return { prepared: true, directoryName: input.directoryName };
|
|
74
|
+
}
|
|
75
|
+
if (stage === 'generate') {
|
|
76
|
+
await runProductionNative(state, input, root, processRecord, track);
|
|
77
|
+
await resolveProductionDependencies(root);
|
|
78
|
+
return { generated: true };
|
|
79
|
+
}
|
|
80
|
+
if (stage === 'save') {
|
|
81
|
+
if (!input.git)
|
|
82
|
+
throw new Error('production_git_scope_required');
|
|
83
|
+
const binding = state.journal.getWorkspace(workspaceId);
|
|
84
|
+
const branch = input.directoryName === 'basic' ? binding.branchName : binding.directories?.find(value => value.directoryName === input.directoryName)?.branchName;
|
|
85
|
+
if (input.git.branchName !== branch || await git(root, 'symbolic-ref', '--short', 'HEAD') !== branch)
|
|
86
|
+
throw new Error('production_git_binding_mismatch');
|
|
87
|
+
await excludeFromGit(root);
|
|
88
|
+
await excludeFromGit(root, '/.env*');
|
|
89
|
+
await excludeFromGit(root, '/.playcraft-temp/');
|
|
90
|
+
await excludeFromGit(root, '/.playcraft-preview-out/');
|
|
91
|
+
await git(root, 'config', 'user.name', 'PlayCraft');
|
|
92
|
+
await git(root, 'config', 'user.email', 'playcraft@localhost');
|
|
93
|
+
const env = { PRODUCTION_REPOSITORY: input.git.repositoryUrl, PRODUCTION_BRANCH: input.git.branchName,
|
|
94
|
+
GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
|
|
95
|
+
if (input.git.username && input.git.password)
|
|
96
|
+
Object.assign(env, { GIT_CONFIG_COUNT: '1',
|
|
97
|
+
GIT_CONFIG_KEY_0: `http.${input.git.repositoryUrl}.extraHeader`,
|
|
98
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${input.git.username}:${input.git.password}`).toString('base64')}` });
|
|
99
|
+
if (typeof input.payload.expectedParent === 'string')
|
|
100
|
+
env.PRODUCTION_EXPECTED_PARENT = input.payload.expectedParent;
|
|
101
|
+
await run(root, productionSaveCommand(), env, true);
|
|
102
|
+
const commitHash = await git(root, 'rev-parse', 'HEAD');
|
|
103
|
+
const snapshot = await freezeBatchCommit({ source: { workspaceId, root }, commitHash, snapshotId: `${input.task.attemptId}-save` });
|
|
104
|
+
return { snapshot, saved: { attemptId: input.task.attemptId, resultVersionId: resultId, workspaceId, sourceDigest: snapshot.checksum, commitHash, pushedCommitHash: commitHash } };
|
|
105
|
+
}
|
|
106
|
+
if (stage === 'freeze') {
|
|
107
|
+
const saved = record(previous('save')?.saved);
|
|
108
|
+
if (typeof saved.commitHash !== 'string')
|
|
109
|
+
throw new Error('production_saved_commit_required');
|
|
110
|
+
const snapshot = await freezeBatchCommit({ source: { workspaceId, root }, commitHash: saved.commitHash, snapshotId: `${input.task.attemptId}-freeze` });
|
|
111
|
+
const baseline = batchBaselineSnapshotSchema.parse({ batchId: input.task.batchId, projectId: String(input.payload.projectId ?? input.task.payload.projectId),
|
|
112
|
+
snapshotId: snapshot.snapshotId, sourceWorkspaceId: snapshot.sourceWorkspaceId, commitHash: snapshot.commitHash, checksum: snapshot.checksum,
|
|
113
|
+
sourceVersion: snapshot.sourceVersion, engine: String(input.payload.engine ?? input.task.payload.engine), skillLockDigest: snapshot.skillLockDigest, frozenAt: snapshot.frozenAt });
|
|
114
|
+
return { snapshot, baseline };
|
|
115
|
+
}
|
|
116
|
+
if (stage === 'build') {
|
|
117
|
+
const saved = record(input.payload.saved ?? previous('save')?.saved);
|
|
118
|
+
const snapshot = (previous('freeze')?.snapshot ?? previous('save')?.snapshot);
|
|
119
|
+
if (!snapshot || snapshot.commitHash !== saved.commitHash || snapshot.checksum !== saved.sourceDigest)
|
|
120
|
+
throw new Error('production_saved_snapshot_mismatch');
|
|
121
|
+
const destination = join(root, '.playcraft-temp', `production-${input.task.attemptId}`);
|
|
122
|
+
await excludeFromGit(root, '/.playcraft-temp/');
|
|
123
|
+
await rm(destination, { recursive: true, force: true });
|
|
124
|
+
try {
|
|
125
|
+
await checkoutBatchCommit({ source: { workspaceId, root }, frozen: snapshot, destination });
|
|
126
|
+
await openDirectoryToDevelopment(destination, (await lstat(state.config.workspaceRoot)).gid);
|
|
127
|
+
const policy = await resolveProductionDependencies(destination);
|
|
128
|
+
await run(destination, process.env.PLAYCRAFT_PRODUCTION_INSTALL_COMMAND ?? 'pnpm install --frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --ignore-scripts');
|
|
129
|
+
await verifyProductionDependencyLocks(destination, policy);
|
|
130
|
+
const output = join(destination, '.production-html');
|
|
131
|
+
const command = process.env.PLAYCRAFT_PRODUCTION_BUILD_COMMAND ?? 'playcraft build --mode full --platform playcraft --format html';
|
|
132
|
+
await run(destination, `${command} --output ${quote(output)} .`);
|
|
133
|
+
await verifyProductionDependencyLocks(destination, policy);
|
|
134
|
+
const entries = await readdir(output, { withFileTypes: true });
|
|
135
|
+
const single = classifyPreviewOutput(entries.map(entry => ({ name: entry.name, kind: entry.isFile() ? 'file' : entry.isDirectory() ? 'dir' : 'symlink' })));
|
|
136
|
+
if (!single.ok)
|
|
137
|
+
throw new Error('production_preview_not_single_file');
|
|
138
|
+
const path = join(output, single.entry);
|
|
139
|
+
if (!(await lstat(path)).isFile())
|
|
140
|
+
throw new Error('production_preview_invalid');
|
|
141
|
+
const body = await readFile(path);
|
|
142
|
+
if (findExternalLocalReferences(body.toString()).length)
|
|
143
|
+
throw new Error('production_preview_external_reference');
|
|
144
|
+
const outputDigest = hash(body), outputKey = `workspaces/${workspaceId}/production/${input.task.attemptId}/${outputDigest}.html`;
|
|
145
|
+
await store.put(outputKey, body);
|
|
146
|
+
return { outputKey, outputDigest, build: { attemptId: input.task.attemptId, resultVersionId: resultId, workspaceId,
|
|
147
|
+
sourceDigest: snapshot.checksum, commitHash: snapshot.commitHash, buildId: `${input.task.attemptId}-build`, succeeded: true } };
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
await rm(destination, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (stage === 'preview') {
|
|
154
|
+
const built = previous('build');
|
|
155
|
+
if (typeof built?.outputKey !== 'string' || typeof built.outputDigest !== 'string')
|
|
156
|
+
throw new Error('production_build_required');
|
|
157
|
+
const body = await store.get(built.outputKey);
|
|
158
|
+
if (hash(body) !== built.outputDigest)
|
|
159
|
+
throw new Error('production_preview_digest_mismatch');
|
|
160
|
+
const previewPath = join(root, '.playcraft-temp', `${input.task.attemptId}.html`);
|
|
161
|
+
await mkdir(dirname(previewPath), { recursive: true });
|
|
162
|
+
await writeFile(previewPath, body);
|
|
163
|
+
try {
|
|
164
|
+
const browser = process.env.PLAYCRAFT_PRODUCTION_PREVIEW_COMMAND ?? 'chromium --headless --no-sandbox --disable-gpu --virtual-time-budget=1000 --dump-dom';
|
|
165
|
+
const opened = await run(root, `${browser} ${quote(`file://${previewPath}`)}`);
|
|
166
|
+
if (!/<html[\s>]/i.test(opened))
|
|
167
|
+
throw new Error('production_preview_not_opened');
|
|
168
|
+
return { opened: true, outputDigest: built.outputDigest };
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await rm(previewPath, { force: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw new Error('invalid_production_stage');
|
|
175
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const taskUrl = (input) => `${input.callbackUrl.replace(/\/+$/, '')}/internal/cloud-production/tasks/${input.task.taskId}`;
|
|
2
|
+
/** Build output travels through the Runtime that leased the task; engineering files travel through Git. */
|
|
3
|
+
export function productionOutputStore(input) {
|
|
4
|
+
const object = (key) => `${taskUrl(input)}/objects/${encodeURIComponent(key)}`;
|
|
5
|
+
const headers = { authorization: `Bearer ${input.runnerToken}`, 'x-playcraft-object-client': 'cli' };
|
|
6
|
+
return {
|
|
7
|
+
async get(key) {
|
|
8
|
+
const response = await fetch(object(key), { headers, redirect: 'error', signal: AbortSignal.timeout(60_000) });
|
|
9
|
+
if (response.status === 404)
|
|
10
|
+
throw new Error('object_missing');
|
|
11
|
+
if (!response.ok)
|
|
12
|
+
throw new Error('object_get_failed');
|
|
13
|
+
return Buffer.from(await response.arrayBuffer());
|
|
14
|
+
},
|
|
15
|
+
async put(key, body) {
|
|
16
|
+
const response = await fetch(object(key), { method: 'PUT', body: new Uint8Array(body), redirect: 'error', signal: AbortSignal.timeout(60_000),
|
|
17
|
+
headers: { ...headers, 'content-type': 'application/octet-stream' } });
|
|
18
|
+
if (!response.ok)
|
|
19
|
+
throw new Error(response.status === 409 ? 'object_conflict' : 'object_put_failed');
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export async function verifyProductionLease(input) {
|
|
24
|
+
const response = await fetch(`${taskUrl(input)}/runner`, {
|
|
25
|
+
headers: { authorization: `Bearer ${input.runnerToken}` }, redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
throw new Error('production_lease_unavailable');
|
|
29
|
+
const { task } = await response.json();
|
|
30
|
+
if (!task || task.taskId !== input.task.taskId || task.attemptId !== input.task.attemptId || task.leaseOwner !== input.lease.owner || task.leaseFence !== input.lease.fence || task.status !== 'running' || task.cancelRequested)
|
|
31
|
+
throw new Error('production_lease_unavailable');
|
|
32
|
+
}
|
|
@@ -18,8 +18,7 @@ import { WorkspaceModelBroker } from './codex/model-broker.js';
|
|
|
18
18
|
import { recoverInterruptedPreviews } from './previews/publish.js';
|
|
19
19
|
import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
|
|
20
20
|
import { loadWorkspaceSkillArtifact } from './workspaces/skill-artifact-loader.js';
|
|
21
|
-
import {
|
|
22
|
-
import { basename } from 'node:path';
|
|
21
|
+
import { workspaceContextFromRoot } from './workspaces/registry.js';
|
|
23
22
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
24
23
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
25
24
|
const negotiated = negotiateProtocol(config);
|
|
@@ -190,10 +189,8 @@ function defaultNativeAdapter(state) {
|
|
|
190
189
|
config,
|
|
191
190
|
...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
|
|
192
191
|
loadSkillArtifact: async (cwd, entry) => {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
throw new Error('workspace_skills_unavailable');
|
|
196
|
-
return loadWorkspaceSkillArtifact(state, id, entry);
|
|
192
|
+
const { workspaceId, directoryName } = await workspaceContextFromRoot(state, cwd);
|
|
193
|
+
return loadWorkspaceSkillArtifact(state, workspaceId, entry, directoryName === 'root' ? 'basic' : directoryName);
|
|
197
194
|
},
|
|
198
195
|
onSkillProcessRestart: process => state.journal.noteExecutionGroup(process),
|
|
199
196
|
});
|
|
@@ -94,7 +94,7 @@ export class DesktopHost {
|
|
|
94
94
|
this.preview ??= new DesktopPreviewHost(this.state, {
|
|
95
95
|
canNavigate: () => !this.disposed && !this.state.journal.destroyed() && this.holder !== 'human',
|
|
96
96
|
});
|
|
97
|
-
await this.preview.open(url.searchParams.get('workspaceId') ?? undefined);
|
|
97
|
+
await this.preview.open(url.searchParams.get('workspaceId') ?? undefined, url.searchParams.get('directoryName') ?? 'basic');
|
|
98
98
|
if (this.disposed || this.state.journal.destroyed()) {
|
|
99
99
|
writeError(response, 'runtime_destroyed');
|
|
100
100
|
return;
|
|
@@ -31,15 +31,15 @@ export class DesktopPreviewHost {
|
|
|
31
31
|
this.state = state;
|
|
32
32
|
this.options = options;
|
|
33
33
|
}
|
|
34
|
-
open(workspaceId) {
|
|
34
|
+
open(workspaceId, directoryName = 'basic') {
|
|
35
35
|
if (!this.canNavigate())
|
|
36
36
|
return Promise.resolve();
|
|
37
|
-
const key = workspaceId
|
|
37
|
+
const key = workspaceId ? `${workspaceId}:${directoryName}` : 'single';
|
|
38
38
|
this.requestedKey = key;
|
|
39
39
|
const existing = this.pending.get(key);
|
|
40
40
|
if (existing)
|
|
41
41
|
return existing;
|
|
42
|
-
const task = this.openWorkspace(key, workspaceId).catch((error) => {
|
|
42
|
+
const task = this.openWorkspace(key, workspaceId, directoryName).catch((error) => {
|
|
43
43
|
if (error instanceof DesktopPreviewError || error instanceof WorkspaceContextError)
|
|
44
44
|
throw error;
|
|
45
45
|
throw new DesktopPreviewError('desktop_preview_start_failed');
|
|
@@ -67,8 +67,8 @@ export class DesktopPreviewHost {
|
|
|
67
67
|
canNavigate() {
|
|
68
68
|
return !this.disposed && !this.state.journal.destroyed() && (this.options.canNavigate?.() ?? true);
|
|
69
69
|
}
|
|
70
|
-
async openWorkspace(key, workspaceId) {
|
|
71
|
-
const root = await workspaceRoot(this.state, workspaceId);
|
|
70
|
+
async openWorkspace(key, workspaceId, directoryName = 'basic') {
|
|
71
|
+
const root = await workspaceRoot(this.state, workspaceId, directoryName);
|
|
72
72
|
const deadline = Date.now() + (this.options.timeoutMs ?? 90_000);
|
|
73
73
|
if (!this.canNavigate())
|
|
74
74
|
return;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { withObservedFile } from '../workspaces/revisions.js';
|
|
2
|
-
import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
2
|
+
import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -55,7 +55,7 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
|
|
|
55
55
|
if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'preview.publish'].includes(operation.kind)) {
|
|
56
56
|
return run();
|
|
57
57
|
}
|
|
58
|
-
return state.journal.exclusive(`workspace:${revisionScope(state, operation.workspaceId) ?? 'single'}`, run);
|
|
58
|
+
return state.journal.exclusive(`workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
|
|
59
59
|
}
|
|
60
60
|
async function executeScopedOperation(state, scopeKey, operation, payload, recovery) {
|
|
61
61
|
let current = await persist(state, scopeKey, transitionOperation(operation, 'dispatched'));
|
|
@@ -122,10 +122,10 @@ async function executeSkillsUpdate(state, scopeKey, operation, payload) {
|
|
|
122
122
|
const parsed = workspaceSkillsUpdatePayloadSchema.safeParse(payload);
|
|
123
123
|
if (!parsed.success)
|
|
124
124
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_skills_update' }));
|
|
125
|
-
if (workspaceSessions(state, operation.workspaceId).some(sessionTurnBusy))
|
|
125
|
+
if (workspaceSessions(state, operation.workspaceId, operation.directoryName).some(sessionTurnBusy))
|
|
126
126
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
|
|
127
127
|
try {
|
|
128
|
-
const root = await workspaceRoot(state, operation.workspaceId);
|
|
128
|
+
const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
129
129
|
const snapshot = await readWorkspaceSkillSnapshot(root);
|
|
130
130
|
if (parsed.data.expectedDeclarationHash && parsed.data.expectedDeclarationHash !== snapshot.lock.declarationHash)
|
|
131
131
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'skills_revision_conflict' }));
|
|
@@ -158,7 +158,7 @@ async function executeCreate(state, scopeKey, operation, payload) {
|
|
|
158
158
|
try {
|
|
159
159
|
started = await native(state).startThread({
|
|
160
160
|
sessionId,
|
|
161
|
-
cwd: await
|
|
161
|
+
cwd: await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName),
|
|
162
162
|
modelConfigId: selected.data,
|
|
163
163
|
});
|
|
164
164
|
}
|
|
@@ -174,8 +174,9 @@ async function executeCreate(state, scopeKey, operation, payload) {
|
|
|
174
174
|
await state.journal.putSession({
|
|
175
175
|
sessionId,
|
|
176
176
|
workspaceId: operation.workspaceId,
|
|
177
|
+
directoryName: operation.directoryName,
|
|
177
178
|
threadId: started.threadId,
|
|
178
|
-
cwd: await
|
|
179
|
+
cwd: await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName),
|
|
179
180
|
recoverability: 'native',
|
|
180
181
|
modelConfigId: started.modelConfigId,
|
|
181
182
|
});
|
|
@@ -217,7 +218,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
217
218
|
}
|
|
218
219
|
let attachments;
|
|
219
220
|
try {
|
|
220
|
-
attachments = await resolveWorkspaceAttachments(await
|
|
221
|
+
attachments = await resolveWorkspaceAttachments(await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName), body.attachmentIds);
|
|
221
222
|
}
|
|
222
223
|
catch {
|
|
223
224
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'attachment_unavailable' }));
|
|
@@ -246,11 +247,15 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
246
247
|
const reserved = state.journal.getOperation(scopeKey) ?? operation;
|
|
247
248
|
if (result.outcome === 'uncertain' || result.outcome === 'failed' || !result.turnId) {
|
|
248
249
|
const failed = result.outcome === 'failed';
|
|
250
|
+
// Codex no longer knows the thread although the binding said it could continue. Marking the
|
|
251
|
+
// binding lost lets the same turn, sent again with the conversation, rebind the session.
|
|
252
|
+
const threadLost = failed && result.error === 'unknown_thread';
|
|
249
253
|
await state.journal.putSession({
|
|
250
254
|
...(state.journal.getSession(sessionId) ?? binding),
|
|
251
255
|
turnStatus: failed ? undefined : 'starting',
|
|
252
256
|
turnBlock: failed ? undefined : 'uncertain',
|
|
253
257
|
activeTurnId: undefined,
|
|
258
|
+
...(threadLost ? { recoverability: 'history_only' } : {}),
|
|
254
259
|
});
|
|
255
260
|
return persist(state, scopeKey, transitionOperation(reserved, failed ? 'failed' : 'uncertain', {
|
|
256
261
|
error: result.outcome === 'failed' ? result.error : 'native_receipt_lost',
|
|
@@ -307,7 +312,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
307
312
|
async function rebindSession(state, operation, binding, modelConfigId) {
|
|
308
313
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
309
314
|
const adapter = native(state);
|
|
310
|
-
const cwd = await
|
|
315
|
+
const cwd = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
311
316
|
const group = state.journal.executionGroup();
|
|
312
317
|
try {
|
|
313
318
|
if (binding && adapter.resumeThread && (!group.replaced || group.stopConfirmed)) {
|
|
@@ -336,6 +341,7 @@ async function rebindSession(state, operation, binding, modelConfigId) {
|
|
|
336
341
|
const next = {
|
|
337
342
|
sessionId,
|
|
338
343
|
workspaceId: operation.workspaceId,
|
|
344
|
+
directoryName: operation.directoryName,
|
|
339
345
|
threadId: started.threadId,
|
|
340
346
|
cwd,
|
|
341
347
|
recoverability: 'native',
|
|
@@ -453,13 +459,13 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
|
|
|
453
459
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'revision_conflict' }));
|
|
454
460
|
}
|
|
455
461
|
try {
|
|
456
|
-
const root = await workspaceRoot(state, operation.workspaceId);
|
|
462
|
+
const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
457
463
|
const written = await writeWorkspaceFile(root, parsed.data.path, parsed.data.content);
|
|
458
464
|
const revision = currentRevision + 1;
|
|
459
465
|
await state.journal.putFileRevision(parsed.data.path, {
|
|
460
466
|
revision,
|
|
461
467
|
contentHash: written.contentHash,
|
|
462
|
-
}, revisionScope(state, operation.workspaceId));
|
|
468
|
+
}, revisionScope(state, operation.workspaceId, operation.directoryName));
|
|
463
469
|
if (parsed.data.hold && parsed.data.writeId) {
|
|
464
470
|
let pid;
|
|
465
471
|
if (writer === 'background') {
|
|
@@ -494,7 +500,7 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
|
|
|
494
500
|
const code = error instanceof InvalidWorkspacePathError ? 'invalid_workspace_path' : 'file_write_failed';
|
|
495
501
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: code }));
|
|
496
502
|
}
|
|
497
|
-
}).catch((error) => {
|
|
503
|
+
}, operation.directoryName).catch((error) => {
|
|
498
504
|
const code = error instanceof InvalidWorkspacePathError ? 'invalid_workspace_path' : 'file_write_failed';
|
|
499
505
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: code }));
|
|
500
506
|
});
|
|
@@ -505,7 +511,7 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
|
|
|
505
511
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_commit' }));
|
|
506
512
|
}
|
|
507
513
|
try {
|
|
508
|
-
const sha = await commitWorkspaceGit(await workspaceRoot(state, operation.workspaceId), parsed.data, boundBranch(state, operation));
|
|
514
|
+
const sha = await commitWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), parsed.data, boundBranch(state, operation));
|
|
509
515
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
510
516
|
await recordNativeEvents(state, accepted, [
|
|
511
517
|
{
|
|
@@ -521,9 +527,10 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
|
|
|
521
527
|
}
|
|
522
528
|
}
|
|
523
529
|
/**
|
|
524
|
-
* Saves the workspace to Git: commit everything that changed, then push a Remix branch
|
|
525
|
-
*
|
|
526
|
-
* project's main branch, so those two only
|
|
530
|
+
* Saves the workspace directory to Git: commit everything that changed, then push a Remix branch
|
|
531
|
+
* (a `remix-*` directory is always on its own Remix branch). A standalone workspace has no remote,
|
|
532
|
+
* and the Git gateway never lets a workspace write its project's main branch, so those two only
|
|
533
|
+
* commit.
|
|
527
534
|
*/
|
|
528
535
|
async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
529
536
|
const parsed = workspaceSavePayloadSchema.safeParse(payload ?? {});
|
|
@@ -532,9 +539,9 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
|
532
539
|
}
|
|
533
540
|
const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
|
|
534
541
|
try {
|
|
535
|
-
const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId), {
|
|
542
|
+
const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), {
|
|
536
543
|
message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
|
|
537
|
-
push: binding?.targetKind === 'remix',
|
|
544
|
+
push: binding?.targetKind === 'remix' || Boolean(operation.directoryName && operation.directoryName !== 'basic'),
|
|
538
545
|
boundBranch: boundBranch(state, operation),
|
|
539
546
|
});
|
|
540
547
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
@@ -557,7 +564,7 @@ async function executeGitPush(state, scopeKey, operation, payload) {
|
|
|
557
564
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
|
|
558
565
|
}
|
|
559
566
|
try {
|
|
560
|
-
await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId), boundBranch(state, operation));
|
|
567
|
+
await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), boundBranch(state, operation));
|
|
561
568
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
562
569
|
await recordNativeEvents(state, accepted, [
|
|
563
570
|
{
|
|
@@ -585,7 +592,7 @@ async function executeGitIndex(state, scopeKey, operation, payload) {
|
|
|
585
592
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_paths' }));
|
|
586
593
|
}
|
|
587
594
|
try {
|
|
588
|
-
await change.run(await workspaceRoot(state, operation.workspaceId), parsed.data);
|
|
595
|
+
await change.run(await workspaceRoot(state, operation.workspaceId, operation.directoryName), parsed.data);
|
|
589
596
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
590
597
|
await recordNativeEvents(state, accepted, [
|
|
591
598
|
{ type: change.event, payload: { paths: parsed.data.paths }, occurredAt: new Date().toISOString() },
|
|
@@ -601,8 +608,8 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
|
|
|
601
608
|
if (!parsed.success) {
|
|
602
609
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_parameter_patch' }));
|
|
603
610
|
}
|
|
604
|
-
const root = await workspaceRoot(state, operation.workspaceId);
|
|
605
|
-
const scope = revisionScope(state, operation.workspaceId);
|
|
611
|
+
const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
612
|
+
const scope = revisionScope(state, operation.workspaceId, operation.directoryName);
|
|
606
613
|
const document = await readWorkspaceParameters(root, state.journal.fileRevisions(scope));
|
|
607
614
|
if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
|
|
608
615
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'parameter_data_missing' }));
|
|
@@ -638,7 +645,7 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
|
|
|
638
645
|
catch (error) {
|
|
639
646
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterPatchError(error) }));
|
|
640
647
|
}
|
|
641
|
-
}).catch((error) => persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterPatchError(error) })));
|
|
648
|
+
}, operation.directoryName).catch((error) => persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterPatchError(error) })));
|
|
642
649
|
}
|
|
643
650
|
/**
|
|
644
651
|
* A preview build takes minutes, so the request returns once the operation is accepted; Runtime
|
|
@@ -674,7 +681,7 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
|
|
|
674
681
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_terminal_create' }));
|
|
675
682
|
}
|
|
676
683
|
const existing = state.journal.getTerminal(terminalId);
|
|
677
|
-
if (existing && existing.workspaceId !== workspaceId) {
|
|
684
|
+
if (existing && (existing.workspaceId !== workspaceId || (existing.directoryName ?? 'basic') !== (operation.directoryName ?? 'basic'))) {
|
|
678
685
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'terminal_already_bound' }));
|
|
679
686
|
}
|
|
680
687
|
if (existing?.status === 'destroyed') {
|
|
@@ -685,6 +692,7 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
|
|
|
685
692
|
const record = await state.journal.putTerminal({
|
|
686
693
|
terminalId,
|
|
687
694
|
workspaceId,
|
|
695
|
+
directoryName: operation.directoryName,
|
|
688
696
|
environment: state.config.environment,
|
|
689
697
|
userId: state.config.userId,
|
|
690
698
|
runtimeId: state.config.runtimeId,
|
|
@@ -707,7 +715,12 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
|
|
|
707
715
|
}
|
|
708
716
|
/** The branch a registered workspace is bound to; the single-workspace mode has none. */
|
|
709
717
|
function boundBranch(state, operation) {
|
|
710
|
-
|
|
718
|
+
const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
|
|
719
|
+
if (!binding)
|
|
720
|
+
return undefined;
|
|
721
|
+
return operation.directoryName && operation.directoryName !== 'basic'
|
|
722
|
+
? binding.directories?.find(directory => directory.directoryName === operation.directoryName)?.branchName
|
|
723
|
+
: binding.branchName ?? undefined;
|
|
711
724
|
}
|
|
712
725
|
function gitErrorCode(error) {
|
|
713
726
|
return error instanceof GitWorkspaceError ? error.code : 'git_command_failed';
|
|
@@ -760,6 +773,7 @@ export async function recordNativeEvents(state, operation, events) {
|
|
|
760
773
|
...(asRecord(event.payload)),
|
|
761
774
|
...(operation.sessionId ? { sessionId: operation.sessionId } : {}),
|
|
762
775
|
...(operation.workspaceId ? { workspaceId: operation.workspaceId } : {}),
|
|
776
|
+
...(operation.directoryName ? { directoryName: operation.directoryName } : {}),
|
|
763
777
|
};
|
|
764
778
|
recorded.push(await state.journal.allocateSourceEvent({
|
|
765
779
|
runtimeId: state.config.runtimeId,
|