@playcraft/cli 0.1.73 → 0.1.75

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
+ }
@@ -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';
@@ -440,7 +440,7 @@ async function dispatchRuntimeRequest(state, request, response) {
440
440
  const body = asRecord(await readJson(request));
441
441
  const workspaceId = maintenance[1];
442
442
  writeJson(response, 200, maintenance[2] === 'cleanup'
443
- ? await cleanupWorkspaceDirectories(state, workspaceId, body.directoryNames)
443
+ ? await cleanupWorkspaceDirectories(state, workspaceId, body.directoryNames, body.archived === true ? { archived: true } : undefined)
444
444
  : await syncWorkspaceDirectories(state, workspaceId, body.commitHash, body.targetDirectoryNames));
445
445
  return;
446
446
  }
@@ -898,8 +898,11 @@ async function dispatchRuntimeRequest(state, request, response) {
898
898
  if (writeDestroyed(state, response))
899
899
  return;
900
900
  const relativePath = url.searchParams.get('path') ?? '';
901
+ const fileWorkspaceId = url.searchParams.get('workspaceId') ?? undefined;
901
902
  try {
902
- 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)));
903
906
  }
904
907
  catch (error) {
905
908
  if (error instanceof InvalidWorkspacePathError) {
@@ -8,21 +8,9 @@ import { excludeFromGit } from './dependencies.js';
8
8
  import { hashAttachment, probeAttachmentVideo } from './video-attachments.js';
9
9
  import { resolveLibraryAttachment } from './library-attachments.js';
10
10
  import { resolveWorkspaceDirectoryAttachment } from './directory-attachments.js';
11
- async function attachmentRoot(root) {
12
- // Reject redirected upload directories, including symlinks that stay inside the workspace.
13
- let directory = root;
14
- for (const part of WORKSPACE_USER_UPLOADS_DIRECTORY.split('/')) {
15
- directory = join(directory, part);
16
- // shared
17
- await mkdir(directory, { mode: 0o755 }).catch((error) => {
18
- if (error.code !== 'EEXIST')
19
- throw error;
20
- });
21
- if (!(await lstat(directory)).isDirectory())
22
- throw new Error('invalid_attachment');
23
- await chmod(directory, 0o755);
24
- }
25
- return directory;
11
+ import { ensureSharedDirectory } from './shared-write.js';
12
+ function attachmentRoot(root) {
13
+ return ensureSharedDirectory(root, join(root, WORKSPACE_USER_UPLOADS_DIRECTORY));
26
14
  }
27
15
  export function imageMime(bytes) {
28
16
  if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
@@ -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
  }
@@ -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.73",
3
+ "version": "0.1.75",
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.73",
61
- "@playcraft/common": "0.1.73",
60
+ "@playcraft/build": "0.1.75",
61
+ "@playcraft/common": "0.1.75",
62
62
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
63
63
  "chokidar": "^4.0.3",
64
64
  "commander": "^13.1.0",