@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.
- package/dist/recording/recorder.js +3 -0
- package/dist/video-replication/comparison-output.js +14 -4
- package/dist/video-replication/evidence-share.js +25 -0
- package/dist/workspace-runtime/persistence/journal.js +3 -0
- package/dist/workspace-runtime/serve.js +3 -1
- package/dist/workspace-runtime/server/http.js +9 -4
- package/dist/workspace-runtime/workspaces/directories.js +89 -20
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +89 -11
- package/dist/workspace-runtime/workspaces/platform-export.js +33 -9
- package/dist/workspace-runtime/workspaces/registry.js +6 -0
- package/dist/workspace-runtime/workspaces/workspace-shares.js +1 -0
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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', [
|
|
37
|
-
|
|
38
|
-
|
|
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() ||
|
|
43
|
-
|
|
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({
|
|
59
|
-
|
|
60
|
-
|
|
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', [
|
|
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({
|
|
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'
|
|
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({
|
|
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({
|
|
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 {
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
*
|
|
35
|
-
* first save does)
|
|
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
|
|
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:
|
|
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
|
|
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('
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
61
|
-
"@playcraft/common": "0.1.
|
|
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",
|