@playcraft/cli 0.1.21 → 0.1.23

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 (35) hide show
  1. package/dist/commands/tools-batch.js +59 -0
  2. package/dist/commands/tools.js +2 -0
  3. package/dist/workspace-runtime/codex/app-server.js +23 -9
  4. package/dist/workspace-runtime/codex/model-broker.js +5 -5
  5. package/dist/workspace-runtime/previews/publish.js +7 -4
  6. package/dist/workspace-runtime/production/contracts.js +17 -0
  7. package/dist/workspace-runtime/production/controller.js +207 -0
  8. package/dist/workspace-runtime/production/dependencies.js +60 -0
  9. package/dist/workspace-runtime/production/native.js +70 -0
  10. package/dist/workspace-runtime/production/processes.js +86 -0
  11. package/dist/workspace-runtime/production/save-command.js +20 -0
  12. package/dist/workspace-runtime/production/stages.js +175 -0
  13. package/dist/workspace-runtime/production/storage.js +32 -0
  14. package/dist/workspace-runtime/serve.js +3 -6
  15. package/dist/workspace-runtime/server/desktop-host.js +1 -1
  16. package/dist/workspace-runtime/server/desktop-preview.js +5 -5
  17. package/dist/workspace-runtime/server/dispatch.js +37 -24
  18. package/dist/workspace-runtime/server/http.js +111 -32
  19. package/dist/workspace-runtime/server/terminal-host.js +6 -1
  20. package/dist/workspace-runtime/workspaces/batch-commit.js +142 -0
  21. package/dist/workspace-runtime/workspaces/dependencies.js +5 -3
  22. package/dist/workspace-runtime/workspaces/directories.js +106 -0
  23. package/dist/workspace-runtime/workspaces/directory-access.js +20 -0
  24. package/dist/workspace-runtime/workspaces/directory-maintenance.js +111 -0
  25. package/dist/workspace-runtime/workspaces/directory-stats.js +49 -0
  26. package/dist/workspace-runtime/workspaces/git-credential-broker.js +63 -30
  27. package/dist/workspace-runtime/workspaces/git-credential-helper.js +4 -2
  28. package/dist/workspace-runtime/workspaces/git-direct-store.js +12 -11
  29. package/dist/workspace-runtime/workspaces/prepare.js +20 -5
  30. package/dist/workspace-runtime/workspaces/registry.js +63 -12
  31. package/dist/workspace-runtime/workspaces/revisions.js +5 -5
  32. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -2
  33. package/package.json +3 -3
  34. package/project-templates/batch-locks/phaser.yaml +922 -0
  35. 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 { workspaceRoot } from './workspaces/registry.js';
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 id = basename(cwd);
194
- if (await workspaceRoot(state, id) !== cwd)
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 ?? 'single';
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 workspaceRoot(state, operation.workspaceId),
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 workspaceRoot(state, operation.workspaceId),
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 workspaceRoot(state, operation.workspaceId), body.attachmentIds);
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' }));
@@ -311,7 +312,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
311
312
  async function rebindSession(state, operation, binding, modelConfigId) {
312
313
  const sessionId = required(operation.sessionId, 'sessionId');
313
314
  const adapter = native(state);
314
- const cwd = await workspaceRoot(state, operation.workspaceId);
315
+ const cwd = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
315
316
  const group = state.journal.executionGroup();
316
317
  try {
317
318
  if (binding && adapter.resumeThread && (!group.replaced || group.stopConfirmed)) {
@@ -340,6 +341,7 @@ async function rebindSession(state, operation, binding, modelConfigId) {
340
341
  const next = {
341
342
  sessionId,
342
343
  workspaceId: operation.workspaceId,
344
+ directoryName: operation.directoryName,
343
345
  threadId: started.threadId,
344
346
  cwd,
345
347
  recoverability: 'native',
@@ -362,6 +364,9 @@ async function executeInterrupt(state, scopeKey, operation) {
362
364
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
363
365
  }
364
366
  const result = await native(state).interrupt({ sessionId, threadId: binding.threadId });
367
+ if (result.outcome === 'failed') {
368
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: result.error ?? 'turn_not_running' }));
369
+ }
365
370
  if (result.outcome !== 'accepted') {
366
371
  return persist(state, scopeKey, transitionOperation(operation, 'uncertain', { error: 'native_receipt_lost' }));
367
372
  }
@@ -457,13 +462,13 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
457
462
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'revision_conflict' }));
458
463
  }
459
464
  try {
460
- const root = await workspaceRoot(state, operation.workspaceId);
465
+ const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
461
466
  const written = await writeWorkspaceFile(root, parsed.data.path, parsed.data.content);
462
467
  const revision = currentRevision + 1;
463
468
  await state.journal.putFileRevision(parsed.data.path, {
464
469
  revision,
465
470
  contentHash: written.contentHash,
466
- }, revisionScope(state, operation.workspaceId));
471
+ }, revisionScope(state, operation.workspaceId, operation.directoryName));
467
472
  if (parsed.data.hold && parsed.data.writeId) {
468
473
  let pid;
469
474
  if (writer === 'background') {
@@ -498,7 +503,7 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
498
503
  const code = error instanceof InvalidWorkspacePathError ? 'invalid_workspace_path' : 'file_write_failed';
499
504
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: code }));
500
505
  }
501
- }).catch((error) => {
506
+ }, operation.directoryName).catch((error) => {
502
507
  const code = error instanceof InvalidWorkspacePathError ? 'invalid_workspace_path' : 'file_write_failed';
503
508
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: code }));
504
509
  });
@@ -509,7 +514,7 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
509
514
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_commit' }));
510
515
  }
511
516
  try {
512
- const sha = await commitWorkspaceGit(await workspaceRoot(state, operation.workspaceId), parsed.data, boundBranch(state, operation));
517
+ const sha = await commitWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), parsed.data, boundBranch(state, operation));
513
518
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
514
519
  await recordNativeEvents(state, accepted, [
515
520
  {
@@ -525,9 +530,10 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
525
530
  }
526
531
  }
527
532
  /**
528
- * Saves the workspace to Git: commit everything that changed, then push a Remix branch. A
529
- * standalone workspace has no remote, and the Git gateway never lets a workspace write its
530
- * project's main branch, so those two only commit.
533
+ * Saves the workspace directory to Git: commit everything that changed, then push a Remix branch
534
+ * (a `remix-*` directory is always on its own Remix branch). A standalone workspace has no remote,
535
+ * and the Git gateway never lets a workspace write its project's main branch, so those two only
536
+ * commit.
531
537
  */
532
538
  async function executeWorkspaceSave(state, scopeKey, operation, payload) {
533
539
  const parsed = workspaceSavePayloadSchema.safeParse(payload ?? {});
@@ -536,9 +542,9 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
536
542
  }
537
543
  const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
538
544
  try {
539
- const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId), {
545
+ const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), {
540
546
  message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
541
- push: binding?.targetKind === 'remix',
547
+ push: binding?.targetKind === 'remix' || Boolean(operation.directoryName && operation.directoryName !== 'basic'),
542
548
  boundBranch: boundBranch(state, operation),
543
549
  });
544
550
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
@@ -561,7 +567,7 @@ async function executeGitPush(state, scopeKey, operation, payload) {
561
567
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
562
568
  }
563
569
  try {
564
- await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId), boundBranch(state, operation));
570
+ await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), boundBranch(state, operation));
565
571
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
566
572
  await recordNativeEvents(state, accepted, [
567
573
  {
@@ -589,7 +595,7 @@ async function executeGitIndex(state, scopeKey, operation, payload) {
589
595
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_paths' }));
590
596
  }
591
597
  try {
592
- await change.run(await workspaceRoot(state, operation.workspaceId), parsed.data);
598
+ await change.run(await workspaceRoot(state, operation.workspaceId, operation.directoryName), parsed.data);
593
599
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
594
600
  await recordNativeEvents(state, accepted, [
595
601
  { type: change.event, payload: { paths: parsed.data.paths }, occurredAt: new Date().toISOString() },
@@ -605,8 +611,8 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
605
611
  if (!parsed.success) {
606
612
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_parameter_patch' }));
607
613
  }
608
- const root = await workspaceRoot(state, operation.workspaceId);
609
- const scope = revisionScope(state, operation.workspaceId);
614
+ const root = await workspaceRoot(state, operation.workspaceId, operation.directoryName);
615
+ const scope = revisionScope(state, operation.workspaceId, operation.directoryName);
610
616
  const document = await readWorkspaceParameters(root, state.journal.fileRevisions(scope));
611
617
  if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
612
618
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'parameter_data_missing' }));
@@ -642,7 +648,7 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
642
648
  catch (error) {
643
649
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterPatchError(error) }));
644
650
  }
645
- }).catch((error) => persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterPatchError(error) })));
651
+ }, operation.directoryName).catch((error) => persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterPatchError(error) })));
646
652
  }
647
653
  /**
648
654
  * A preview build takes minutes, so the request returns once the operation is accepted; Runtime
@@ -678,7 +684,7 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
678
684
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_terminal_create' }));
679
685
  }
680
686
  const existing = state.journal.getTerminal(terminalId);
681
- if (existing && existing.workspaceId !== workspaceId) {
687
+ if (existing && (existing.workspaceId !== workspaceId || (existing.directoryName ?? 'basic') !== (operation.directoryName ?? 'basic'))) {
682
688
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'terminal_already_bound' }));
683
689
  }
684
690
  if (existing?.status === 'destroyed') {
@@ -689,6 +695,7 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
689
695
  const record = await state.journal.putTerminal({
690
696
  terminalId,
691
697
  workspaceId,
698
+ directoryName: operation.directoryName,
692
699
  environment: state.config.environment,
693
700
  userId: state.config.userId,
694
701
  runtimeId: state.config.runtimeId,
@@ -711,7 +718,12 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
711
718
  }
712
719
  /** The branch a registered workspace is bound to; the single-workspace mode has none. */
713
720
  function boundBranch(state, operation) {
714
- return operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId)?.branchName ?? undefined : undefined;
721
+ const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
722
+ if (!binding)
723
+ return undefined;
724
+ return operation.directoryName && operation.directoryName !== 'basic'
725
+ ? binding.directories?.find(directory => directory.directoryName === operation.directoryName)?.branchName
726
+ : binding.branchName ?? undefined;
715
727
  }
716
728
  function gitErrorCode(error) {
717
729
  return error instanceof GitWorkspaceError ? error.code : 'git_command_failed';
@@ -764,6 +776,7 @@ export async function recordNativeEvents(state, operation, events) {
764
776
  ...(asRecord(event.payload)),
765
777
  ...(operation.sessionId ? { sessionId: operation.sessionId } : {}),
766
778
  ...(operation.workspaceId ? { workspaceId: operation.workspaceId } : {}),
779
+ ...(operation.directoryName ? { directoryName: operation.directoryName } : {}),
767
780
  };
768
781
  recorded.push(await state.journal.allocateSourceEvent({
769
782
  runtimeId: state.config.runtimeId,