@playcraft/cli 0.1.72 → 0.1.74

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.
@@ -9,6 +9,7 @@ import { promisify } from 'node:util';
9
9
  import { createRequire } from 'node:module';
10
10
  import { canonicalReplicaJson, replicaRecordingMetadataSchema, } from '@playcraft/common/agent-tools';
11
11
  import { prepareArtifactDirectory, writeJsonAtomic } from '../video-replication/evidence.js';
12
+ import { shareVideoEvidence } from '../video-replication/evidence-share.js';
12
13
  import { probeVideo } from '../video-replication/media.js';
13
14
  import { VideoCliError } from '../video-replication/messages.js';
14
15
  import { computeProjectInputDigest } from './project-digest.js';
@@ -101,6 +102,8 @@ async function recordOne(browser, root, url, scenario, variant, signal) {
101
102
  const consolePath = join(directory, 'console.jsonl');
102
103
  await writeFile(eventPath, '', { flag: 'wx', mode: 0o600 });
103
104
  await writeFile(consolePath, '', { flag: 'wx', mode: 0o600 });
105
+ await shareVideoEvidence(eventPath, 'file');
106
+ await shareVideoEvidence(consolePath, 'file');
104
107
  const context = await browser.newContext({
105
108
  viewport: { width: scenario.viewport.width, height: scenario.viewport.height }, deviceScaleFactor: scenario.viewport.deviceScaleFactor,
106
109
  hasTouch: scenario.pointer === 'touch', isMobile: scenario.pointer === 'touch', serviceWorkers: 'block',
@@ -2,14 +2,24 @@ import { randomUUID } from 'node:crypto';
2
2
  import { lstat, mkdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { dirname, extname, isAbsolute, join, parse, resolve, sep } from 'node:path';
4
4
  import { ComparisonCliError } from './comparison-messages.js';
5
+ import { shareVideoEvidence } from './evidence-share.js';
6
+ /** Creates one private directory; one this run created is shared inside a cloud workspace. */
7
+ async function makeOutputDirectory(path) {
8
+ const created = await mkdir(path, { mode: 0o700 }).then(() => true, error => {
9
+ if (error.code !== 'EEXIST')
10
+ throw error;
11
+ return false;
12
+ });
13
+ if (created)
14
+ await shareVideoEvidence(path, 'directory');
15
+ }
5
16
  export async function comparisonOutputRoot(root, create = false) {
6
17
  const absolute = resolve(root), prefix = parse(absolute).root;
7
18
  let current = prefix;
8
19
  for (const part of absolute.slice(prefix.length).split(sep).filter(Boolean)) {
9
20
  current = join(current, part);
10
21
  if (create)
11
- await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
12
- throw error; });
22
+ await makeOutputDirectory(current);
13
23
  const stat = await lstat(current);
14
24
  if (stat.isSymbolicLink()) {
15
25
  // macOS exposes system-managed temporary directories through these aliases.
@@ -32,8 +42,7 @@ export async function comparisonOutputPath(root, relative) {
32
42
  const parts = relative.split('/');
33
43
  for (const part of parts.slice(0, -1)) {
34
44
  current = join(current, part);
35
- await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
36
- throw error; });
45
+ await makeOutputDirectory(current);
37
46
  const stat = await lstat(current);
38
47
  if (!stat.isDirectory() || stat.isSymbolicLink())
39
48
  throw new ComparisonCliError('unsafeOutput', 64);
@@ -51,6 +60,7 @@ export async function writeComparisonArtifact(root, relative, writer) {
51
60
  const temporary = join(dirname(target), `.comparison-${randomUUID()}.tmp${extname(target)}`);
52
61
  try {
53
62
  await writer(temporary);
63
+ await shareVideoEvidence(temporary, 'file');
54
64
  await comparisonOutputPath(root, relative);
55
65
  await rename(temporary, target);
56
66
  }
@@ -0,0 +1,25 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { isInsideRoot, SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, sharePathWithGroup, workspaceGroup, } from '../workspace-runtime/workspaces/shared-write.js';
4
+ /** The cloud workspace checkout above `path`: its control process writes this context file. */
5
+ async function cloudWorkspaceRoot(path) {
6
+ for (let current = resolve(path);; current = dirname(current)) {
7
+ const context = await lstat(join(current, '.playcraft/workspace-context.json')).catch(() => undefined);
8
+ if (context?.isFile())
9
+ return current;
10
+ if (dirname(current) === current)
11
+ return undefined;
12
+ }
13
+ }
14
+ /**
15
+ * Evidence is created private (0700/0600) so a local run never exposes it to other users. In a
16
+ * cloud workspace the controller is a second identity that lists and opens these files for the
17
+ * user, so an entry this run created gets the development group instead. Ownership elsewhere is
18
+ * left to the workspace share repair.
19
+ */
20
+ export async function shareVideoEvidence(path, kind) {
21
+ const root = await cloudWorkspaceRoot(dirname(path));
22
+ if (!root || resolve(path) === root || !isInsideRoot(root, path))
23
+ return;
24
+ await sharePathWithGroup(path, await workspaceGroup(root), kind === 'directory' ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE);
25
+ }
@@ -109,6 +109,9 @@ export class RuntimeJournal {
109
109
  getWorkspace(workspaceId) {
110
110
  return this.snapshot.workspaces[workspaceId];
111
111
  }
112
+ listWorkspaces() {
113
+ return Object.values(this.snapshot.workspaces);
114
+ }
112
115
  async putWorkspace(binding) {
113
116
  const parsed = workspaceRegistrationSchema.parse(binding);
114
117
  await this.run('upsertRecord', { bucket: 'workspaces', key: parsed.workspaceId, body: parsed });
@@ -33,7 +33,7 @@ import { revisionScope } from './workspaces/registry.js';
33
33
  import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
34
34
  import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
35
35
  import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
36
- import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
36
+ import { platformExportTarget, readPlatformExport, startPlatformExport, syncWorkspaceBindings } from './workspaces/platform-export.js';
37
37
  import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
38
38
  import { executionContextToolHandler } from './workspaces/execution-context.js';
39
39
  import { registerLevelEditorRequest, startLevelEditorRequestQueue, stopLevelEditorRequestQueue } from './workspaces/level-editor-requests.js';
@@ -352,6 +352,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
352
352
  const port = await listenOn(server, config.listen.host, config.listen.port);
353
353
  startLevelEditorRequestQueue(state);
354
354
  startSkillActivationRequestQueue(state);
355
+ // After listening: the platform registers a missed binding through this server before it replies.
356
+ void syncWorkspaceBindings(state);
355
357
  return {
356
358
  config: state.config,
357
359
  state,
@@ -17,7 +17,7 @@ import { listLevelEditorTasks } from '../workspaces/level-editor-agent.js';
17
17
  import { listLevelEditorRequests } from '../workspaces/level-editor-requests.js';
18
18
  import { listSkillActivationRequests } from '../workspaces/skill-activation-requests.js';
19
19
  import { readDependencies } from '../workspaces/dependencies.js';
20
- import { permissionDriftFromError } from '../workspaces/workspace-shares.js';
20
+ import { permissionDriftFromError, withShareRepair } from '../workspaces/workspace-shares.js';
21
21
  import { desktopHost, desktopRoute } from './desktop-host.js';
22
22
  import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
23
23
  import { readAttachmentUpload, uploadWorkspaceAttachment, uploadWorkspaceVideoAttachment, } from '../workspaces/attachments.js';
@@ -42,7 +42,7 @@ import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
42
42
  import { WorkspaceArtifactStore } from '../workspaces/artifacts.js';
43
43
  import { OperationLogStore } from '../workspaces/operation-log.js';
44
44
  import { acceptOperationCore, observeLevelEditorTransaction } from './accept-operation.js';
45
- import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-workspace/runtime';
45
+ import { workspaceArtifactContentQuerySchema, workspaceBindingIdentity } from '@playcraft/common/cloud-workspace/runtime';
46
46
  import { searchWorkspaceFiles } from '../workspaces/files-search.js';
47
47
  import { serveWorkspaceArchive } from '../workspaces/archive.js';
48
48
  import { verifyRuntimeAssetProvenance } from '../workspaces/asset-provenance-verifier.js';
@@ -251,6 +251,8 @@ async function dispatchRuntimeRequest(state, request, response) {
251
251
  ...(state.native?.startPrediction ? { predictionVersion: 1 } : {}),
252
252
  sourceReadToken: state.journal.sourceReadToken(),
253
253
  workspaceMode: state.config.workspaceMode,
254
+ // Runtime compares these with Business and registers again a binding this sandbox missed.
255
+ workspaceBindings: state.journal.listWorkspaces().map(workspaceBindingIdentity),
254
256
  executionGroup: {
255
257
  replaced: state.journal.executionGroup().replaced,
256
258
  stopConfirmed: state.journal.executionGroup().stopConfirmed,
@@ -438,7 +440,7 @@ async function dispatchRuntimeRequest(state, request, response) {
438
440
  const body = asRecord(await readJson(request));
439
441
  const workspaceId = maintenance[1];
440
442
  writeJson(response, 200, maintenance[2] === 'cleanup'
441
- ? await cleanupWorkspaceDirectories(state, workspaceId, body.directoryNames)
443
+ ? await cleanupWorkspaceDirectories(state, workspaceId, body.directoryNames, body.archived === true ? { archived: true } : undefined)
442
444
  : await syncWorkspaceDirectories(state, workspaceId, body.commitHash, body.targetDirectoryNames));
443
445
  return;
444
446
  }
@@ -896,8 +898,11 @@ async function dispatchRuntimeRequest(state, request, response) {
896
898
  if (writeDestroyed(state, response))
897
899
  return;
898
900
  const relativePath = url.searchParams.get('path') ?? '';
901
+ const fileWorkspaceId = url.searchParams.get('workspaceId') ?? undefined;
899
902
  try {
900
- writeJson(response, 200, await readObservedFile(state, url.searchParams.get('workspaceId') ?? undefined, relativePath, directoryName));
903
+ // A file that lost development-group access is repaired once before the read is reported.
904
+ const root = await workspaceRoot(state, fileWorkspaceId, directoryName);
905
+ writeJson(response, 200, await withShareRepair(state, root, () => readObservedFile(state, fileWorkspaceId, relativePath, directoryName)));
901
906
  }
902
907
  catch (error) {
903
908
  if (error instanceof InvalidWorkspacePathError) {
@@ -3,13 +3,15 @@ import { randomUUID } from 'node:crypto';
3
3
  import { lstat, rename, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { workspaceDirectoryRegistrationSchema } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { workspaceDirectoryRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { workspaceContainerRoot, workspaceRoot, WorkspaceContextError } from './registry.js';
8
8
  import { openDirectoryToDevelopment } from './directory-access.js';
9
9
  import { prepareWorkspaceCheckout } from './prepare.js';
10
10
  import { readDependencies, scheduleDependencyInstall } from './dependencies.js';
11
11
  import { revisionScope } from './registry.js';
12
- import { ensureWorkspaceSkills } from './skills.js';
12
+ import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
13
+ import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess, } from './skill-artifact-loader.js';
14
+ import { writeWorkspaceCliContext } from './cli-context.js';
13
15
  import { checkoutBatchCommit, freezeBatchCommit } from './batch-commit.js';
14
16
  import { ensureWorkspaceArtifactInstructions } from './instructions.js';
15
17
  import { shareWorkspaceRepository } from './git.js';
@@ -28,19 +30,42 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
28
30
  const binding = state.journal.getWorkspace(directory.workspaceId);
29
31
  if (!binding)
30
32
  throw new WorkspaceContextError('workspace_not_registered');
31
- const existing = binding.directories?.find(item => item.directoryName === directory.directoryName);
32
- if (existing && (existing.remixId !== directory.remixId || existing.branchName !== directory.branchName || (existing.sourceCommit !== undefined && existing.sourceCommit !== directory.sourceCommit)))
33
+ const existing = binding.directories?.find((item) => item.directoryName === directory.directoryName);
34
+ if (existing &&
35
+ (existing.remixId !== directory.remixId ||
36
+ existing.branchName !== directory.branchName ||
37
+ (existing.sourceCommit !== undefined && existing.sourceCommit !== directory.sourceCommit)))
33
38
  throw new WorkspaceContextError('workspace_directory_binding_conflict');
34
39
  const parent = await workspaceContainerRoot(state, directory.workspaceId);
35
40
  const root = join(parent, directory.directoryName);
36
- const git = async (cwd, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'credential.helper=', '-c', 'core.fsmonitor=false', '-C', cwd, ...args], {
37
- timeout: 90_000, maxBuffer: 1024 * 1024,
38
- env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
41
+ const git = async (cwd, ...args) => (await execute('git', [
42
+ '-c',
43
+ 'core.hooksPath=/dev/null',
44
+ '-c',
45
+ 'credential.helper=',
46
+ '-c',
47
+ 'core.fsmonitor=false',
48
+ '-C',
49
+ cwd,
50
+ ...args,
51
+ ], {
52
+ timeout: 90_000,
53
+ maxBuffer: 1024 * 1024,
54
+ env: {
55
+ PATH: process.env.PATH,
56
+ HOME: state.config.stateDir,
57
+ GIT_CONFIG_GLOBAL: '/dev/null',
58
+ GIT_CONFIG_NOSYSTEM: '1',
59
+ GIT_TERMINAL_PROMPT: '0',
60
+ },
39
61
  })).stdout.trim();
40
62
  const stat = await lstat(root).catch(() => undefined);
41
63
  if (stat) {
42
- if (!stat.isDirectory() || stat.isSymbolicLink() || await git(root, 'symbolic-ref', '--short', 'HEAD') !== directory.branchName
43
- || (binding.repositoryUrl !== null && await git(root, 'config', '--get', 'remote.origin.url') !== binding.repositoryUrl))
64
+ if (!stat.isDirectory() ||
65
+ stat.isSymbolicLink() ||
66
+ (await git(root, 'symbolic-ref', '--short', 'HEAD')) !== directory.branchName ||
67
+ (binding.repositoryUrl !== null &&
68
+ (await git(root, 'config', '--get', 'remote.origin.url')) !== binding.repositoryUrl))
44
69
  throw new WorkspaceContextError('workspace_checkout_mismatch');
45
70
  }
46
71
  else {
@@ -55,12 +80,29 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
55
80
  if (worktree)
56
81
  await checkoutChatWorktree(source, staging, directory.sourceCommit, state.config.stateDir);
57
82
  else if (sourceDirectoryName !== undefined) {
58
- const frozen = await freezeBatchCommit({ source: { workspaceId: directory.workspaceId, root: source },
59
- commitHash: directory.sourceCommit, snapshotId: `directory-${directory.remixId}` });
60
- await checkoutBatchCommit({ source: { workspaceId: directory.workspaceId, root: source }, frozen, destination: staging });
83
+ const frozen = await freezeBatchCommit({
84
+ source: { workspaceId: directory.workspaceId, root: source },
85
+ commitHash: directory.sourceCommit,
86
+ snapshotId: `directory-${directory.remixId}`,
87
+ });
88
+ await checkoutBatchCommit({
89
+ source: { workspaceId: directory.workspaceId, root: source },
90
+ frozen,
91
+ destination: staging,
92
+ });
61
93
  }
62
94
  else
63
- await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, staging], { timeout: 90_000, maxBuffer: 1024 * 1024 });
95
+ await execute('git', [
96
+ '-c',
97
+ 'core.hooksPath=/dev/null',
98
+ 'clone',
99
+ '--local',
100
+ '--no-hardlinks',
101
+ '--no-checkout',
102
+ '--',
103
+ source,
104
+ staging,
105
+ ], { timeout: 90_000, maxBuffer: 1024 * 1024 });
64
106
  await git(staging, 'checkout', '-b', directory.branchName, directory.sourceCommit);
65
107
  if (binding.repositoryUrl)
66
108
  await git(staging, 'remote', 'set-url', 'origin', binding.repositoryUrl);
@@ -85,7 +127,13 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
85
127
  state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, leaseFence);
86
128
  await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
87
129
  const latest = state.journal.getWorkspace(directory.workspaceId) ?? binding;
88
- await state.journal.putWorkspace({ ...latest, directories: [...(latest.directories ?? []).filter(item => item.directoryName !== directory.directoryName), { ...directory, status: 'ready' }] });
130
+ await state.journal.putWorkspace({
131
+ ...latest,
132
+ directories: [
133
+ ...(latest.directories ?? []).filter((item) => item.directoryName !== directory.directoryName),
134
+ { ...directory, status: 'ready' },
135
+ ],
136
+ });
89
137
  });
90
138
  if (state.gitCredentials && binding.repositoryUrl) {
91
139
  await state.gitCredentials.configure(state.journal.getWorkspace(directory.workspaceId) ?? binding, await state.gitCredentials.prepareDirectory(directory.workspaceId, directory.directoryName), directory.directoryName);
@@ -99,7 +147,7 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
99
147
  return;
100
148
  await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
101
149
  const binding = state.journal.getWorkspace(workspaceId);
102
- const directory = binding?.directories?.find(item => item.directoryName === directoryName);
150
+ const directory = binding?.directories?.find((item) => item.directoryName === directoryName);
103
151
  if (!binding)
104
152
  return;
105
153
  if (directory?.status !== 'reclaimed') {
@@ -113,15 +161,33 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
113
161
  throw new WorkspaceContextError('workspace_recovery_required');
114
162
  const preparation = await state.gitCredentials.prepareDirectory(workspaceId, directoryName);
115
163
  const { pendingFork: _pendingFork, directories: _directories, ...base } = binding;
116
- const target = directoryName === 'basic' ? base : { ...base, targetKind: 'remix', remixId: directory.remixId, branchName: directory.branchName };
164
+ const target = directoryName === 'basic'
165
+ ? base
166
+ : {
167
+ ...base,
168
+ targetKind: 'remix',
169
+ remixId: directory.remixId,
170
+ branchName: directory.branchName,
171
+ };
117
172
  const root = join(state.config.workspaceRoot, workspaceId, directoryName);
118
- if (!await lstat(root).catch(() => undefined))
173
+ if (!(await lstat(root).catch(() => undefined)))
119
174
  await prepareWorkspaceCheckout(state, target, preparation, state.acceptedLeaseFence, directoryName, true);
120
175
  await shareWorkspaceRepository(root);
121
- await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
176
+ await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), workspacePlatformSkillAccess(state, workspaceId), { optionalPlatformDefaults: true });
177
+ if (directoryName === 'basic')
178
+ await writeWorkspaceCliContext(root, workspaceId);
122
179
  await ensureWorkspaceArtifactInstructions(root);
123
180
  await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
124
- await state.journal.putWorkspace({ ...binding, directories: binding.directories?.map(item => item.directoryName === directoryName ? { ...item, status: 'ready' } : item) });
181
+ await state.journal.putWorkspace({
182
+ ...binding,
183
+ directories: binding.directories?.map((item) => item.directoryName === directoryName ? { ...item, status: 'ready' } : item),
184
+ });
185
+ try {
186
+ await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), (entry) => loadWorkspaceSkillArtifact(state, workspaceId, entry));
187
+ }
188
+ catch {
189
+ /* Skills reads and native turns diagnose missing artifacts; files and source repair remain available. */
190
+ }
125
191
  try {
126
192
  await state.gitCredentials.configure(binding, preparation, directoryName);
127
193
  const scope = revisionScope(state, workspaceId, directoryName);
@@ -131,7 +197,10 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
131
197
  }
132
198
  catch (error) {
133
199
  const current = state.journal.getWorkspace(workspaceId);
134
- await state.journal.putWorkspace({ ...current, directories: current.directories?.map(item => item.directoryName === directoryName ? { ...item, status: 'reclaimed' } : item) });
200
+ await state.journal.putWorkspace({
201
+ ...current,
202
+ directories: current.directories?.map((item) => item.directoryName === directoryName ? { ...item, status: 'reclaimed' } : item),
203
+ });
135
204
  throw error;
136
205
  }
137
206
  });
@@ -1,12 +1,14 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
2
+ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, relative } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { promisify } from 'node:util';
6
6
  import { workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { batchDirectoryHeld } from '../batch/occupancy.js';
8
- import { workspaceRoot, workspaceSessions } from './registry.js';
8
+ import { WorkspaceContextError } from './context-error.js';
9
+ import { residentWorkspaceDirectory, workspaceRoot, workspaceSessions } from './registry.js';
9
10
  import { withDirectoryWrite } from '../server/directory-access.js';
11
+ import { runDeveloperCommand } from './developer-command.js';
10
12
  const execute = promisify(execFile);
11
13
  const git = async (state, root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
12
14
  timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
@@ -30,10 +32,74 @@ async function assertCleanAndPushed(state, root, allowedHead) {
30
32
  if (!upstream || (Number(await git(state, root, 'rev-list', '--count', `${upstream}..HEAD`)) > 0 && await git(state, root, 'rev-parse', 'HEAD') !== allowedHead))
31
33
  throw new Error('workspace_directory_unpushed');
32
34
  }
35
+ async function cleanupRoot(state, workspaceId, directoryName, archived) {
36
+ if (state.journal.getWorkspace(workspaceId)) {
37
+ try {
38
+ return await workspaceRoot(state, workspaceId, directoryName);
39
+ }
40
+ catch (error) {
41
+ if (!archived || !(error instanceof WorkspaceContextError) || error.message !== 'workspace_directory_not_registered')
42
+ throw error;
43
+ }
44
+ }
45
+ else if (!archived)
46
+ throw new Error('workspace_not_registered');
47
+ // An archived checkout can outlive the journal that registered it.
48
+ return residentWorkspaceDirectory(state, workspaceId, directoryName);
49
+ }
50
+ /** True only when the archived checkout path is absent. A symlink or escaped path stays blocked. */
51
+ async function archivedCheckoutMissing(state, workspaceId, directoryName) {
52
+ if (!/^[0-9a-f-]{36}$/i.test(workspaceId) || !/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
53
+ return false;
54
+ const parent = await realpath(state.config.workspaceRoot);
55
+ const container = join(parent, workspaceId);
56
+ let containerStat;
57
+ try {
58
+ containerStat = await lstat(container);
59
+ }
60
+ catch (error) {
61
+ return error.code === 'ENOENT';
62
+ }
63
+ if (!containerStat.isDirectory() || containerStat.isSymbolicLink() || relative(parent, await realpath(container)) !== workspaceId)
64
+ return false;
65
+ try {
66
+ await lstat(join(container, directoryName));
67
+ return false;
68
+ }
69
+ catch (error) {
70
+ return error.code === 'ENOENT';
71
+ }
72
+ }
73
+ function shellQuote(value) {
74
+ return `'${value.replace(/'/g, `'\\''`)}'`;
75
+ }
76
+ /** The controller cannot unlink files the development user created with mode 0700. That user removes them. */
77
+ async function removeCheckout(state, root) {
78
+ try {
79
+ await rm(root, { recursive: true, force: true });
80
+ return;
81
+ }
82
+ catch (error) {
83
+ const code = error.code;
84
+ if (code !== 'EACCES' && code !== 'EPERM')
85
+ throw error;
86
+ const outcome = await runDeveloperCommand(state, {
87
+ processId: `checkout-remove-${randomUUID()}`,
88
+ cwd: dirname(root),
89
+ command: `rm -rf -- ${shellQuote(root)}`,
90
+ timeoutMs: 60_000,
91
+ onOutput: () => undefined,
92
+ });
93
+ if (outcome.code !== 0 || outcome.timedOut)
94
+ throw new Error('workspace_permission_denied');
95
+ }
96
+ await rm(root, { recursive: true, force: true });
97
+ }
33
98
  /** Keep remote branches, published previews and all journal identities; only local checkouts are reclaimed. */
34
- export async function cleanupWorkspaceDirectories(state, workspaceId, directoryNames) {
99
+ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryNames, options) {
35
100
  if (!Array.isArray(directoryNames) || directoryNames.length === 0 || directoryNames.length > 64)
36
101
  throw new Error('workspace_directories_invalid');
102
+ const archived = options?.archived === true;
37
103
  const names = [...new Set(directoryNames.map(name => workspaceDirectoryNameSchema.parse(name)))];
38
104
  return state.journal.exclusive(`maintenance:${workspaceId}`, async () => {
39
105
  const cleaned = [];
@@ -43,14 +109,26 @@ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryN
43
109
  await withDirectoryWrite(state, { workspaceId, directoryName, mode: 'exclusive', wait: false }, () => state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
44
110
  if (await workspaceDirectoryOccupied(state, workspaceId, directoryName))
45
111
  throw new Error('workspace_directory_occupied');
46
- const root = await workspaceRoot(state, workspaceId, directoryName);
47
- await assertCleanAndPushed(state, root);
112
+ const root = await cleanupRoot(state, workspaceId, directoryName, archived).catch(async (error) => {
113
+ if (!archived || !(error instanceof WorkspaceContextError) || error.message !== 'workspace_not_prepared' || !await archivedCheckoutMissing(state, workspaceId, directoryName))
114
+ throw error;
115
+ return null;
116
+ });
117
+ // Archived projects are reclaimed to free the sandbox. Active projects keep uncommitted work.
118
+ if (!archived) {
119
+ if (root === null)
120
+ throw new Error('workspace_not_prepared');
121
+ await assertCleanAndPushed(state, root);
122
+ }
48
123
  const binding = state.journal.getWorkspace(workspaceId);
49
- // Persist recoverable identity before removing the checkout; a retry cannot silently adopt another branch.
50
- const existing = binding.directories?.find(item => item.directoryName === directoryName);
51
- const directory = existing ?? { workspaceId, directoryName, remixId: binding.remixId, branchName: binding.branchName };
52
- await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directoryName), { ...directory, status: 'reclaimed' }] });
53
- await rm(root, { recursive: true });
124
+ if (binding) {
125
+ // Persist recoverable identity before removing the checkout; a retry cannot silently adopt another branch.
126
+ const existing = binding.directories?.find(item => item.directoryName === directoryName);
127
+ const directory = existing ?? { workspaceId, directoryName, remixId: binding.remixId, branchName: binding.branchName };
128
+ await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directoryName), { ...directory, status: 'reclaimed' }] });
129
+ }
130
+ if (root)
131
+ await removeCheckout(state, root);
54
132
  cleaned.push(directoryName);
55
133
  }));
56
134
  }
@@ -31,12 +31,11 @@ export async function platformExportTarget(state, scope) {
31
31
  return { projectId: binding.projectId, root };
32
32
  }
33
33
  /**
34
- * A standalone workspace has no remote to export from. The platform gives it a project (as its
35
- * first save does) and registers it again; the registration reaches this runtime before the reply.
34
+ * Asks the platform about this workspace's binding. `promote` gives a standalone workspace a
35
+ * project (as its first save does); both register the binding Business holds again when this
36
+ * runtime lacks it. The registration reaches this runtime before the reply.
36
37
  */
37
- async function promoteStandalone(state, scope) {
38
- if (state.journal.getWorkspace(scope.workspaceId)?.targetKind !== 'standalone' || scope.directoryName !== 'basic')
39
- return;
38
+ async function requestBinding(state, workspaceId, intent) {
40
39
  const config = state.config;
41
40
  const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.workspacePromotions), {
42
41
  method: 'POST',
@@ -44,7 +43,7 @@ async function promoteStandalone(state, scope) {
44
43
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
45
44
  headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
46
45
  body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: state.runtimeEpoch,
47
- workspaceId: scope.workspaceId }),
46
+ workspaceId, ...(intent === 'sync' ? { intent } : {}) }),
48
47
  });
49
48
  if (!response.ok) {
50
49
  const body = await response.json().catch(() => undefined);
@@ -52,6 +51,28 @@ async function promoteStandalone(state, scope) {
52
51
  }
53
52
  await response.body?.cancel().catch(() => undefined);
54
53
  }
54
+ /**
55
+ * Basic needs a project to export from. Without one in the journal, the workspace is either still
56
+ * standalone or holds a binding whose registration never arrived; the platform settles both.
57
+ */
58
+ async function ensureProjectBinding(state, scope) {
59
+ if (scope.directoryName !== 'basic' || state.journal.getWorkspace(scope.workspaceId)?.projectId)
60
+ return;
61
+ await requestBinding(state, scope.workspaceId, 'promote');
62
+ }
63
+ /**
64
+ * On start, asks that each binding this runtime holds still match Business, so one whose
65
+ * registration never arrived is registered again before anything needs it. Best effort.
66
+ */
67
+ export async function syncWorkspaceBindings(state) {
68
+ if (state.config.workspaceMode !== 'multi' || !state.config.publicBaseUrl || !state.config.controlToken)
69
+ return;
70
+ for (const binding of state.journal.listWorkspaces()) {
71
+ await requestBinding(state, binding.workspaceId, 'sync').catch((error) => {
72
+ console.warn('[workspace-runtime] binding sync failed:', error instanceof Error ? error.message : error);
73
+ });
74
+ }
75
+ }
55
76
  const credentialSchema = z.object({ apiUrl: z.string().url(), token: z.string().min(1) });
56
77
  async function request(state, path, init) {
57
78
  await ensureCliCredentials(state).catch(() => { throw new PlatformExportError('cli_credentials_unavailable'); });
@@ -86,13 +107,16 @@ async function request(state, path, init) {
86
107
  * Saves the directory (commit + push), then exports that commit for the channels through the
87
108
  * platform, as the delivery panel's Export does. Each channel uses that panel's fixed HTML or
88
109
  * ZIP choice. Other options repeat the channel's latest export of the branch, or stay empty
89
- * without one. A standalone workspace first becomes a project.
110
+ * without one. A standalone workspace first becomes a project, and a binding this runtime missed
111
+ * is registered again.
90
112
  */
91
113
  export async function startPlatformExport(state, input) {
92
- await promoteStandalone(state, input);
114
+ await ensureProjectBinding(state, input);
93
115
  const target = await platformExportTarget(state, input);
116
+ // The platform replied that the workspace has a project, but this runtime still holds none.
94
117
  if (!target)
95
- throw new PlatformExportError('platform_export_requires_project');
118
+ throw new PlatformExportError(input.directoryName === 'basic'
119
+ ? 'workspace_registration_not_delivered' : 'platform_export_requires_project');
96
120
  const saved = await submitTrustedOperation(state, {
97
121
  kind: 'workspace.save',
98
122
  workspaceId: input.workspaceId,
@@ -23,6 +23,12 @@ export function workspaceSessions(state, workspaceId, directoryName = 'basic') {
23
23
  .listSessions()
24
24
  .filter((item) => state.config.workspaceMode !== 'multi' || (item.workspaceId === workspaceId && (item.directoryName ?? 'basic') === directoryName));
25
25
  }
26
+ /** A checkout that outlived the journal. Cleanup of an archived workspace is the only caller. */
27
+ export async function residentWorkspaceDirectory(state, workspaceId, directoryName) {
28
+ if (!/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
29
+ throw new WorkspaceContextError('invalid_workspace_directory');
30
+ return checkedDirectory(state, workspaceId, directoryName);
31
+ }
26
32
  export async function workspaceRoot(state, workspaceId, directoryName = 'basic') {
27
33
  if (state.config.workspaceMode !== 'multi')
28
34
  return state.config.workspaceRoot;
@@ -11,6 +11,7 @@ export const WORKSPACE_SHARED_PATHS = [
11
11
  '.playcraft/artifact-receipts',
12
12
  '.playcraft/skills',
13
13
  '.playcraft/skill-artifacts',
14
+ '.playcraft/video-replication',
14
15
  '.playcraft-temp',
15
16
  'dist',
16
17
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -57,8 +57,8 @@
57
57
  "@gltf-transform/core": "^4.3.0",
58
58
  "@gltf-transform/extensions": "^4.3.0",
59
59
  "@gltf-transform/functions": "^4.3.0",
60
- "@playcraft/build": "0.1.72",
61
- "@playcraft/common": "0.1.72",
60
+ "@playcraft/build": "0.1.74",
61
+ "@playcraft/common": "0.1.74",
62
62
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
63
63
  "chokidar": "^4.0.3",
64
64
  "commander": "^13.1.0",