@playcraft/cli 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/tools-batch.js +59 -0
- package/dist/commands/tools.js +2 -0
- package/dist/workspace-runtime/codex/app-server.js +16 -5
- package/dist/workspace-runtime/codex/model-broker.js +5 -5
- package/dist/workspace-runtime/previews/publish.js +7 -4
- package/dist/workspace-runtime/production/contracts.js +17 -0
- package/dist/workspace-runtime/production/controller.js +207 -0
- package/dist/workspace-runtime/production/dependencies.js +60 -0
- package/dist/workspace-runtime/production/native.js +70 -0
- package/dist/workspace-runtime/production/processes.js +86 -0
- package/dist/workspace-runtime/production/save-command.js +20 -0
- package/dist/workspace-runtime/production/stages.js +175 -0
- package/dist/workspace-runtime/production/storage.js +32 -0
- package/dist/workspace-runtime/serve.js +3 -6
- package/dist/workspace-runtime/server/desktop-host.js +1 -1
- package/dist/workspace-runtime/server/desktop-preview.js +5 -5
- package/dist/workspace-runtime/server/dispatch.js +38 -24
- package/dist/workspace-runtime/server/http.js +117 -33
- package/dist/workspace-runtime/server/terminal-host.js +6 -1
- package/dist/workspace-runtime/workspaces/batch-commit.js +142 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +5 -3
- package/dist/workspace-runtime/workspaces/directories.js +106 -0
- package/dist/workspace-runtime/workspaces/directory-access.js +20 -0
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +111 -0
- package/dist/workspace-runtime/workspaces/directory-stats.js +49 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +63 -30
- package/dist/workspace-runtime/workspaces/git-credential-helper.js +4 -2
- package/dist/workspace-runtime/workspaces/git-direct-store.js +12 -11
- package/dist/workspace-runtime/workspaces/prepare.js +20 -5
- package/dist/workspace-runtime/workspaces/registry.js +63 -12
- package/dist/workspace-runtime/workspaces/revisions.js +5 -5
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -2
- package/package.json +3 -3
- package/project-templates/batch-locks/phaser.yaml +922 -0
- package/project-templates/batch-locks/threejs.yaml +978 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
|
|
2
|
-
import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
2
|
+
import { registerWorkspace, revisionScope, sessionWorkspaceRoot, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, isLostThreadError, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
6
6
|
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
7
7
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
@@ -16,9 +16,14 @@ import { readWorkspaceSkills } from '../workspaces/skills.js';
|
|
|
16
16
|
import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { terminalHost } from './terminal-host.js';
|
|
19
|
+
import { productionCapability, productionDirectoryBusy, executeSandboxProduction, sandboxProductionReceipt } from '../production/controller.js';
|
|
20
|
+
import { cleanupWorkspaceDirectories, syncWorkspaceDirectories } from '../workspaces/directory-maintenance.js';
|
|
21
|
+
import { registerWorkspaceDirectory, restoreWorkspaceDirectory } from '../workspaces/directories.js';
|
|
22
|
+
import { readWorkspaceDirectoryStats } from '../workspaces/directory-stats.js';
|
|
19
23
|
import { probeRuntimeConnectivity } from './connectivity.js';
|
|
20
24
|
export async function handleRuntimeRequest(state, request, response) {
|
|
21
25
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
26
|
+
const directoryName = url.searchParams.get('directoryName') ?? 'basic';
|
|
22
27
|
const access = authorizeRuntime(state, request);
|
|
23
28
|
if (!access) {
|
|
24
29
|
writeError(response, runtimeError('unauthenticated', 'control_token_required'));
|
|
@@ -29,6 +34,21 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
29
34
|
return;
|
|
30
35
|
}
|
|
31
36
|
try {
|
|
37
|
+
if (request.method === 'GET' && url.pathname === '/runtime/production/capability') {
|
|
38
|
+
writeJson(response, 200, await productionCapability(state));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (request.method === 'POST' && ['/runtime/production/stages', '/runtime/production/stop', '/runtime/production/reconcile'].includes(url.pathname)) {
|
|
42
|
+
writeJson(response, 200, await executeSandboxProduction(state, await readJson(request)));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (request.method === 'GET' && url.pathname.startsWith('/runtime/production/stages/')) {
|
|
46
|
+
writeJson(response, 200, await sandboxProductionReceipt(state, decodeURIComponent(url.pathname.slice('/runtime/production/stages/'.length)), {
|
|
47
|
+
workspaceId: url.searchParams.get('workspaceId') ?? '', directoryName, taskId: url.searchParams.get('taskId') ?? '',
|
|
48
|
+
attemptId: url.searchParams.get('attemptId') ?? '', owner: url.searchParams.get('owner') ?? '', fence: Number(url.searchParams.get('fence')),
|
|
49
|
+
}));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
32
52
|
if (request.method === 'GET' && url.pathname === '/runtime/health') {
|
|
33
53
|
writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
|
|
34
54
|
return;
|
|
@@ -42,7 +62,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
42
62
|
return;
|
|
43
63
|
}
|
|
44
64
|
const workspaceId = parsed.data;
|
|
45
|
-
await
|
|
65
|
+
await sessionWorkspaceRoot(state, workspaceId, directoryName);
|
|
46
66
|
writeJson(response, 200, await probeRuntimeConnectivity(state, workspaceId));
|
|
47
67
|
return;
|
|
48
68
|
}
|
|
@@ -82,6 +102,34 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
82
102
|
writeJson(response, 200, { workspaceId: binding.workspaceId, status: 'registered' });
|
|
83
103
|
return;
|
|
84
104
|
}
|
|
105
|
+
if (request.method === 'POST' && url.pathname === '/runtime/workspaces/directories/register') {
|
|
106
|
+
if (writeDestroyed(state, response))
|
|
107
|
+
return;
|
|
108
|
+
const body = asRecord(await readJson(request));
|
|
109
|
+
if (body.generation !== state.config.generation || body.runtimeEpoch !== state.runtimeEpoch || !Number.isSafeInteger(body.leaseFence)) {
|
|
110
|
+
writeJson(response, 409, { error: 'invalid_workspace_registration' });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
writeJson(response, 200, await registerWorkspaceDirectory(state, body.directory, Number(body.leaseFence)));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const maintenance = /^\/runtime\/workspaces\/([a-f0-9-]{36})\/directories\/(cleanup|sync|stats)$/.exec(url.pathname);
|
|
117
|
+
if ((request.method === 'GET' || request.method === 'POST') && maintenance?.[2] === 'stats') {
|
|
118
|
+
if (writeDestroyed(state, response))
|
|
119
|
+
return;
|
|
120
|
+
writeJson(response, 200, await readWorkspaceDirectoryStats(state, maintenance[1]));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (request.method === 'POST' && maintenance) {
|
|
124
|
+
if (writeDestroyed(state, response))
|
|
125
|
+
return;
|
|
126
|
+
const body = asRecord(await readJson(request));
|
|
127
|
+
const workspaceId = maintenance[1];
|
|
128
|
+
writeJson(response, 200, maintenance[2] === 'cleanup'
|
|
129
|
+
? await cleanupWorkspaceDirectories(state, workspaceId, body.directoryNames)
|
|
130
|
+
: await syncWorkspaceDirectories(state, workspaceId, body.commitHash, body.targetDirectoryNames));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
85
133
|
if (request.method === 'POST' && url.pathname === '/runtime/endpoints') {
|
|
86
134
|
try {
|
|
87
135
|
writeJson(response, 200, await applyRuntimeEndpointPatch(state, await readJson(request)));
|
|
@@ -114,7 +162,11 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
114
162
|
if (writeDestroyed(state, response))
|
|
115
163
|
return;
|
|
116
164
|
const workspaceId = url.searchParams.get('workspaceId') ?? '';
|
|
117
|
-
|
|
165
|
+
if (await productionDirectoryBusy(state, workspaceId, directoryName)) {
|
|
166
|
+
writeJson(response, 409, { error: 'batch_workspace_readonly' });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
118
170
|
try {
|
|
119
171
|
writeJson(response, 200, await uploadWorkspaceAttachment(root, await readAttachmentUpload(request)));
|
|
120
172
|
}
|
|
@@ -127,8 +179,8 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
127
179
|
if (writeDestroyed(state, response))
|
|
128
180
|
return;
|
|
129
181
|
const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
130
|
-
const root = await workspaceRoot(state, workspaceId);
|
|
131
|
-
const listed = await listWorkspaceFiles(root, state.journal.fileRevisions(revisionScope(state, workspaceId)));
|
|
182
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
183
|
+
const listed = await listWorkspaceFiles(root, state.journal.fileRevisions(revisionScope(state, workspaceId, directoryName)));
|
|
132
184
|
console.log(`[workspace-files] GET /runtime/files workspaceId=${workspaceId ?? '-'} root=${root} files=${listed.files.length}`);
|
|
133
185
|
writeJson(response, 200, listed);
|
|
134
186
|
return;
|
|
@@ -138,7 +190,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
138
190
|
return;
|
|
139
191
|
const relativePath = url.searchParams.get('path') ?? '';
|
|
140
192
|
try {
|
|
141
|
-
writeJson(response, 200, await readObservedFile(state, url.searchParams.get('workspaceId') ?? undefined, relativePath));
|
|
193
|
+
writeJson(response, 200, await readObservedFile(state, url.searchParams.get('workspaceId') ?? undefined, relativePath, directoryName));
|
|
142
194
|
}
|
|
143
195
|
catch (error) {
|
|
144
196
|
if (error instanceof InvalidWorkspacePathError) {
|
|
@@ -154,11 +206,11 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
154
206
|
return;
|
|
155
207
|
}
|
|
156
208
|
if (request.method === 'GET' && url.pathname === '/runtime/git/status') {
|
|
157
|
-
await writeGitRead(state, response, async () => readWorkspaceGitStatus(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
|
|
209
|
+
await writeGitRead(state, response, async () => readWorkspaceGitStatus(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName)));
|
|
158
210
|
return;
|
|
159
211
|
}
|
|
160
212
|
if (request.method === 'GET' && url.pathname === '/runtime/git/diff') {
|
|
161
|
-
await writeGitRead(state, response, async () => readWorkspaceGitDiff(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
|
|
213
|
+
await writeGitRead(state, response, async () => readWorkspaceGitDiff(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName)));
|
|
162
214
|
return;
|
|
163
215
|
}
|
|
164
216
|
if (request.method === 'GET' && url.pathname === '/runtime/git/file') {
|
|
@@ -167,15 +219,15 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
167
219
|
writeJson(response, 400, { error: 'invalid_git_ref' });
|
|
168
220
|
return;
|
|
169
221
|
}
|
|
170
|
-
await writeGitRead(state, response, async () => readWorkspaceGitFile(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined), url.searchParams.get('path') ?? '', ref.data));
|
|
222
|
+
await writeGitRead(state, response, async () => readWorkspaceGitFile(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName), url.searchParams.get('path') ?? '', ref.data));
|
|
171
223
|
return;
|
|
172
224
|
}
|
|
173
225
|
if (request.method === 'GET' && url.pathname === '/runtime/dependencies') {
|
|
174
226
|
if (writeDestroyed(state, response))
|
|
175
227
|
return;
|
|
176
228
|
const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
177
|
-
await workspaceRoot(state, workspaceId);
|
|
178
|
-
writeJson(response, 200, readDependencies(state, workspaceId ?? 'workspace'));
|
|
229
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
230
|
+
writeJson(response, 200, readDependencies(state, revisionScope(state, workspaceId, directoryName) ?? 'workspace'));
|
|
179
231
|
return;
|
|
180
232
|
}
|
|
181
233
|
if (request.method === 'GET' && url.pathname === '/runtime/skills') {
|
|
@@ -183,7 +235,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
183
235
|
return;
|
|
184
236
|
const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
185
237
|
try {
|
|
186
|
-
const view = await state.journal.exclusive(`workspace:${revisionScope(state, workspaceId) ?? 'single'}`, async () => readWorkspaceSkills(await workspaceRoot(state, workspaceId), join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, workspaceId ?? 'workspace', entry)));
|
|
238
|
+
const view = await state.journal.exclusive(`workspace:${revisionScope(state, workspaceId, directoryName) ?? 'single'}`, async () => readWorkspaceSkills(await workspaceRoot(state, workspaceId, directoryName), join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, workspaceId ?? 'workspace', entry, directoryName)));
|
|
187
239
|
writeJson(response, 200, view);
|
|
188
240
|
}
|
|
189
241
|
catch (error) {
|
|
@@ -194,20 +246,20 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
194
246
|
return;
|
|
195
247
|
}
|
|
196
248
|
if (request.method === 'GET' && url.pathname === '/runtime/git/graph') {
|
|
197
|
-
await writeGitRead(state, response, async () => readWorkspaceGitGraph(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
|
|
249
|
+
await writeGitRead(state, response, async () => readWorkspaceGitGraph(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName)));
|
|
198
250
|
return;
|
|
199
251
|
}
|
|
200
252
|
if (request.method === 'GET' && url.pathname === '/runtime/parameters') {
|
|
201
253
|
if (writeDestroyed(state, response))
|
|
202
254
|
return;
|
|
203
|
-
writeJson(response, 200, await readObservedParameters(state, url.searchParams.get('workspaceId') ?? undefined));
|
|
255
|
+
writeJson(response, 200, await readObservedParameters(state, url.searchParams.get('workspaceId') ?? undefined, directoryName));
|
|
204
256
|
return;
|
|
205
257
|
}
|
|
206
258
|
if (request.method === 'GET' && url.pathname === '/runtime/terminals') {
|
|
207
259
|
if (writeDestroyed(state, response))
|
|
208
260
|
return;
|
|
209
261
|
writeJson(response, 200, {
|
|
210
|
-
terminals: state.journal.listTerminals(url.searchParams.get('workspaceId') ?? undefined),
|
|
262
|
+
terminals: state.journal.listTerminals(url.searchParams.get('workspaceId') ?? undefined).filter(terminal => (terminal.directoryName ?? 'basic') === directoryName),
|
|
211
263
|
});
|
|
212
264
|
return;
|
|
213
265
|
}
|
|
@@ -358,8 +410,19 @@ async function acceptOperation(state, request, response) {
|
|
|
358
410
|
writeJson(response, 400, { error: 'invalid_operation', reason: 'sessionId_required' });
|
|
359
411
|
return;
|
|
360
412
|
}
|
|
413
|
+
if (parsed.data.directoryName === 'root' && !parsed.data.kind.startsWith('session.') && parsed.data.kind !== 'runtime.ensure') {
|
|
414
|
+
writeJson(response, 400, { error: 'invalid_workspace_directory' });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId)
|
|
418
|
+
await restoreWorkspaceDirectory(state, parsed.data.workspaceId, parsed.data.directoryName);
|
|
361
419
|
if (state.config.workspaceMode === 'multi')
|
|
362
|
-
await
|
|
420
|
+
await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
|
|
421
|
+
const mutation = !['runtime.ensure', 'runtime.destroy', 'session.interrupt', 'session.approval.respond'].includes(parsed.data.kind);
|
|
422
|
+
if (mutation && await productionDirectoryBusy(state, parsed.data.workspaceId, parsed.data.directoryName)) {
|
|
423
|
+
writeJson(response, 409, { error: 'batch_workspace_readonly' });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
363
426
|
const payloadHash = hashOperationPayload(parsed.data.payload);
|
|
364
427
|
const scopeKey = operationScopeKey({
|
|
365
428
|
environment: state.config.environment,
|
|
@@ -367,6 +430,7 @@ async function acceptOperation(state, request, response) {
|
|
|
367
430
|
kind: parsed.data.kind,
|
|
368
431
|
clientRequestId: parsed.data.clientRequestId,
|
|
369
432
|
workspaceId: parsed.data.workspaceId,
|
|
433
|
+
directoryName: parsed.data.directoryName,
|
|
370
434
|
sessionId: parsed.data.sessionId,
|
|
371
435
|
});
|
|
372
436
|
const accept = () => state.journal.exclusive(scopeKey, async () => {
|
|
@@ -383,7 +447,12 @@ async function acceptOperation(state, request, response) {
|
|
|
383
447
|
}));
|
|
384
448
|
return;
|
|
385
449
|
}
|
|
386
|
-
|
|
450
|
+
// A turn refused for a lost thread never reached Codex. Sent again with the conversation, it
|
|
451
|
+
// runs afresh under the same request, so the message is shown and answered only once.
|
|
452
|
+
const retryWithRecovery = decision.action === 'reuse' && parsed.data.kind === 'session.turn'
|
|
453
|
+
&& Boolean(parsed.data.sessionRecovery) && decision.operation.status === 'failed'
|
|
454
|
+
&& isLostThreadError(decision.operation.error);
|
|
455
|
+
if (decision.action === 'reuse' && !retryWithRecovery) {
|
|
387
456
|
if (decision.operation.status === 'uncertain') {
|
|
388
457
|
writeError(response, runtimeError('uncertain_operation', decision.operation.error ?? 'native_receipt_lost', {
|
|
389
458
|
operationId: decision.operation.operationId,
|
|
@@ -423,7 +492,8 @@ async function acceptOperation(state, request, response) {
|
|
|
423
492
|
const session = state.journal.getSession(parsed.data.sessionId);
|
|
424
493
|
// A replaced sandbox has no binding yet; the recovering turn creates it.
|
|
425
494
|
const rebinding = !session && parsed.data.kind === 'session.turn' && parsed.data.sessionRecovery;
|
|
426
|
-
if (!rebinding && (!session || session.workspaceId !== parsed.data.workspaceId
|
|
495
|
+
if (!rebinding && (!session || session.workspaceId !== parsed.data.workspaceId
|
|
496
|
+
|| (session.directoryName ?? 'basic') !== (parsed.data.directoryName ?? 'basic'))) {
|
|
427
497
|
writeJson(response, 409, { error: 'session_workspace_mismatch' });
|
|
428
498
|
return;
|
|
429
499
|
}
|
|
@@ -440,6 +510,7 @@ async function acceptOperation(state, request, response) {
|
|
|
440
510
|
status: executable ? 'queued' : 'failed',
|
|
441
511
|
payloadHash,
|
|
442
512
|
workspaceId: parsed.data.workspaceId,
|
|
513
|
+
directoryName: parsed.data.directoryName,
|
|
443
514
|
sessionId: parsed.data.sessionId,
|
|
444
515
|
expectedRevision: parsed.data.expectedRevision,
|
|
445
516
|
generation: state.config.generation,
|
|
@@ -474,19 +545,32 @@ async function acceptOperation(state, request, response) {
|
|
|
474
545
|
}
|
|
475
546
|
writeJson(response, executed.status === 'failed' ? 409 : 202, executed);
|
|
476
547
|
});
|
|
477
|
-
|
|
478
|
-
await state.
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
548
|
+
const acceptBound = async () => {
|
|
549
|
+
if (mutation && await productionDirectoryBusy(state, parsed.data.workspaceId, parsed.data.directoryName)) {
|
|
550
|
+
writeJson(response, 409, { error: 'batch_workspace_readonly' });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (state.config.workspaceMode === 'multi')
|
|
554
|
+
await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
|
|
555
|
+
if (parsed.data.kind === 'session.create')
|
|
556
|
+
await state.journal.exclusive(`session:${parsed.data.sessionId}`, accept);
|
|
557
|
+
else if (parsed.data.kind === 'terminal.create') {
|
|
558
|
+
const terminalId = typeof asRecord(parsed.data.payload).terminalId === 'string'
|
|
559
|
+
? String(asRecord(parsed.data.payload).terminalId).trim()
|
|
560
|
+
: '';
|
|
561
|
+
if (terminalId)
|
|
562
|
+
await state.journal.exclusive(`terminal:${terminalId}`, accept);
|
|
563
|
+
else
|
|
564
|
+
await accept();
|
|
565
|
+
}
|
|
485
566
|
else
|
|
486
567
|
await accept();
|
|
487
|
-
}
|
|
568
|
+
};
|
|
569
|
+
// Production admission and maintenance use this same lock before checking durable occupancy.
|
|
570
|
+
if (mutation && state.config.workspaceMode === 'multi')
|
|
571
|
+
await state.journal.exclusive(`directory:${parsed.data.workspaceId}:${parsed.data.directoryName ?? 'basic'}`, acceptBound);
|
|
488
572
|
else
|
|
489
|
-
await
|
|
573
|
+
await acceptBound();
|
|
490
574
|
}
|
|
491
575
|
async function writeGitRead(state, response, read) {
|
|
492
576
|
if (writeDestroyed(state, response))
|
|
@@ -633,13 +717,13 @@ async function handleSessionHistory(state, response, workspaceId, sessionId) {
|
|
|
633
717
|
}
|
|
634
718
|
writeJson(response, 200, body.data);
|
|
635
719
|
}
|
|
636
|
-
async function readObservedParameters(state, workspaceId) {
|
|
637
|
-
const root = await workspaceRoot(state, workspaceId);
|
|
638
|
-
const scope = revisionScope(state, workspaceId);
|
|
720
|
+
async function readObservedParameters(state, workspaceId, directoryName = 'basic') {
|
|
721
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
722
|
+
const scope = revisionScope(state, workspaceId, directoryName);
|
|
639
723
|
const first = await readWorkspaceParameters(root, state.journal.fileRevisions(scope));
|
|
640
724
|
for (const path of [first.schemaPath, first.dataPath]) {
|
|
641
725
|
if (path)
|
|
642
|
-
await withObservedFile(state, workspaceId, path, async () => undefined);
|
|
726
|
+
await withObservedFile(state, workspaceId, path, async () => undefined, directoryName);
|
|
643
727
|
}
|
|
644
728
|
return readWorkspaceParameters(root, state.journal.fileRevisions(scope));
|
|
645
729
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { productionDirectoryBusy } from '../production/controller.js';
|
|
1
2
|
import { createRequire } from 'node:module';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { lstat } from 'node:fs/promises';
|
|
@@ -59,6 +60,10 @@ export class TerminalHost {
|
|
|
59
60
|
rejectUpgrade(socket, 404, { error: 'terminal_not_found' });
|
|
60
61
|
return;
|
|
61
62
|
}
|
|
63
|
+
if (await productionDirectoryBusy(this.state, terminal.workspaceId, terminal.directoryName)) {
|
|
64
|
+
rejectUpgrade(socket, 409, { error: 'batch_workspace_readonly' });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
62
67
|
if (terminal.status === 'destroyed') {
|
|
63
68
|
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
|
|
64
69
|
return;
|
|
@@ -258,7 +263,7 @@ function exitFrame(session) {
|
|
|
258
263
|
};
|
|
259
264
|
}
|
|
260
265
|
async function terminalDirectory(state, terminal) {
|
|
261
|
-
const root = await workspaceRoot(state, terminal.workspaceId);
|
|
266
|
+
const root = await workspaceRoot(state, terminal.workspaceId, terminal.directoryName);
|
|
262
267
|
if (!terminal.cwd)
|
|
263
268
|
return root;
|
|
264
269
|
const resolved = await resolveWorkspaceFile(root, terminal.cwd);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { lstat, mkdir, open, realpath, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { batchCommitSourceVersion } from '@playcraft/common/cloud-workspace/runtime';
|
|
8
|
+
import { validateProjectSkillsSnapshot } from '@playcraft/common/project-skills';
|
|
9
|
+
const execute = promisify(execFile);
|
|
10
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
11
|
+
const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
12
|
+
const PRIVATE_NAMES = new Set([
|
|
13
|
+
'.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store', '.config',
|
|
14
|
+
'.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json',
|
|
15
|
+
'credentials', 'secrets', 'id_rsa', 'id_ed25519', 'settings.local.json',
|
|
16
|
+
]);
|
|
17
|
+
const DEPENDENCY_LOCKS = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'bun.lock', 'bun.lockb'];
|
|
18
|
+
const SKILLS_DECLARATION = '.playcraft/skills.json';
|
|
19
|
+
const SKILLS_LOCK = '.playcraft/skills.lock.json';
|
|
20
|
+
const sha256 = (body) => createHash('sha256').update(body).digest('hex');
|
|
21
|
+
async function git(root, args, encoding = 'utf8') {
|
|
22
|
+
const { stdout } = await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
|
|
23
|
+
timeout: 90_000, maxBuffer: 64 * 1024 * 1024, encoding: 'buffer',
|
|
24
|
+
env: { PATH: process.env.PATH, HOME: root, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
25
|
+
});
|
|
26
|
+
return encoding === 'utf8' ? Buffer.from(stdout.toString('utf8').trim()) : stdout;
|
|
27
|
+
}
|
|
28
|
+
function assertSource(source) {
|
|
29
|
+
if (!ID.test(source.workspaceId) || !isAbsolute(source.root))
|
|
30
|
+
throw new Error('batch_snapshot_scope_invalid');
|
|
31
|
+
}
|
|
32
|
+
function engineeringPath(path) {
|
|
33
|
+
return !path.split('/').some((name) => PRIVATE_NAMES.has(name) || /^\.env(?:$|\.)/.test(name) || /\.(?:pem|key|p12|pfx)$/.test(name));
|
|
34
|
+
}
|
|
35
|
+
function lockedArtifacts(declaration, lock) {
|
|
36
|
+
const snapshot = validateProjectSkillsSnapshot(JSON.parse(declaration.toString('utf8')), JSON.parse(lock.toString('utf8')));
|
|
37
|
+
return snapshot.lock.entries.map((entry) => `.playcraft/skill-artifacts/${entry.revision.artifactHash}.tar.gz`);
|
|
38
|
+
}
|
|
39
|
+
function inside(root, path) {
|
|
40
|
+
const rel = relative(root, path);
|
|
41
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
42
|
+
}
|
|
43
|
+
/** Locked skill archives are kept out of Git; each one is named by the hash of its exact bytes. */
|
|
44
|
+
async function readArtifact(root, path) {
|
|
45
|
+
const full = join(root, ...path.split('/'));
|
|
46
|
+
if (!inside(root, await realpath(dirname(full)).catch(() => '')))
|
|
47
|
+
throw new Error('batch_snapshot_skill_artifact_missing');
|
|
48
|
+
const handle = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => undefined);
|
|
49
|
+
if (!handle)
|
|
50
|
+
throw new Error('batch_snapshot_skill_artifact_missing');
|
|
51
|
+
try {
|
|
52
|
+
const stat = await handle.stat();
|
|
53
|
+
if (!stat.isFile() || stat.nlink !== 1)
|
|
54
|
+
throw new Error('batch_snapshot_skill_artifact_missing');
|
|
55
|
+
const body = await handle.readFile();
|
|
56
|
+
if (sha256(body) !== path.slice(path.lastIndexOf('/') + 1, -'.tar.gz'.length))
|
|
57
|
+
throw new Error('batch_snapshot_skill_artifact_missing');
|
|
58
|
+
return body;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await handle.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function treeChecksum(root, commitHash) {
|
|
65
|
+
const listing = await git(root, ['ls-tree', '-r', '--full-tree', '-z', commitHash], 'buffer');
|
|
66
|
+
const paths = listing.toString('utf8').split('\0').filter(Boolean).map((line) => line.slice(line.indexOf('\t') + 1));
|
|
67
|
+
return { checksum: sha256(listing), paths };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Freezes a commit of `basic` as batch input. The commit is already saved by Git, so later edits to
|
|
71
|
+
* `basic` never reach the batch; nothing is copied to separate storage. A commit is accepted only
|
|
72
|
+
* when it carries the dependency lock and skill lock its build needs and no credential files.
|
|
73
|
+
*/
|
|
74
|
+
export async function freezeBatchCommit(input) {
|
|
75
|
+
assertSource(input.source);
|
|
76
|
+
if (!COMMIT.test(input.commitHash) || !ID.test(input.snapshotId))
|
|
77
|
+
throw new Error('batch_snapshot_commit_required');
|
|
78
|
+
const root = await realpath(input.source.root);
|
|
79
|
+
const resolved = (await git(root, ['rev-parse', '--verify', '--quiet', `${input.commitHash}^{commit}`]).catch(() => Buffer.from(''))).toString();
|
|
80
|
+
if (resolved !== input.commitHash)
|
|
81
|
+
throw new Error('batch_snapshot_commit_required');
|
|
82
|
+
const { checksum, paths } = await treeChecksum(root, input.commitHash);
|
|
83
|
+
if (paths.some((path) => !engineeringPath(path)))
|
|
84
|
+
throw new Error('batch_snapshot_path_invalid');
|
|
85
|
+
const tracked = new Set(paths);
|
|
86
|
+
if (!tracked.has('package.json') || !DEPENDENCY_LOCKS.some((path) => tracked.has(path)))
|
|
87
|
+
throw new Error('batch_snapshot_dependency_lock_required');
|
|
88
|
+
if (!tracked.has(SKILLS_DECLARATION) || !tracked.has(SKILLS_LOCK))
|
|
89
|
+
throw new Error('batch_snapshot_skills_lock_required');
|
|
90
|
+
const declaration = await git(root, ['show', `${input.commitHash}:${SKILLS_DECLARATION}`], 'buffer');
|
|
91
|
+
const lock = await git(root, ['show', `${input.commitHash}:${SKILLS_LOCK}`], 'buffer');
|
|
92
|
+
for (const path of lockedArtifacts(declaration, lock))
|
|
93
|
+
if (!tracked.has(path))
|
|
94
|
+
await readArtifact(root, path);
|
|
95
|
+
const committedAt = (await git(root, ['show', '-s', '--format=%cI', input.commitHash])).toString();
|
|
96
|
+
return {
|
|
97
|
+
snapshotId: input.snapshotId, sourceWorkspaceId: input.source.workspaceId, commitHash: input.commitHash, checksum,
|
|
98
|
+
sourceVersion: batchCommitSourceVersion(input.commitHash), skillLockDigest: sha256(lock),
|
|
99
|
+
frozenAt: new Date(committedAt).toISOString(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Checks the frozen commit out into a new private directory and adds the locked skill archives. A
|
|
104
|
+
* pre-existing destination is never reused, and the checkout must still match the frozen checksum.
|
|
105
|
+
*/
|
|
106
|
+
export async function checkoutBatchCommit(input) {
|
|
107
|
+
assertSource(input.source);
|
|
108
|
+
if (!isAbsolute(input.destination) || !COMMIT.test(input.frozen.commitHash))
|
|
109
|
+
throw new Error('batch_snapshot_scope_invalid');
|
|
110
|
+
if (input.frozen.sourceWorkspaceId !== input.source.workspaceId)
|
|
111
|
+
throw new Error('batch_snapshot_identity_mismatch');
|
|
112
|
+
if (await lstat(input.destination).catch(() => undefined))
|
|
113
|
+
throw new Error('batch_snapshot_destination_exists');
|
|
114
|
+
const root = await realpath(input.source.root);
|
|
115
|
+
const { checksum } = await treeChecksum(root, input.frozen.commitHash);
|
|
116
|
+
if (checksum !== input.frozen.checksum)
|
|
117
|
+
throw new Error('batch_snapshot_identity_mismatch');
|
|
118
|
+
await mkdir(dirname(input.destination), { recursive: true });
|
|
119
|
+
try {
|
|
120
|
+
await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', root, input.destination], {
|
|
121
|
+
timeout: 90_000, maxBuffer: 1024 * 1024,
|
|
122
|
+
env: { PATH: process.env.PATH, HOME: dirname(input.destination), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
123
|
+
});
|
|
124
|
+
await git(input.destination, ['checkout', '--detach', input.frozen.commitHash]);
|
|
125
|
+
const lock = await git(input.destination, ['show', `${input.frozen.commitHash}:${SKILLS_LOCK}`], 'buffer');
|
|
126
|
+
if (sha256(lock) !== input.frozen.skillLockDigest)
|
|
127
|
+
throw new Error('batch_snapshot_identity_mismatch');
|
|
128
|
+
const declaration = await git(input.destination, ['show', `${input.frozen.commitHash}:${SKILLS_DECLARATION}`], 'buffer');
|
|
129
|
+
for (const path of lockedArtifacts(declaration, lock)) {
|
|
130
|
+
const target = join(input.destination, ...path.split('/'));
|
|
131
|
+
if (await lstat(target).catch(() => undefined))
|
|
132
|
+
continue;
|
|
133
|
+
const body = await readArtifact(root, path);
|
|
134
|
+
await mkdir(dirname(target), { recursive: true });
|
|
135
|
+
await writeFile(target, body, { flag: 'wx', mode: 0o600 });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (cause) {
|
|
139
|
+
await rm(input.destination, { recursive: true, force: true });
|
|
140
|
+
throw cause;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -11,11 +11,11 @@ const LOG_TAIL_MAX_CHARS = 8_000;
|
|
|
11
11
|
const WRITE_PREFIX = 'dependencies-';
|
|
12
12
|
const SHARE_WAIT_MS = 60_000;
|
|
13
13
|
/**
|
|
14
|
-
* Projects are managed with pnpm.
|
|
14
|
+
* Projects are managed with pnpm. Frozen installation preserves pnpm-lock.yaml, never
|
|
15
15
|
* prompts, and writes plain lines to the log. Package scripts run: the workspace is the user's own
|
|
16
16
|
* sandbox and the install runs as the development user.
|
|
17
17
|
*/
|
|
18
|
-
const INSTALL_COMMAND = 'pnpm install --reporter=append-only --config.confirm-modules-purge=false';
|
|
18
|
+
const INSTALL_COMMAND = 'pnpm install --frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --reporter=append-only --config.confirm-modules-purge=false';
|
|
19
19
|
/** Installs whenever there is a package.json; the hash says whether a finished install is current. */
|
|
20
20
|
export async function planInstall(root) {
|
|
21
21
|
const manifest = await readOptional(join(root, 'package.json'));
|
|
@@ -159,7 +159,9 @@ async function installOnce(state, workspaceId, root) {
|
|
|
159
159
|
*/
|
|
160
160
|
async function runInstall(state, workspaceId, root) {
|
|
161
161
|
// Tests replace the install; a real registry is never reached from them.
|
|
162
|
-
const
|
|
162
|
+
const store = process.env.PLAYCRAFT_PNPM_STORE ?? (process.platform === 'linux' ? '/var/cache/playcraft/pnpm-store' : join(state.config.stateDir, 'pnpm-store'));
|
|
163
|
+
const quotedStore = `'${store.replaceAll("'", "'\\''")}'`;
|
|
164
|
+
const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || INSTALL_COMMAND.replace('/var/cache/playcraft/pnpm-store', quotedStore);
|
|
163
165
|
const logDir = join(state.config.stateDir, 'dependency-logs');
|
|
164
166
|
await mkdir(logDir, { recursive: true, mode: 0o700 });
|
|
165
167
|
const logPath = join(logDir, `${workspaceId}.log`);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { lstat, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { workspaceDirectoryRegistrationSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { workspaceContainerRoot, workspaceRoot, WorkspaceContextError } from './registry.js';
|
|
8
|
+
import { openDirectoryToDevelopment } from './directory-access.js';
|
|
9
|
+
import { prepareWorkspaceCheckout } from './prepare.js';
|
|
10
|
+
import { readDependencies, scheduleDependencyInstall } from './dependencies.js';
|
|
11
|
+
import { revisionScope } from './registry.js';
|
|
12
|
+
import { ensureWorkspaceSkills } from './skills.js';
|
|
13
|
+
const execute = promisify(execFile);
|
|
14
|
+
/** Trusted Runtime registration clones an immutable local commit; it never follows basic's current branch head. */
|
|
15
|
+
export async function registerWorkspaceDirectory(state, input, leaseFence) {
|
|
16
|
+
const directory = workspaceDirectoryRegistrationSchema.parse(input);
|
|
17
|
+
if (directory.directoryName === 'basic')
|
|
18
|
+
throw new WorkspaceContextError('workspace_directory_binding_conflict');
|
|
19
|
+
return state.journal.exclusive(`directory:${directory.workspaceId}:${directory.directoryName}`, async () => {
|
|
20
|
+
if (state.journal.destroyed())
|
|
21
|
+
throw new WorkspaceContextError('runtime_destroyed');
|
|
22
|
+
if (leaseFence < state.acceptedLeaseFence)
|
|
23
|
+
throw new WorkspaceContextError('stale_lease');
|
|
24
|
+
const binding = state.journal.getWorkspace(directory.workspaceId);
|
|
25
|
+
if (!binding || !binding.repositoryUrl)
|
|
26
|
+
throw new WorkspaceContextError('workspace_not_registered');
|
|
27
|
+
const existing = binding.directories?.find(item => item.directoryName === directory.directoryName);
|
|
28
|
+
if (existing && (existing.remixId !== directory.remixId || existing.branchName !== directory.branchName || (existing.sourceCommit !== undefined && existing.sourceCommit !== directory.sourceCommit)))
|
|
29
|
+
throw new WorkspaceContextError('workspace_directory_binding_conflict');
|
|
30
|
+
const parent = await workspaceContainerRoot(state, directory.workspaceId);
|
|
31
|
+
const root = join(parent, directory.directoryName);
|
|
32
|
+
const git = async (cwd, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'credential.helper=', '-c', 'core.fsmonitor=false', '-C', cwd, ...args], {
|
|
33
|
+
timeout: 90_000, maxBuffer: 1024 * 1024,
|
|
34
|
+
env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
35
|
+
})).stdout.trim();
|
|
36
|
+
const stat = await lstat(root).catch(() => undefined);
|
|
37
|
+
if (stat) {
|
|
38
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await git(root, 'symbolic-ref', '--short', 'HEAD') !== directory.branchName || await git(root, 'config', '--get', 'remote.origin.url') !== binding.repositoryUrl)
|
|
39
|
+
throw new WorkspaceContextError('workspace_checkout_mismatch');
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
if (existing && existing.status !== 'pending')
|
|
43
|
+
throw new WorkspaceContextError('workspace_recovery_required');
|
|
44
|
+
if (!directory.sourceCommit)
|
|
45
|
+
throw new WorkspaceContextError('workspace_directory_source_commit_required');
|
|
46
|
+
const source = await workspaceRoot(state, directory.workspaceId, 'basic');
|
|
47
|
+
await git(source, 'cat-file', '-e', `${directory.sourceCommit}^{commit}`);
|
|
48
|
+
const staging = join(parent, `.directory-${randomUUID()}`);
|
|
49
|
+
try {
|
|
50
|
+
await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, staging], { timeout: 90_000, maxBuffer: 1024 * 1024 });
|
|
51
|
+
await git(staging, 'checkout', '-b', directory.branchName, directory.sourceCommit);
|
|
52
|
+
await git(staging, 'remote', 'set-url', 'origin', binding.repositoryUrl);
|
|
53
|
+
await git(staging, 'config', 'user.name', 'PlayCraft');
|
|
54
|
+
await git(staging, 'config', 'user.email', 'playcraft@localhost');
|
|
55
|
+
await ensureWorkspaceSkills(staging, join(state.config.stateDir, 'skill-artifacts'));
|
|
56
|
+
if (leaseFence < state.acceptedLeaseFence)
|
|
57
|
+
throw new WorkspaceContextError('stale_lease');
|
|
58
|
+
await rename(staging, root);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await rm(staging, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
|
|
65
|
+
state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, leaseFence);
|
|
66
|
+
await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
|
|
67
|
+
await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directory.directoryName), { ...directory, status: 'ready' }] });
|
|
68
|
+
if (state.gitCredentials)
|
|
69
|
+
await state.gitCredentials.configure(binding, await state.gitCredentials.prepareDirectory(directory.workspaceId, directory.directoryName), directory.directoryName);
|
|
70
|
+
return directory;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** A reclaimed checkout recovers its existing remote branch; no new variant or moving basic head is used. */
|
|
74
|
+
export async function restoreWorkspaceDirectory(state, workspaceId, directoryName = 'basic') {
|
|
75
|
+
if (directoryName === 'root')
|
|
76
|
+
return;
|
|
77
|
+
await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
78
|
+
const binding = state.journal.getWorkspace(workspaceId);
|
|
79
|
+
const directory = binding?.directories?.find(item => item.directoryName === directoryName);
|
|
80
|
+
if (!binding || directory?.status !== 'reclaimed')
|
|
81
|
+
return;
|
|
82
|
+
if (binding.targetKind === 'standalone' || !state.gitCredentials)
|
|
83
|
+
throw new WorkspaceContextError('workspace_recovery_required');
|
|
84
|
+
const preparation = await state.gitCredentials.prepareDirectory(workspaceId, directoryName);
|
|
85
|
+
const { pendingFork: _pendingFork, directories: _directories, ...base } = binding;
|
|
86
|
+
const target = directoryName === 'basic' ? base : { ...base, targetKind: 'remix', remixId: directory.remixId, branchName: directory.branchName };
|
|
87
|
+
const root = join(state.config.workspaceRoot, workspaceId, directoryName);
|
|
88
|
+
if (!await lstat(root).catch(() => undefined))
|
|
89
|
+
await prepareWorkspaceCheckout(state, target, preparation, state.acceptedLeaseFence, directoryName, true);
|
|
90
|
+
await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
|
|
91
|
+
await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
|
|
92
|
+
await state.journal.putWorkspace({ ...binding, directories: binding.directories?.map(item => item.directoryName === directoryName ? { ...item, status: 'ready' } : item) });
|
|
93
|
+
try {
|
|
94
|
+
await state.gitCredentials.configure(binding, preparation, directoryName);
|
|
95
|
+
const scope = revisionScope(state, workspaceId, directoryName);
|
|
96
|
+
await scheduleDependencyInstall(state, scope, root);
|
|
97
|
+
if (!['ready', 'none'].includes(readDependencies(state, scope).status))
|
|
98
|
+
throw new WorkspaceContextError('workspace_directory_restore_dependencies_failed');
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const current = state.journal.getWorkspace(workspaceId);
|
|
102
|
+
await state.journal.putWorkspace({ ...current, directories: current.directories?.map(item => item.directoryName === directoryName ? { ...item, status: 'reclaimed' } : item) });
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { chmod, chown, lstat, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
|
|
4
|
+
export async function openDirectoryToDevelopment(root, groupId) {
|
|
5
|
+
const visit = async (path) => {
|
|
6
|
+
const stat = await lstat(path);
|
|
7
|
+
if (stat.isSymbolicLink())
|
|
8
|
+
return;
|
|
9
|
+
if (stat.gid !== groupId)
|
|
10
|
+
await chown(path, stat.uid, groupId);
|
|
11
|
+
if (stat.isDirectory()) {
|
|
12
|
+
await chmod(path, (stat.mode & 0o777) | 0o2770);
|
|
13
|
+
for (const entry of await readdir(path))
|
|
14
|
+
await visit(join(path, entry));
|
|
15
|
+
}
|
|
16
|
+
else if (stat.isFile())
|
|
17
|
+
await chmod(path, (stat.mode & 0o777) | 0o660);
|
|
18
|
+
};
|
|
19
|
+
await visit(root);
|
|
20
|
+
}
|