@playcraft/cli 0.1.57-beta → 0.1.58-beta
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/workspace.js +43 -0
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/prepare.js +108 -93
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +1 -0
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- package/dist/workspace-runtime/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +11 -2
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +61 -23
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +52 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +43 -8
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +11 -0
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
|
@@ -3,7 +3,7 @@ import { observeWorkspaceDirectories } from '../workspaces/directory-revisions.j
|
|
|
3
3
|
import { registerWorkspace, revisionScope, sessionWorkspaceRoot, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
5
|
import { access as accessDirectory, stat } from 'node:fs/promises';
|
|
6
|
-
import { workspaceRegistrationRequestSchema, workspaceFileSearchQuerySchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, sourceAckSchema, operationAcceptRequestSchema, operationLogSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, takesWorkspaceDirectory, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, workspaceUiResultSchema, workspaceRemixReferenceImportRequestSchema, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_ASSET_LIBRARY_IMPORT_BODY_MAX_BYTES, workspaceSessionRestoreRequestSchema, workspaceSessionRestoreResponseSchema, deliveryValidatePayloadSchema, deliveryBuildListQuerySchema, deliveryBuildListSchema, deliveryBuildDetailSchema, deliveryBuildLogSchema, workspaceDirectoryNameSchema, attachmentIdsSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { workspaceRegistrationRequestSchema, workspaceFileSearchQuerySchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, sourceAckSchema, operationAcceptRequestSchema, operationLogSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, takesWorkspaceDirectory, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, workspaceUiResultSchema, workspaceRemixReferenceImportRequestSchema, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_ASSET_LIBRARY_IMPORT_BODY_MAX_BYTES, workspaceSessionRestoreRequestSchema, workspaceSessionRestoreResponseSchema, deliveryValidatePayloadSchema, deliveryBuildListQuerySchema, deliveryBuildListSchema, deliveryBuildDetailSchema, deliveryBuildLogSchema, workspaceDirectoryNameSchema, workspaceDirectoryRegistrationSchema, attachmentIdsSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
8
8
|
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
9
9
|
import { wakeSourceEventUploader } from './source-uploader.js';
|
|
@@ -12,6 +12,7 @@ import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitFile, readWork
|
|
|
12
12
|
import { readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
13
13
|
import { listParameterAgentTasks } from '../workspaces/parameter-agent.js';
|
|
14
14
|
import { readDependencies } from '../workspaces/dependencies.js';
|
|
15
|
+
import { permissionDriftFromError } from '../workspaces/workspace-shares.js';
|
|
15
16
|
import { desktopHost, desktopRoute } from './desktop-host.js';
|
|
16
17
|
import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
|
|
17
18
|
import { readAttachmentUpload, uploadWorkspaceAttachment, uploadWorkspaceVideoAttachment } from '../workspaces/attachments.js';
|
|
@@ -44,7 +45,31 @@ import { serveSessionImage } from '../workspaces/session-image.js';
|
|
|
44
45
|
import { restoreNativeSession, SessionRestoreError } from './session-restore.js';
|
|
45
46
|
import { handleAssetTransferRequest } from './asset-transfers.js';
|
|
46
47
|
import { readWorktreeSource, registerChatWorktree } from '../workspaces/worktrees.js';
|
|
48
|
+
import { withDirectoryWrite } from './directory-access.js';
|
|
47
49
|
export async function handleRuntimeRequest(state, request, response) {
|
|
50
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
51
|
+
const workspaceId = url.searchParams.get('workspaceId');
|
|
52
|
+
// Body-scoped operations, members and terminal control take their own lifetime-aware leases.
|
|
53
|
+
const ownLease = url.pathname.startsWith('/runtime/operations') || url.pathname.startsWith('/runtime/terminals/') ||
|
|
54
|
+
url.pathname.startsWith('/runtime/batch-') || /^\/runtime\/sessions\/[^/]+\/ui-results$/.test(url.pathname);
|
|
55
|
+
if (authorizeRuntime(state, request) === 'control' && workspaceId &&
|
|
56
|
+
(!['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET') || url.pathname === '/runtime/git-bundle') && !ownLease) {
|
|
57
|
+
try {
|
|
58
|
+
return await withDirectoryWrite(state, { workspaceId, directoryName: url.searchParams.get('directoryName') ?? 'basic', wait: false,
|
|
59
|
+
sessionId: url.searchParams.get('sessionId') ?? undefined,
|
|
60
|
+
...(url.pathname === '/runtime/git-bundle' ? { mode: 'exclusive' } : {}) }, () => dispatchRuntimeRequest(state, request, response));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (!response.headersSent)
|
|
64
|
+
writeJson(response, error instanceof Error && error.message === 'directory_busy' ? 409 : 500, { error: error instanceof Error && error.message === 'directory_busy' ? 'directory_busy' : 'runtime_request_failed' });
|
|
65
|
+
else
|
|
66
|
+
response.destroy(error instanceof Error ? error : undefined);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return dispatchRuntimeRequest(state, request, response);
|
|
71
|
+
}
|
|
72
|
+
async function dispatchRuntimeRequest(state, request, response) {
|
|
48
73
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
49
74
|
const directoryName = url.searchParams.get('directoryName') ?? 'basic';
|
|
50
75
|
const access = authorizeRuntime(state, request);
|
|
@@ -104,6 +129,8 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
104
129
|
permissionsVersion: 1,
|
|
105
130
|
composerContextVersion: 1,
|
|
106
131
|
parameterAgentVersion: 1,
|
|
132
|
+
directoryConcurrencyVersion: 1,
|
|
133
|
+
parameterInitializationVersion: 1,
|
|
107
134
|
remixReferencesVersion: 1,
|
|
108
135
|
batchSourceFreezeRevision: 2,
|
|
109
136
|
...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
|
|
@@ -189,7 +216,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
189
216
|
writeJson(response, 409, { error: 'invalid_workspace_registration' });
|
|
190
217
|
return;
|
|
191
218
|
}
|
|
192
|
-
const binding = await registerWorkspace(state, parsed.data.binding, parsed.data.leaseFence, parsed.data.preparation, parsed.data.cliCredentials === false, parsed.data.sourceSnapshot);
|
|
219
|
+
const binding = await withDirectoryWrite(state, { workspaceId: parsed.data.binding.workspaceId, directoryName: 'basic', wait: false }, () => registerWorkspace(state, parsed.data.binding, parsed.data.leaseFence, parsed.data.preparation, parsed.data.cliCredentials === false, parsed.data.sourceSnapshot));
|
|
193
220
|
if (parsed.data.cliCredentials)
|
|
194
221
|
await ensureCliCredentials(state);
|
|
195
222
|
writeJson(response, 200, { workspaceId: binding.workspaceId, status: 'registered' });
|
|
@@ -210,10 +237,13 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
210
237
|
writeJson(response, 400, { error: 'invalid_workspace_registration' });
|
|
211
238
|
return;
|
|
212
239
|
}
|
|
213
|
-
|
|
240
|
+
const directory = workspaceDirectoryRegistrationSchema.parse(body.directory);
|
|
241
|
+
writeJson(response, 200, await withDirectoryWrite(state, { ...directory, mode: 'exclusive', wait: false }, () => withDirectoryWrite(state, { workspaceId: directory.workspaceId, directoryName: source.data, mode: 'exclusive', wait: false }, () => registerChatWorktree(state, directory, Number(body.leaseFence), source.data, body.sourceBranchName, attachments.data))));
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
const directory = workspaceDirectoryRegistrationSchema.parse(body.directory);
|
|
245
|
+
writeJson(response, 200, await withDirectoryWrite(state, { ...directory, mode: 'exclusive', wait: false }, () => registerWorkspaceDirectory(state, directory, Number(body.leaseFence))));
|
|
214
246
|
}
|
|
215
|
-
else
|
|
216
|
-
writeJson(response, 200, await registerWorkspaceDirectory(state, body.directory, Number(body.leaseFence)));
|
|
217
247
|
return;
|
|
218
248
|
}
|
|
219
249
|
if (request.method === 'POST' && url.pathname === '/runtime/workspaces/directories/worktree-source') {
|
|
@@ -226,7 +256,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
226
256
|
writeJson(response, 409, { error: 'invalid_workspace_registration' });
|
|
227
257
|
return;
|
|
228
258
|
}
|
|
229
|
-
writeJson(response, 200, await readWorktreeSource(state, body.workspaceId, source.data, Number(body.leaseFence), body.sourceBranchName));
|
|
259
|
+
writeJson(response, 200, await withDirectoryWrite(state, { workspaceId: body.workspaceId, directoryName: source.data, mode: 'exclusive', wait: false }, () => readWorktreeSource(state, body.workspaceId, source.data, Number(body.leaseFence), body.sourceBranchName)));
|
|
230
260
|
return;
|
|
231
261
|
}
|
|
232
262
|
const maintenance = /^\/runtime\/workspaces\/([a-f0-9-]{36})\/directories\/(cleanup|sync|stats)$/.exec(url.pathname);
|
|
@@ -763,6 +793,10 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
763
793
|
writeJson(response, 404, { error: 'not_found' });
|
|
764
794
|
}
|
|
765
795
|
catch (error) {
|
|
796
|
+
if (error instanceof Error && error.message === 'directory_busy') {
|
|
797
|
+
writeJson(response, 409, { error: 'directory_busy' });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
766
800
|
if (request.method === 'GET' && ['/runtime/connectivity', '/runtime/files'].includes(url.pathname)) {
|
|
767
801
|
const cause = error instanceof Error && error.cause ? error.cause : error;
|
|
768
802
|
if (['EACCES', 'EPERM'].includes(String(cause?.code))) {
|
|
@@ -778,6 +812,12 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
778
812
|
writeJson(response, 413, { error: 'request_body_too_large' });
|
|
779
813
|
return;
|
|
780
814
|
}
|
|
815
|
+
// A shared path without development-group access is a repair, not an internal failure.
|
|
816
|
+
const drift = permissionDriftFromError(error);
|
|
817
|
+
if (drift) {
|
|
818
|
+
writeJson(response, 409, { error: 'workspace_permission_drift', ...drift });
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
781
821
|
if (error instanceof WorkspaceContextError) {
|
|
782
822
|
writeJson(response, 409, { error: error.message });
|
|
783
823
|
return;
|
|
@@ -858,7 +898,7 @@ async function acceptOperation(state, request, response) {
|
|
|
858
898
|
return;
|
|
859
899
|
}
|
|
860
900
|
// leaseFence 由 Runtime Server 派发,本控制面单独校验;其余代次/身份/根目录字段仍只来自引导配置。
|
|
861
|
-
const untrusted = findUntrustedRuntimeFields(body).filter((field) => field !== 'leaseFence');
|
|
901
|
+
const untrusted = findUntrustedRuntimeFields(body).filter((field) => field !== 'leaseFence' && field !== 'gatewayOperationId');
|
|
862
902
|
if (untrusted.length > 0) {
|
|
863
903
|
writeError(response, runtimeError('untrusted_runtime_field', untrusted.join(',')));
|
|
864
904
|
return;
|
|
@@ -896,7 +936,7 @@ async function acceptOperation(state, request, response) {
|
|
|
896
936
|
return;
|
|
897
937
|
}
|
|
898
938
|
if (state.config.workspaceMode === 'multi' && parsed.data.kind === 'runtime.ensure' && parsed.data.workspaceId)
|
|
899
|
-
await restoreWorkspaceDirectory(state, parsed.data.workspaceId, parsed.data.directoryName);
|
|
939
|
+
await withDirectoryWrite(state, { workspaceId: parsed.data.workspaceId, directoryName: parsed.data.directoryName, mode: 'exclusive', wait: false }, () => restoreWorkspaceDirectory(state, parsed.data.workspaceId, parsed.data.directoryName));
|
|
900
940
|
if (state.config.workspaceMode === 'multi')
|
|
901
941
|
await sessionWorkspaceRoot(state, parsed.data.workspaceId, parsed.data.directoryName);
|
|
902
942
|
// Shared classification: a new conversation writes no files, so a batch hold must not refuse it.
|
|
@@ -995,7 +1035,9 @@ async function acceptOperation(state, request, response) {
|
|
|
995
1035
|
};
|
|
996
1036
|
// Directory maintenance uses this same lock before checking durable occupancy.
|
|
997
1037
|
if (occupiesDirectory && state.config.workspaceMode === 'multi')
|
|
998
|
-
await state.journal.exclusive(
|
|
1038
|
+
await state.journal.exclusive(parsed.data.kind.startsWith('session.') && parsed.data.sessionId
|
|
1039
|
+
? `session-admission:${parsed.data.sessionId}`
|
|
1040
|
+
: `directory:${parsed.data.workspaceId}:${parsed.data.directoryName ?? 'basic'}`, acceptBound);
|
|
999
1041
|
else
|
|
1000
1042
|
await acceptBound();
|
|
1001
1043
|
}
|
|
@@ -1152,7 +1194,7 @@ async function readObservedParameters(state, workspaceId, directoryName = 'basic
|
|
|
1152
1194
|
const scope = revisionScope(state, workspaceId, directoryName);
|
|
1153
1195
|
return state.journal.exclusive(`workspace:${scope ?? 'single'}`, async () => {
|
|
1154
1196
|
const first = await readWorkspaceParameters(root, state.journal.fileRevisions(scope));
|
|
1155
|
-
for (const path of [first.schemaPath, first.dataPath]) {
|
|
1197
|
+
for (const path of [first.schemaPath, first.dataPath, first.runtimeI18n?.globalPath, first.runtimeI18n?.themePath]) {
|
|
1156
1198
|
if (path)
|
|
1157
1199
|
await withObservedFile(state, workspaceId, path, async () => undefined, directoryName);
|
|
1158
1200
|
}
|
|
@@ -7,6 +7,8 @@ export function buildOperationRecord(state, parsed, payloadHash, leaseFence, exe
|
|
|
7
7
|
: {};
|
|
8
8
|
return {
|
|
9
9
|
operationId: randomUUID(),
|
|
10
|
+
...(parsed.gatewayOperationId ? { gatewayOperationId: parsed.gatewayOperationId } : {}),
|
|
11
|
+
...(parsed.kind === 'session.turn' && payload.parameterTask !== undefined ? { directoryAccessMode: 'exclusive' } : {}),
|
|
10
12
|
environment: state.config.environment,
|
|
11
13
|
userId: state.config.userId,
|
|
12
14
|
clientRequestId: parsed.clientRequestId,
|
|
@@ -8,7 +8,9 @@ import { appendTerminalOutput, createTerminalOutputLog, decodeTerminalBytes, run
|
|
|
8
8
|
import { resolveWorkspaceFile } from '../workspaces/files.js';
|
|
9
9
|
import { sessionWorkspaceRoot } from '../workspaces/registry.js';
|
|
10
10
|
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
11
|
+
import { stopBackgroundWriter } from '../processes/managed-writes.js';
|
|
11
12
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
13
|
+
import { cancelPendingDirectoryAccess, holdDirectoryAccess } from './directory-access.js';
|
|
12
14
|
const require = createRequire(import.meta.url);
|
|
13
15
|
const commands = new WeakMap();
|
|
14
16
|
const hosts = new WeakMap();
|
|
@@ -37,6 +39,7 @@ export class TerminalHost {
|
|
|
37
39
|
closing = new Set();
|
|
38
40
|
stops = new Map();
|
|
39
41
|
confirmed = new Set();
|
|
42
|
+
directoryReleases = new Map();
|
|
40
43
|
sockets = new WebSocketServer({ noServer: true });
|
|
41
44
|
constructor(state) {
|
|
42
45
|
this.state = state;
|
|
@@ -110,7 +113,14 @@ export class TerminalHost {
|
|
|
110
113
|
return pending;
|
|
111
114
|
this.closing.add(terminalId);
|
|
112
115
|
const stopped = (async () => {
|
|
113
|
-
await this.
|
|
116
|
+
await cancelPendingDirectoryAccess(this.state, `terminal:${terminalId}`);
|
|
117
|
+
try {
|
|
118
|
+
await this.spawning.get(terminalId);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (this.attempted.has(terminalId) && !this.confirmed.has(terminalId))
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
114
124
|
const session = this.sessions.get(terminalId);
|
|
115
125
|
if (session)
|
|
116
126
|
await session.stop();
|
|
@@ -123,6 +133,7 @@ export class TerminalHost {
|
|
|
123
133
|
...terminal, status: 'destroyed', destroyedAt: new Date().toISOString(),
|
|
124
134
|
});
|
|
125
135
|
this.confirmed.add(terminalId);
|
|
136
|
+
this.releaseDirectory(terminalId);
|
|
126
137
|
this.revoke(terminalId, 'terminal_destroyed');
|
|
127
138
|
this.closing.delete(terminalId);
|
|
128
139
|
return destroyed;
|
|
@@ -139,12 +150,7 @@ export class TerminalHost {
|
|
|
139
150
|
for (const listener of session.listeners)
|
|
140
151
|
listener(frame);
|
|
141
152
|
session.listeners.clear();
|
|
142
|
-
|
|
143
|
-
session.pty.kill();
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
/* the process may already have exited */
|
|
147
|
-
}
|
|
153
|
+
void session.stop().then(() => this.exited(terminalId, session, null)).catch(() => undefined);
|
|
148
154
|
}
|
|
149
155
|
dispose() {
|
|
150
156
|
this.disposed = true;
|
|
@@ -153,6 +159,28 @@ export class TerminalHost {
|
|
|
153
159
|
this.sockets.close();
|
|
154
160
|
}
|
|
155
161
|
async spawn(terminal, cwd) {
|
|
162
|
+
if (this.closing.has(terminal.terminalId) || this.disposed || this.state.journal.destroyed() ||
|
|
163
|
+
this.state.journal.getTerminal(terminal.terminalId)?.status === 'destroyed')
|
|
164
|
+
throw new Error('pty_unavailable');
|
|
165
|
+
const release = await holdDirectoryAccess(this.state, terminal, `terminal:${terminal.terminalId}`, 'shared');
|
|
166
|
+
this.directoryReleases.set(terminal.terminalId, release);
|
|
167
|
+
try {
|
|
168
|
+
if (this.closing.has(terminal.terminalId) || this.state.journal.destroyed() ||
|
|
169
|
+
this.state.journal.getTerminal(terminal.terminalId)?.status === 'destroyed')
|
|
170
|
+
throw new Error('pty_unavailable');
|
|
171
|
+
return await this.spawnProcess(terminal, cwd);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (!this.attempted.has(terminal.terminalId) || this.confirmed.has(terminal.terminalId))
|
|
175
|
+
this.releaseDirectory(terminal.terminalId);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
releaseDirectory(terminalId) {
|
|
180
|
+
this.directoryReleases.get(terminalId)?.();
|
|
181
|
+
this.directoryReleases.delete(terminalId);
|
|
182
|
+
}
|
|
183
|
+
async spawnProcess(terminal, cwd) {
|
|
156
184
|
if (this.disposed)
|
|
157
185
|
throw new Error('pty_unavailable');
|
|
158
186
|
if (this.state.config.workspaceMode === 'multi' || process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
@@ -167,6 +195,7 @@ export class TerminalHost {
|
|
|
167
195
|
runtimeEpoch: this.state.runtimeEpoch, cwd, argv: [], env: {}, terminal: { cols: 80, rows: 24 } });
|
|
168
196
|
if (this.disposed) {
|
|
169
197
|
await child.stop();
|
|
198
|
+
this.confirmed.add(terminal.terminalId);
|
|
170
199
|
throw new Error('pty_unavailable');
|
|
171
200
|
}
|
|
172
201
|
const session = { terminalId: terminal.terminalId, streamId: randomUUID(), log: createTerminalOutputLog(), listeners: new Set(),
|
|
@@ -212,6 +241,8 @@ export class TerminalHost {
|
|
|
212
241
|
await Promise.race([knownExit, new Promise((_resolve, reject) => {
|
|
213
242
|
timer = setTimeout(() => reject(new Error('terminal_stop_unconfirmed')), 3000);
|
|
214
243
|
})]);
|
|
244
|
+
if (!await stopBackgroundWriter(session.pty.pid))
|
|
245
|
+
throw new Error('terminal_stop_unconfirmed');
|
|
215
246
|
}
|
|
216
247
|
finally {
|
|
217
248
|
clearTimeout(timer);
|
|
@@ -220,7 +251,10 @@ export class TerminalHost {
|
|
|
220
251
|
};
|
|
221
252
|
const local = session.pty;
|
|
222
253
|
local.onData((data) => publish(session, data));
|
|
223
|
-
local.onExit(({ exitCode, signal }) => {
|
|
254
|
+
local.onExit(({ exitCode, signal }) => {
|
|
255
|
+
finishExit();
|
|
256
|
+
void session.stop().then(() => this.exited(terminal.terminalId, session, exitCode, signal), () => this.exited(terminal.terminalId, session, undefined, signal)).catch(() => undefined);
|
|
257
|
+
});
|
|
224
258
|
this.sessions.set(terminal.terminalId, session);
|
|
225
259
|
return session;
|
|
226
260
|
}
|
|
@@ -235,6 +269,7 @@ export class TerminalHost {
|
|
|
235
269
|
});
|
|
236
270
|
if (terminal)
|
|
237
271
|
this.confirmed.add(terminalId);
|
|
272
|
+
this.releaseDirectory(terminalId);
|
|
238
273
|
}
|
|
239
274
|
}
|
|
240
275
|
finally {
|
|
@@ -105,7 +105,9 @@ export async function handleWorkspaceTransfer(state, request, response, url) {
|
|
|
105
105
|
lastProbe = size;
|
|
106
106
|
assertTransferDiskSpace(temporary, chunk.length).then(() => callback(null, chunk), error => callback(error));
|
|
107
107
|
} });
|
|
108
|
-
await pipeline(Readable.from(request.iterator({ destroyOnReturn: false })), guard,
|
|
108
|
+
await pipeline(Readable.from(request.iterator({ destroyOnReturn: false })), guard,
|
|
109
|
+
// private
|
|
110
|
+
createWriteStream(bundlePath, { flags: 'wx', mode: 0o600 }), { signal: controller.signal });
|
|
109
111
|
if (!size || (declaredLength !== undefined && size !== declaredLength))
|
|
110
112
|
throw new WorkspaceTransferError('invalid_workspace_transfer');
|
|
111
113
|
const lockKeys = [...new Set([...directories.map(name => `directory:${workspaceId}:${name}`),
|
|
@@ -18,7 +18,9 @@ export class AppendOnlyLog {
|
|
|
18
18
|
async append(chunk) {
|
|
19
19
|
if (!chunk)
|
|
20
20
|
return;
|
|
21
|
+
// private
|
|
21
22
|
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
23
|
+
// private
|
|
22
24
|
const handle = await open(this.path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, 0o600);
|
|
23
25
|
try {
|
|
24
26
|
await handle.write(Buffer.from(chunk));
|
|
@@ -34,6 +36,7 @@ export class AppendOnlyLog {
|
|
|
34
36
|
const tail = content.subarray(Math.max(0, content.length - (this.maxBytes - notice.length)));
|
|
35
37
|
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
36
38
|
try {
|
|
39
|
+
// private
|
|
37
40
|
await writeFile(temporary, Buffer.concat([notice, tail]), { mode: 0o600 });
|
|
38
41
|
await rename(temporary, this.path);
|
|
39
42
|
}
|
|
@@ -6,14 +6,17 @@ import { watch } from 'chokidar';
|
|
|
6
6
|
import { join, relative, sep } from 'node:path';
|
|
7
7
|
import { readdir } from 'node:fs/promises';
|
|
8
8
|
import { generatedWorkspace } from './artifact-receipts.js';
|
|
9
|
+
import { withShareRepair } from './workspace-shares.js';
|
|
9
10
|
export async function refreshWorkspaceArtifacts(state, workspaceId, origin, directoryName = 'basic') {
|
|
10
11
|
if (state.journal.destroyed())
|
|
11
12
|
throw new Error('runtime_destroyed');
|
|
12
13
|
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
13
14
|
const active = state.journal.listSessions().some(session => session.workspaceId === workspaceId
|
|
14
15
|
&& (session.directoryName ?? 'basic') === directoryName && ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? ''));
|
|
15
|
-
const
|
|
16
|
-
|
|
16
|
+
const store = new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName);
|
|
17
|
+
// A scan that hits a drifted directory repairs it once as the owner and retries, so a transient
|
|
18
|
+
// EACCES never turns artifact discovery into a 500 for the session.
|
|
19
|
+
const result = await withShareRepair(state, root, () => store.discover(root, origin?.source, origin?.paths, origin?.playable ?? (active ? { mode: 'hold' } : {})));
|
|
17
20
|
if (result.changed && !state.journal.destroyed()) {
|
|
18
21
|
const event = await state.journal.allocateSourceEvent({
|
|
19
22
|
runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
|
|
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { lstat, mkdir, open, readFile, realpath, link, unlink } from 'node:fs/promises';
|
|
4
4
|
import { extname, join, posix, resolve, sep } from 'node:path';
|
|
5
5
|
import { WORKSPACE_ARTIFACT_MAX_BYTES, isWorkspaceArtifactOutputPath, workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { openSharedFile } from './shared-write.js';
|
|
6
7
|
const formats = {
|
|
7
8
|
'.png': ['image', 'image/png'], '.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'],
|
|
8
9
|
'.webp': ['image', 'image/webp'], '.gif': ['image', 'image/gif'], '.avif': ['image', 'image/avif'], '.svg': ['image', 'image/svg+xml'],
|
|
@@ -34,10 +35,12 @@ export async function artifactPath(root, path) {
|
|
|
34
35
|
throw new Error('invalid_artifact_path');
|
|
35
36
|
return target;
|
|
36
37
|
}
|
|
38
|
+
/** Private catalog storage: only the controller reads the runtime state directory. */
|
|
37
39
|
export async function artifactDirectory(root, relative) {
|
|
38
40
|
let target = await realpath(root);
|
|
39
41
|
for (const part of workspaceRelativePathSchema.parse(relative).split('/')) {
|
|
40
42
|
target = join(target, part);
|
|
43
|
+
// private
|
|
41
44
|
await mkdir(target, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
|
|
42
45
|
throw error; });
|
|
43
46
|
const stat = await lstat(target);
|
|
@@ -47,7 +50,7 @@ export async function artifactDirectory(root, relative) {
|
|
|
47
50
|
return target;
|
|
48
51
|
}
|
|
49
52
|
/** Copies the exact bytes observed, and refuses a file changed while being copied. */
|
|
50
|
-
export async function snapshotArtifactFile(root, path, objects, role = 'primary') {
|
|
53
|
+
export async function snapshotArtifactFile(root, path, objects, role = 'primary', shared = false) {
|
|
51
54
|
const target = await artifactPath(root, path);
|
|
52
55
|
const source = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
53
56
|
const temporary = join(objects, `${randomUUID()}.tmp`);
|
|
@@ -56,9 +59,12 @@ export async function snapshotArtifactFile(root, path, objects, role = 'primary'
|
|
|
56
59
|
const before = await source.stat();
|
|
57
60
|
if (!before.isFile() || before.nlink !== 1 || before.size > WORKSPACE_ARTIFACT_MAX_BYTES)
|
|
58
61
|
throw new Error('artifact_file_unavailable');
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
// Workspace receipts are written by whichever identity generates the artifact and read by the
|
|
63
|
+
// other; the controller's own object store stays private.
|
|
64
|
+
output = shared
|
|
65
|
+
? await openSharedFile(root, temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW)
|
|
66
|
+
// private
|
|
67
|
+
: await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
62
68
|
const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024);
|
|
63
69
|
let size = 0;
|
|
64
70
|
for (;;) {
|
|
@@ -176,16 +182,16 @@ export async function artifactResources(root, path) {
|
|
|
176
182
|
throw new Error('artifact_too_many_resources');
|
|
177
183
|
return [...resources].sort();
|
|
178
184
|
}
|
|
179
|
-
export async function snapshotArtifactBundle(root, path, objects, thumbnailPath) {
|
|
185
|
+
export async function snapshotArtifactBundle(root, path, objects, thumbnailPath, shared = false) {
|
|
180
186
|
if (!isWorkspaceArtifactOutputPath(path) || (thumbnailPath !== undefined && !isWorkspaceArtifactOutputPath(thumbnailPath)))
|
|
181
187
|
throw new Error('invalid_artifact_path');
|
|
182
188
|
const resources = await artifactResources(root, path);
|
|
183
|
-
const files = [await snapshotArtifactFile(root, path, objects)];
|
|
189
|
+
const files = [await snapshotArtifactFile(root, path, objects, 'primary', shared)];
|
|
184
190
|
for (const resource of resources)
|
|
185
191
|
if (resource !== path)
|
|
186
|
-
files.push(await snapshotArtifactFile(root, resource, objects, 'resource'));
|
|
192
|
+
files.push(await snapshotArtifactFile(root, resource, objects, 'resource', shared));
|
|
187
193
|
if (thumbnailPath && !files.some(file => file.path === thumbnailPath))
|
|
188
|
-
files.push(await snapshotArtifactFile(root, thumbnailPath, objects, 'thumbnail'));
|
|
194
|
+
files.push(await snapshotArtifactFile(root, thumbnailPath, objects, 'thumbnail', shared));
|
|
189
195
|
if (files.reduce((size, file) => size + file.size, 0) > WORKSPACE_ARTIFACT_MAX_BYTES)
|
|
190
196
|
throw new Error('artifact_bundle_too_large');
|
|
191
197
|
return files;
|
|
@@ -3,9 +3,9 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { isWorkspaceArtifactOutputPath, workspaceArtifactReceiptSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
-
import {
|
|
7
|
-
import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
|
|
6
|
+
import { artifactFormat, relativeArtifactPath, snapshotArtifactBundle } from './artifact-files.js';
|
|
8
7
|
import { excludeFromGit } from './dependencies.js';
|
|
8
|
+
import { ensureSharedDirectory, openSharedFile } from './shared-write.js';
|
|
9
9
|
export const ARTIFACT_RECEIPTS_DIRECTORY = '.playcraft/artifact-receipts';
|
|
10
10
|
export const artifactReceiptSchema = workspaceArtifactReceiptSchema;
|
|
11
11
|
export async function generatedWorkspace(cwd) {
|
|
@@ -33,21 +33,6 @@ export async function generatedWorkspace(cwd) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
37
|
-
* The receipt tree is written by the development identity and read by the controller. A directory
|
|
38
|
-
* the other identity created cannot be re-shaped here; its owner repairs it on the next write and
|
|
39
|
-
* readers keep working from whatever already carries the shared bits.
|
|
40
|
-
*/
|
|
41
|
-
async function shareArtifactDirectory(root, directory) {
|
|
42
|
-
try {
|
|
43
|
-
await shareWorkspaceAssetDirectory(root, directory);
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
const code = error.code;
|
|
47
|
-
if (code !== 'EACCES' && code !== 'EPERM')
|
|
48
|
-
throw error;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
36
|
/** Preserve final outputs before another command overwrites them; intermediate project assets are not registered. */
|
|
52
37
|
export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd = process.cwd(), expectedDigest, origin) {
|
|
53
38
|
const context = await generatedWorkspace(cwd);
|
|
@@ -63,13 +48,11 @@ export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd
|
|
|
63
48
|
}
|
|
64
49
|
if (!isWorkspaceArtifactOutputPath(path) || !artifactFormat(path))
|
|
65
50
|
return;
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
await shareArtifactDirectory(context.root, receipts);
|
|
70
|
-
await shareArtifactDirectory(context.root, objects);
|
|
51
|
+
// Generations are written by whichever identity runs the tool; the controller scans the same tree.
|
|
52
|
+
const receipts = await ensureSharedDirectory(context.root, join(context.root, ARTIFACT_RECEIPTS_DIRECTORY));
|
|
53
|
+
const objects = await ensureSharedDirectory(context.root, join(context.root, ARTIFACT_RECEIPTS_DIRECTORY, 'objects'));
|
|
71
54
|
const thumbnailPath = metadata.thumbnailPath ? relativeArtifactPath(context.root, resolve(cwd, metadata.thumbnailPath)) : undefined;
|
|
72
|
-
const files = await snapshotArtifactBundle(context.root, path, objects, thumbnailPath);
|
|
55
|
+
const files = await snapshotArtifactBundle(context.root, path, objects, thumbnailPath, true);
|
|
73
56
|
if (expectedDigest && files.find(file => file.role === 'primary')?.sha256 !== expectedDigest)
|
|
74
57
|
throw new Error('artifact_file_changed');
|
|
75
58
|
const receipt = artifactReceiptSchema.parse({
|
|
@@ -80,9 +63,8 @@ export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd
|
|
|
80
63
|
...(metadata.duration !== undefined ? { duration: metadata.duration } : {}),
|
|
81
64
|
});
|
|
82
65
|
const temporary = join(receipts, `${receipt.id}.tmp`);
|
|
83
|
-
const handle = await
|
|
66
|
+
const handle = await openSharedFile(context.root, temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
|
|
84
67
|
try {
|
|
85
|
-
await handle.chmod(0o660);
|
|
86
68
|
await handle.writeFile(`${JSON.stringify(receipt)}\n`);
|
|
87
69
|
await handle.sync();
|
|
88
70
|
await handle.close();
|
|
@@ -6,6 +6,7 @@ import sharp from 'sharp';
|
|
|
6
6
|
import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
|
|
8
8
|
import { ARTIFACT_RECEIPTS_DIRECTORY, readArtifactReceipt } from './artifact-receipts.js';
|
|
9
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
9
10
|
import { validateWorkspacePlayable } from '../previews/finalize.js';
|
|
10
11
|
import { PREVIEW_HTML_MAX_BYTES, WORKSPACE_PLAYABLE_OUTPUT_PATH } from '@playcraft/common/cloud-workspace/runtime';
|
|
11
12
|
const serial = new Map();
|
|
@@ -35,6 +36,7 @@ export class WorkspaceArtifactStore {
|
|
|
35
36
|
}
|
|
36
37
|
get scopeId() { return this.directoryName === 'basic' ? this.workspaceId : `${this.workspaceId}:${this.directoryName}`; }
|
|
37
38
|
async directory() {
|
|
39
|
+
// private
|
|
38
40
|
await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
|
|
39
41
|
return artifactDirectory(this.stateDir, `artifacts/${digest(this.scopeId)}`);
|
|
40
42
|
}
|
|
@@ -64,6 +66,7 @@ export class WorkspaceArtifactStore {
|
|
|
64
66
|
}
|
|
65
67
|
async write(directory, catalog) {
|
|
66
68
|
const temporary = join(directory, `${randomUUID()}.tmp`);
|
|
69
|
+
// private
|
|
67
70
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
68
71
|
try {
|
|
69
72
|
await handle.writeFile(JSON.stringify(catalog));
|
|
@@ -281,7 +284,7 @@ export class WorkspaceArtifactStore {
|
|
|
281
284
|
const target = join(root, ARTIFACT_RECEIPTS_DIRECTORY);
|
|
282
285
|
if (!(await lstat(target)).isDirectory())
|
|
283
286
|
return [];
|
|
284
|
-
directory = await
|
|
287
|
+
directory = await ensureSharedDirectory(root, target);
|
|
285
288
|
}
|
|
286
289
|
catch {
|
|
287
290
|
return [];
|
|
@@ -5,6 +5,7 @@ import { basename, dirname, extname, join, relative } from 'node:path';
|
|
|
5
5
|
import { BINARY_WORKSPACE_FILE_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES, WORKSPACE_LIBRARY_ATTACHMENT_MAX_FILES, workspaceAssetLibraryImportPayloadSchema, workspaceAssetLibraryImportResultSchema, workspaceAssetLibraryImportTransportSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from './binary-write.js';
|
|
7
7
|
import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
|
|
8
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
8
9
|
const RESERVED = new Set(['.git', 'node_modules', '.playcraft-temp', '.tmp']);
|
|
9
10
|
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
10
11
|
const missing = (error) => error?.code === 'ENOENT';
|
|
@@ -23,6 +24,7 @@ async function safeDirectory(root, path, create = false) {
|
|
|
23
24
|
let current = root;
|
|
24
25
|
for (const part of path.split('/').filter(Boolean)) {
|
|
25
26
|
current = join(current, part);
|
|
27
|
+
// private
|
|
26
28
|
if (create)
|
|
27
29
|
await mkdir(current, { mode: 0o700 }).catch((error) => {
|
|
28
30
|
if (error.code !== 'EEXIST')
|
|
@@ -47,8 +49,7 @@ async function createPublicDirectory(root, path) {
|
|
|
47
49
|
if (!missing(error))
|
|
48
50
|
throw error;
|
|
49
51
|
await safeDirectory(root, parent);
|
|
50
|
-
await
|
|
51
|
-
await shareWorkspaceAssetDirectory(root, join(root, current));
|
|
52
|
+
await ensureSharedDirectory(root, join(root, current));
|
|
52
53
|
await safeDirectory(root, current);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -127,6 +128,7 @@ async function syncDirectory(path) {
|
|
|
127
128
|
async function saveTransaction(root, transaction, value) {
|
|
128
129
|
await safeDirectory(root, relative(root, transaction));
|
|
129
130
|
const temporary = join(transaction, 'manifest.next');
|
|
131
|
+
// private
|
|
130
132
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
|
|
131
133
|
try {
|
|
132
134
|
await handle.writeFile(JSON.stringify(value));
|
|
@@ -181,6 +183,8 @@ export async function recoverInterruptedLibraryImports(root) {
|
|
|
181
183
|
return result;
|
|
182
184
|
throw error;
|
|
183
185
|
}
|
|
186
|
+
// The shared scratch root is where both identities create their own private transactions.
|
|
187
|
+
await ensureSharedDirectory(rootReal, scratch);
|
|
184
188
|
for (const entry of await readdir(scratch, { withFileTypes: true })) {
|
|
185
189
|
if (!entry.isDirectory() || !entry.name.startsWith('library-import-'))
|
|
186
190
|
continue;
|
|
@@ -239,9 +243,10 @@ async function publishBundle(root, target, asset, replace, assertCurrent, operat
|
|
|
239
243
|
if (previous && (!replace || !previous.isDirectory() || previous.isSymbolicLink()))
|
|
240
244
|
throw new AssetLibraryImportError('content_conflict');
|
|
241
245
|
const originalHash = previous ? await directorySnapshot(join(root, target)) : undefined;
|
|
242
|
-
const scratch = await
|
|
246
|
+
const scratch = await ensureSharedDirectory(root, join(root, '.playcraft-temp'));
|
|
243
247
|
const transaction = await mkdtemp(join(scratch, 'library-import-'));
|
|
244
248
|
const staged = join(transaction, 'incoming'), backup = join(transaction, 'previous');
|
|
249
|
+
// private
|
|
245
250
|
await mkdir(staged, { mode: 0o700 });
|
|
246
251
|
let moved = false, published = false, preserve = false;
|
|
247
252
|
let reservation;
|
|
@@ -271,7 +276,7 @@ async function publishBundle(root, target, asset, replace, assertCurrent, operat
|
|
|
271
276
|
}
|
|
272
277
|
else if (current)
|
|
273
278
|
throw new AssetLibraryImportError('content_conflict');
|
|
274
|
-
await
|
|
279
|
+
await ensureSharedDirectory(root, join(root, target));
|
|
275
280
|
reservation = await lstat(join(root, target));
|
|
276
281
|
await saveTransaction(root, transaction, { ...manifest, phase: 'reserved', reservation: { dev: reservation.dev, ino: reservation.ino } });
|
|
277
282
|
await assertCurrent();
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
2
|
import { lstat, open } from 'node:fs/promises';
|
|
3
|
+
import { SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, shareHandleWithGroup, } from './shared-write.js';
|
|
4
|
+
/**
|
|
5
|
+
* Published workspace assets belong to the development group so both identities can read and edit
|
|
6
|
+
* them. Private transfers, chunks, transaction records and backups never use these helpers.
|
|
7
|
+
*/
|
|
3
8
|
async function shareWithWorkspaceGroup(root, handle, directory) {
|
|
4
9
|
const workspace = await lstat(root), target = await handle.stat();
|
|
5
10
|
if (!workspace.isDirectory() || workspace.isSymbolicLink()
|
|
6
11
|
|| (directory ? !target.isDirectory() : !target.isFile() || target.nlink !== 1))
|
|
7
12
|
throw new Error('invalid_workspace_asset');
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (target.gid === workspace.gid && (target.mode & 0o7777) === shared)
|
|
11
|
-
return;
|
|
12
|
-
if (target.gid !== workspace.gid)
|
|
13
|
-
await handle.chown(target.uid, workspace.gid);
|
|
14
|
-
// Match directory-access.ts: the development group can read and edit public project files.
|
|
15
|
-
// Private transfers, chunks, transaction records and backups never use these helpers.
|
|
16
|
-
await handle.chmod(shared);
|
|
13
|
+
// Already shared or owned by the other identity: shaping is a no-op, never a cross-owner EPERM.
|
|
14
|
+
await shareHandleWithGroup(handle, workspace.gid, directory ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE);
|
|
17
15
|
}
|
|
18
16
|
export async function shareWorkspaceAssetFile(root, handle) {
|
|
19
17
|
await shareWithWorkspaceGroup(root, handle, false);
|
|
@@ -40,6 +40,7 @@ async function assemble(transfer) {
|
|
|
40
40
|
const staged = [];
|
|
41
41
|
for (const [index, file] of transfer.record.manifest.files.entries()) {
|
|
42
42
|
const destination = join(transfer.directory, 'incoming', String(index));
|
|
43
|
+
// private
|
|
43
44
|
const output = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
44
45
|
const hash = createHash('sha256');
|
|
45
46
|
try {
|
|
@@ -120,6 +121,7 @@ async function createDirectories(transfer, path) {
|
|
|
120
121
|
if (!assetTransferMissing(error))
|
|
121
122
|
throw error;
|
|
122
123
|
await safeAssetTransferDirectory(transfer.root, dirname(directory) === '.' ? '' : dirname(directory));
|
|
124
|
+
// private
|
|
123
125
|
await mkdir(join(transfer.root, directory), { mode: 0o700 });
|
|
124
126
|
await shareWorkspaceAssetDirectory(transfer.root, join(transfer.root, directory));
|
|
125
127
|
const stat = await lstat(await safeAssetTransferDirectory(transfer.root, directory));
|
|
@@ -146,6 +148,7 @@ async function ensureDirectoryMarkers(transfer) {
|
|
|
146
148
|
const parent = await safeAssetTransferDirectory(transfer.root, path);
|
|
147
149
|
const marker = join(parent, '.gitkeep');
|
|
148
150
|
try {
|
|
151
|
+
// private
|
|
149
152
|
const handle = await open(marker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
150
153
|
try {
|
|
151
154
|
await shareWorkspaceAssetFile(transfer.root, handle);
|