@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.
Files changed (82) hide show
  1. package/dist/commands/workspace.js +43 -0
  2. package/dist/workspace/messages.js +10 -0
  3. package/dist/workspace-runtime/batch/deliver.js +140 -80
  4. package/dist/workspace-runtime/batch/freeze.js +18 -6
  5. package/dist/workspace-runtime/batch/generate.js +68 -64
  6. package/dist/workspace-runtime/batch/prepare.js +108 -93
  7. package/dist/workspace-runtime/batch/records.js +4 -0
  8. package/dist/workspace-runtime/batch/routes.js +10 -2
  9. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  10. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  11. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  12. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  13. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  14. package/dist/workspace-runtime/codex/app-server.js +1 -0
  15. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  16. package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
  17. package/dist/workspace-runtime/persistence/journal.js +34 -0
  18. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  19. package/dist/workspace-runtime/previews/output.js +2 -0
  20. package/dist/workspace-runtime/previews/publish.js +20 -5
  21. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  22. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  23. package/dist/workspace-runtime/serve.js +11 -2
  24. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  25. package/dist/workspace-runtime/server/directory-access.js +414 -0
  26. package/dist/workspace-runtime/server/dispatch.js +61 -23
  27. package/dist/workspace-runtime/server/endpoints.js +1 -0
  28. package/dist/workspace-runtime/server/http.js +52 -10
  29. package/dist/workspace-runtime/server/operation-record.js +2 -0
  30. package/dist/workspace-runtime/server/terminal-host.js +43 -8
  31. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  32. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  33. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  34. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  35. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  36. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  37. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  38. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  39. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  40. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  41. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  42. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  43. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  44. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  45. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  46. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  47. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  48. package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
  49. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  50. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  51. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  52. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  53. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  54. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  55. package/dist/workspace-runtime/workspaces/files.js +4 -0
  56. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  57. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  58. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  59. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  60. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  61. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  62. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  63. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  64. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  65. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  66. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  67. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  68. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  69. package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
  70. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  71. package/dist/workspace-runtime/workspaces/registry.js +11 -0
  72. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  73. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  74. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  75. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  76. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  77. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  78. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  79. package/package.json +5 -4
  80. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  81. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  82. 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
- writeJson(response, 200, await registerChatWorktree(state, body.directory, Number(body.leaseFence), source.data, body.sourceBranchName, attachments.data));
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(`directory:${parsed.data.workspaceId}:${parsed.data.directoryName ?? 'basic'}`, acceptBound);
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.spawning.get(terminalId);
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
- try {
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 }) => { finishExit(); void this.exited(terminal.terminalId, session, exitCode, signal).catch(() => undefined); });
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, createWriteStream(bundlePath, { flags: 'wx', mode: 0o600 }), { signal: controller.signal });
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 result = await new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName)
16
- .discover(root, origin?.source, origin?.paths, origin?.playable ?? (active ? { mode: 'hold' } : {}));
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
- // Objects are written by whichever identity generates the artifact and read by the other.
60
- output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o660);
61
- await output.chmod(0o660);
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 { artifactDirectory, artifactFormat, relativeArtifactPath, snapshotArtifactBundle } from './artifact-files.js';
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
- const receipts = await artifactDirectory(context.root, ARTIFACT_RECEIPTS_DIRECTORY);
67
- const objects = await artifactDirectory(context.root, `${ARTIFACT_RECEIPTS_DIRECTORY}/objects`);
68
- // Generations are written as the development identity while the controller scans this tree.
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 open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o660);
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 artifactDirectory(root, ARTIFACT_RECEIPTS_DIRECTORY);
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 mkdir(join(root, current), { mode: 0o700 });
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 safeDirectory(root, '.playcraft-temp', true);
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 mkdir(join(root, target), { mode: 0o700 });
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
- const shared = directory ? 0o2770 : 0o660;
9
- // Already shared: the other identity owns this entry and cannot re-shape it from here.
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);