@playcraft/cli 0.1.57-beta → 0.1.59-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 (89) hide show
  1. package/dist/commands/workspace.js +43 -0
  2. package/dist/utils/agent-generation-provenance.js +1 -1
  3. package/dist/workspace/messages.js +10 -0
  4. package/dist/workspace-runtime/batch/deliver.js +140 -80
  5. package/dist/workspace-runtime/batch/freeze.js +18 -6
  6. package/dist/workspace-runtime/batch/generate.js +68 -64
  7. package/dist/workspace-runtime/batch/policy.js +71 -22
  8. package/dist/workspace-runtime/batch/prepare.js +108 -93
  9. package/dist/workspace-runtime/batch/records.js +4 -0
  10. package/dist/workspace-runtime/batch/routes.js +10 -2
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  12. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  13. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  14. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  15. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  16. package/dist/workspace-runtime/codex/app-server.js +278 -20
  17. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  18. package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
  19. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  20. package/dist/workspace-runtime/codex/loopback.js +2 -0
  21. package/dist/workspace-runtime/codex/model-broker.js +7 -4
  22. package/dist/workspace-runtime/persistence/journal.js +34 -0
  23. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  24. package/dist/workspace-runtime/previews/output.js +2 -0
  25. package/dist/workspace-runtime/previews/publish.js +20 -5
  26. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  27. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  28. package/dist/workspace-runtime/serve.js +13 -3
  29. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  30. package/dist/workspace-runtime/server/directory-access.js +414 -0
  31. package/dist/workspace-runtime/server/dispatch.js +74 -29
  32. package/dist/workspace-runtime/server/endpoints.js +1 -0
  33. package/dist/workspace-runtime/server/http.js +80 -10
  34. package/dist/workspace-runtime/server/operation-record.js +2 -0
  35. package/dist/workspace-runtime/server/terminal-host.js +124 -8
  36. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  37. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  38. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  39. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  40. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  41. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  42. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  43. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  44. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  45. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  46. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  47. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  48. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  49. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  50. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  51. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  52. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  53. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  54. package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
  55. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  56. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  57. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  58. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  59. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  60. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  61. package/dist/workspace-runtime/workspaces/files.js +4 -0
  62. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  63. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  64. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  65. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  66. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  67. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  68. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  69. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  70. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  72. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  73. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  74. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  75. package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
  76. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  77. package/dist/workspace-runtime/workspaces/registry.js +55 -3
  78. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  79. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  80. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  81. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  82. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  83. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  84. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  85. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  86. package/package.json +5 -4
  87. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  88. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  89. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
@@ -4,6 +4,7 @@ import { constants } from 'node:fs';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { join } from 'node:path';
6
6
  import { launcherSocket, runDeveloperCommand } from './developer-command.js';
7
+ import { adoptDirectoryAccess, holdDirectoryAccess, managedDirectoryScope } from '../server/directory-access.js';
7
8
  /** Long enough for a cold store on a game template; a stuck registry must not hold the write lease. */
8
9
  const INSTALL_TIMEOUT_MS = 10 * 60_000;
9
10
  const LOG_TAIL_LINES = 50;
@@ -44,17 +45,18 @@ export function scheduleDependencyInstall(state, workspaceId, root, shouldStart)
44
45
  running.set(state, inFlight);
45
46
  const existing = inFlight.get(workspaceId);
46
47
  if (existing?.root === root) {
48
+ void adoptDirectoryAccess(state, `managed:${existing.writeId}`).catch(() => undefined);
47
49
  if (!shouldStart)
48
50
  existing.guards = undefined;
49
51
  else
50
52
  existing.guards?.push(shouldStart);
51
53
  return existing.task;
52
54
  }
53
- const scheduled = { root, task: Promise.resolve(), guards: shouldStart ? [shouldStart] : undefined };
55
+ const scheduled = { root, writeId: `${WRITE_PREFIX}${workspaceId}-${randomUUID()}`, task: Promise.resolve(), guards: shouldStart ? [shouldStart] : undefined };
54
56
  const begin = () => {
55
57
  if (state.journal.destroyed() || (scheduled.guards && !scheduled.guards.some((guard) => guard())))
56
58
  return;
57
- return installOnce(state, workspaceId, root);
59
+ return installOnce(state, workspaceId, root, scheduled.writeId);
58
60
  };
59
61
  const task = (existing ? existing.task.then(begin) : begin() ?? Promise.resolve())
60
62
  .catch((error) => {
@@ -91,8 +93,8 @@ export async function ensureWorkspaceDependencies(state, workspaceId, root) {
91
93
  };
92
94
  }
93
95
  /**
94
- * An install cut off by a restart left no process behind (the launcher stops the group when its
95
- * connection closes), so its status and write lease are closed rather than left in progress.
96
+ * A lost controller makes the install result unknown. Keep its writer claim until a supervisor
97
+ * confirms that the process group stopped; a closed connection alone cannot prove that.
96
98
  */
97
99
  export async function recoverInterruptedInstalls(journal) {
98
100
  const now = new Date().toISOString();
@@ -106,81 +108,96 @@ export async function recoverInterruptedInstalls(journal) {
106
108
  finishedAt: now,
107
109
  });
108
110
  }
109
- for (const write of journal.listManagedWrites()) {
110
- if (write.writeId.startsWith(WRITE_PREFIX) && !write.stoppedAt) {
111
- await journal.putManagedWrite({ ...write, stoppedAt: now });
112
- }
113
- }
114
111
  }
115
- async function installOnce(state, workspaceId, root) {
116
- let plan;
112
+ async function installOnce(state, workspaceId, root, writeId) {
113
+ const startedAt = new Date().toISOString();
114
+ const release = await holdDirectoryAccess(state, managedDirectoryScope(state, workspaceId), `managed:${writeId}`, 'shared');
115
+ let processMayRun = false;
116
+ let processStopped = false;
117
117
  try {
118
- plan = await planInstall(root);
119
- }
120
- catch {
121
- await state.journal.putWorkspaceDependencies({
122
- workspaceId, status: 'failed', error: 'dependency_inputs_unreadable', finishedAt: new Date().toISOString(),
123
- });
124
- return;
125
- }
126
- const current = state.journal.getWorkspaceDependencies(workspaceId);
127
- if (plan.kind === 'skip') {
128
- if (current?.status !== 'none') {
118
+ if (state.journal.listManagedWrites().some(writer => writer.workspaceId === workspaceId &&
119
+ writer.writeId !== writeId && writer.writeId.startsWith(WRITE_PREFIX) && !writer.stoppedAt)) {
129
120
  await state.journal.putWorkspaceDependencies({
130
- workspaceId,
131
- status: 'none',
132
- finishedAt: new Date().toISOString(),
121
+ workspaceId, status: 'failed', error: 'dependency_run_unconfirmed', finishedAt: new Date().toISOString(),
133
122
  });
123
+ return;
134
124
  }
135
- return;
136
- }
137
- if (current?.status === 'ready' &&
138
- current.lockHash === plan.lockHash &&
139
- (await isDirectory(join(root, 'node_modules')))) {
140
- return;
141
- }
142
- await excludeFromGit(root);
143
- const startedAt = new Date().toISOString();
144
- const writeId = `${WRITE_PREFIX}${workspaceId}-${randomUUID()}`;
145
- // A checkpoint waits for this write: node_modules is half written until the install ends.
146
- await state.journal.putManagedWrite({ writeId, workspaceId, source: 'background', startedAt });
147
- await state.journal.putWorkspaceDependencies({
148
- workspaceId,
149
- status: 'installing',
150
- startedAt,
151
- });
152
- let outcome;
153
- try {
154
- // Through the launcher the install runs as the development group, which cannot enter the
155
- // checkout until Runtime has opened it; a local install runs as the owner and need not wait.
156
- const shared = !launcherSocket(state) || (await waitUntilShared(root));
157
- outcome = shared
158
- ? await runInstall(state, workspaceId, root)
159
- : { code: undefined, timedOut: false, log: '', error: 'workspace_not_shared' };
160
- }
161
- finally {
162
- await state.journal.putManagedWrite({
163
- writeId,
125
+ let plan;
126
+ try {
127
+ plan = await planInstall(root);
128
+ }
129
+ catch {
130
+ await state.journal.putWorkspaceDependencies({
131
+ workspaceId, status: 'failed', error: 'dependency_inputs_unreadable', finishedAt: new Date().toISOString(),
132
+ });
133
+ return;
134
+ }
135
+ const current = state.journal.getWorkspaceDependencies(workspaceId);
136
+ if (plan.kind === 'skip') {
137
+ if (current?.status !== 'none') {
138
+ await state.journal.putWorkspaceDependencies({
139
+ workspaceId,
140
+ status: 'none',
141
+ finishedAt: new Date().toISOString(),
142
+ });
143
+ }
144
+ return;
145
+ }
146
+ if (current?.status === 'ready' &&
147
+ current.lockHash === plan.lockHash &&
148
+ (await isDirectory(join(root, 'node_modules')))) {
149
+ return;
150
+ }
151
+ await excludeFromGit(root);
152
+ // A checkpoint waits for this write: node_modules is half written until the install ends.
153
+ await state.journal.putManagedWrite({ writeId, workspaceId, source: 'background', startedAt });
154
+ await state.journal.putWorkspaceDependencies({
164
155
  workspaceId,
165
- source: 'background',
156
+ status: 'installing',
166
157
  startedAt,
167
- stoppedAt: new Date().toISOString(),
158
+ });
159
+ let outcome;
160
+ try {
161
+ // Through the launcher the install runs as the development group, which cannot enter the
162
+ // checkout until Runtime has opened it; a local install runs as the owner and need not wait.
163
+ const shared = !launcherSocket(state) || (await waitUntilShared(root));
164
+ if (shared) {
165
+ processMayRun = true;
166
+ outcome = await runInstall(state, workspaceId, root);
167
+ processStopped = outcome.code !== undefined;
168
+ }
169
+ else
170
+ outcome = { code: undefined, timedOut: false, log: '', error: 'workspace_not_shared' };
171
+ }
172
+ finally {
173
+ if (!processMayRun || processStopped)
174
+ await state.journal.putManagedWrite({
175
+ writeId,
176
+ workspaceId,
177
+ source: 'background',
178
+ startedAt,
179
+ stoppedAt: new Date().toISOString(),
180
+ });
181
+ }
182
+ const ok = outcome.code === 0 && !outcome.timedOut;
183
+ await state.journal.putWorkspaceDependencies({
184
+ workspaceId,
185
+ status: ok ? 'ready' : 'failed',
186
+ ...(ok
187
+ ? { lockHash: plan.lockHash }
188
+ : {
189
+ error: outcome.error ??
190
+ (outcome.timedOut ? 'install_timeout' : `install_exit_${outcome.code ?? 'lost'}`),
191
+ }),
192
+ startedAt,
193
+ finishedAt: new Date().toISOString(),
194
+ logTail: tail(outcome.log),
168
195
  });
169
196
  }
170
- const ok = outcome.code === 0 && !outcome.timedOut;
171
- await state.journal.putWorkspaceDependencies({
172
- workspaceId,
173
- status: ok ? 'ready' : 'failed',
174
- ...(ok
175
- ? { lockHash: plan.lockHash }
176
- : {
177
- error: outcome.error ??
178
- (outcome.timedOut ? 'install_timeout' : `install_exit_${outcome.code ?? 'lost'}`),
179
- }),
180
- startedAt,
181
- finishedAt: new Date().toISOString(),
182
- logTail: tail(outcome.log),
183
- });
197
+ finally {
198
+ if (!processMayRun || processStopped)
199
+ release();
200
+ }
184
201
  }
185
202
  /**
186
203
  * Runs as the development user through the launcher, so the packages belong to the same user as
@@ -190,8 +207,10 @@ async function runInstall(state, workspaceId, root) {
190
207
  // Tests replace the install; a real registry is never reached from them.
191
208
  const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || dependencyInstallCommand(state);
192
209
  const logDir = join(state.config.stateDir, 'dependency-logs');
210
+ // private
193
211
  await mkdir(logDir, { recursive: true, mode: 0o700 });
194
212
  const logPath = join(logDir, `${workspaceId}.log`);
213
+ // private
195
214
  await writeFile(logPath, `$ ${planned}\n`, { mode: 0o600 });
196
215
  let log = '';
197
216
  let pending = Promise.resolve();
@@ -254,6 +273,7 @@ export async function excludeFromGit(root, entry = '/node_modules/') {
254
273
  });
255
274
  const info = await open(join(gitPath, 'info'), flags);
256
275
  handles.push(info);
276
+ // shared
257
277
  file = await open(join(pinned(info, join(root, '.git/info')), 'exclude'), constants.O_RDWR | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o666);
258
278
  const metadata = await file.stat();
259
279
  if (!metadata.isFile() || metadata.nlink !== 1 || metadata.size > 1024 * 1024) {
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { startLauncherProcess } from '../codex/launcher-client.js';
3
+ import { stopBackgroundWriter } from '../processes/managed-writes.js';
3
4
  export function launcherSocket(state) {
4
5
  const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
5
6
  return socketPath && state.config.workspaceMode === 'multi' ? socketPath : undefined;
@@ -94,5 +95,15 @@ export async function runDeveloperCommand(state, input) {
94
95
  local.once('close', (value) => finish(value));
95
96
  }
96
97
  });
97
- return { code, timedOut, ...(canceled ? { canceled: true } : {}) };
98
+ let stopped = !launched && !local?.pid;
99
+ try {
100
+ if (launched) {
101
+ await launched.stop();
102
+ stopped = true;
103
+ }
104
+ else if (local?.pid)
105
+ stopped = await stopBackgroundWriter(local.pid);
106
+ }
107
+ catch { /* Transport loss cannot prove the process group has stopped. */ }
108
+ return { code: stopped ? code : undefined, timedOut, ...(canceled ? { canceled: true } : {}) };
98
109
  }
@@ -1,5 +1,6 @@
1
1
  import { chmod, chown, lstat, readdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, sharePathWithGroup, } from './shared-write.js';
3
4
  /** Grant development-group traversal of the workspace container without touching source checkouts. */
4
5
  export async function grantWorkspaceTraversal(root, groupId) {
5
6
  const stat = await lstat(root);
@@ -18,9 +19,7 @@ export async function openScratchToDevelopment(path, groupId) {
18
19
  const stat = await lstat(path);
19
20
  if (!stat.isDirectory() || stat.isSymbolicLink())
20
21
  throw new Error('invalid_scratch_directory');
21
- if (stat.gid !== groupId)
22
- await chown(path, stat.uid, groupId);
23
- await chmod(path, 0o2770);
22
+ await sharePathWithGroup(path, groupId, SHARED_DIRECTORY_MODE);
24
23
  }
25
24
  /** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
26
25
  export async function openDirectoryToDevelopment(root, groupId) {
@@ -29,22 +28,18 @@ export async function openDirectoryToDevelopment(root, groupId) {
29
28
  const stat = await lstat(path);
30
29
  if (stat.isSymbolicLink())
31
30
  return;
31
+ // Entries owned by the other identity cannot be re-shaped from here; the owner repairs them.
32
32
  const canModify = currentUid === undefined || currentUid === 0 || stat.uid === currentUid;
33
- if (canModify && stat.gid !== groupId)
34
- await chown(path, stat.uid, groupId);
35
- if (stat.isDirectory()) {
36
- if (canModify)
37
- await chmod(path, (stat.mode & 0o777) | 0o2770);
38
- const entries = await readdir(path).catch(error => {
39
- if (!canModify && error.code === 'EACCES')
40
- return [];
41
- throw error;
42
- });
43
- for (const entry of entries)
44
- await visit(join(path, entry));
45
- }
46
- else if (stat.isFile() && canModify)
47
- await chmod(path, (stat.mode & 0o777) | 0o660);
33
+ await sharePathWithGroup(path, groupId, stat.isDirectory() ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE, stat.isFile());
34
+ if (!stat.isDirectory())
35
+ return;
36
+ const entries = await readdir(path).catch(error => {
37
+ if (!canModify && error.code === 'EACCES')
38
+ return [];
39
+ throw error;
40
+ });
41
+ for (const entry of entries)
42
+ await visit(join(path, entry));
48
43
  };
49
44
  await visit(root);
50
45
  }
@@ -25,6 +25,7 @@ async function safeDirectory(root, relative, create = false) {
25
25
  let directory = root;
26
26
  for (const part of relative.split('/')) {
27
27
  directory = join(directory, part);
28
+ // shared
28
29
  if (create)
29
30
  await mkdir(directory, { mode: 0o755 }).catch((error) => {
30
31
  if (error.code !== 'EEXIST')
@@ -59,10 +60,14 @@ export async function beginWorkspaceDirectoryAttachment(root, body) {
59
60
  const staging = await safeDirectory(root, stagingPath, true);
60
61
  const id = randomUUID();
61
62
  const stage = join(staging, id);
63
+ // shared
62
64
  await mkdir(stage, { mode: 0o755 });
63
65
  try {
66
+ // shared
64
67
  await mkdir(join(stage, 'data'), { mode: 0o755 });
68
+ // shared
65
69
  await mkdir(join(stage, 'data', manifest.name), { mode: 0o755 });
70
+ // shared
66
71
  await writeFile(join(stage, 'manifest.json'), JSON.stringify(manifest), { flag: 'wx', mode: 0o644 });
67
72
  return { uploadId: id };
68
73
  }
@@ -84,6 +89,7 @@ export async function uploadWorkspaceDirectoryFile(root, id, path, request) {
84
89
  throw new Error('invalid_attachment');
85
90
  const relative = `${stagingPath}/${id}/data/${manifest.name}/${path}`;
86
91
  await safeDirectory(root, dirname(relative), true);
92
+ // shared
87
93
  const handle = await open(join(root, relative), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
88
94
  try {
89
95
  let size = 0;
@@ -164,9 +170,11 @@ export async function completeWorkspaceDirectoryAttachment(root, id) {
164
170
  path: `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}/${manifest.name}`, files };
165
171
  workspaceAttachmentSchema.parse(attachment);
166
172
  const target = join(uploads, id);
173
+ // shared
167
174
  await mkdir(target, { mode: 0o755 });
168
175
  try {
169
176
  await rename(join(stage, 'data', manifest.name), join(target, manifest.name));
177
+ // shared
170
178
  await writeFile(join(metadata, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
171
179
  }
172
180
  catch (error) {
@@ -1,8 +1,9 @@
1
1
  import { constants } from 'node:fs';
2
- import { lstat, mkdir, open, realpath, rmdir } from 'node:fs/promises';
2
+ import { lstat, mkdir, realpath, rmdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { directoryCreatePayloadSchema } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { ensureWorkspaceRoot, InvalidWorkspacePathError } from './files.js';
6
+ import { ensureSharedDirectory, openSharedFile } from './shared-write.js';
6
7
  const RESERVED_DIRECTORIES = new Set(['.git', 'node_modules', '.playcraft-temp']);
7
8
  export class WorkspaceDirectoryCreateError extends Error {
8
9
  code = 'directory_exists';
@@ -18,18 +19,29 @@ export async function createWorkspaceDirectory(root, input) {
18
19
  if (parts.some(part => RESERVED_DIRECTORIES.has(part)))
19
20
  throw new InvalidWorkspacePathError(path);
20
21
  const rootReal = await ensureWorkspaceRoot(root);
22
+ const share = async (path) => {
23
+ try {
24
+ await ensureSharedDirectory(rootReal, path);
25
+ }
26
+ catch (error) {
27
+ if (error.message === 'invalid_shared_directory')
28
+ throw new InvalidWorkspacePathError(path);
29
+ throw error;
30
+ }
31
+ };
21
32
  let parent = rootReal;
22
33
  for (const part of parts.slice(0, -1)) {
23
34
  parent = join(parent, part);
24
- await mkdir(parent, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
25
- throw error; });
35
+ // Created directories are visible to the development identity that runs agents and builds.
36
+ await share(parent);
26
37
  const stat = await lstat(parent);
27
38
  if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(parent) !== parent)
28
39
  throw new InvalidWorkspacePathError(path);
29
40
  }
30
41
  const target = join(parent, parts.at(-1));
42
+ // shared
31
43
  try {
32
- await mkdir(target, { mode: 0o700 });
44
+ await mkdir(target, { mode: 0o2770 });
33
45
  }
34
46
  catch (error) {
35
47
  if (error.code !== 'EEXIST')
@@ -41,7 +53,8 @@ export async function createWorkspaceDirectory(root, input) {
41
53
  try {
42
54
  if (!(await lstat(target)).isDirectory() || await realpath(target) !== target)
43
55
  throw new InvalidWorkspacePathError(path);
44
- const marker = await open(join(target, '.gitkeep'), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
56
+ await share(target);
57
+ const marker = await openSharedFile(rootReal, join(target, '.gitkeep'), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
45
58
  try {
46
59
  await marker.sync();
47
60
  }
@@ -6,6 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { batchDirectoryHeld } from '../batch/occupancy.js';
8
8
  import { workspaceRoot, workspaceSessions } from './registry.js';
9
+ import { withDirectoryWrite } from '../server/directory-access.js';
9
10
  const execute = promisify(execFile);
10
11
  const git = async (state, root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
11
12
  timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
@@ -39,7 +40,7 @@ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryN
39
40
  const blocked = [];
40
41
  for (const directoryName of names) {
41
42
  try {
42
- await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
43
+ await withDirectoryWrite(state, { workspaceId, directoryName, mode: 'exclusive', wait: false }, () => state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
43
44
  if (await workspaceDirectoryOccupied(state, workspaceId, directoryName))
44
45
  throw new Error('workspace_directory_occupied');
45
46
  const root = await workspaceRoot(state, workspaceId, directoryName);
@@ -51,7 +52,7 @@ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryN
51
52
  await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directoryName), { ...directory, status: 'reclaimed' }] });
52
53
  await rm(root, { recursive: true });
53
54
  cleaned.push(directoryName);
54
- });
55
+ }));
55
56
  }
56
57
  catch (error) {
57
58
  blocked.push({ directoryName, reason: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'workspace_cleanup_failed' });
@@ -70,42 +71,52 @@ export async function syncWorkspaceDirectories(state, workspaceId, commitHash, t
70
71
  for (const directoryName of [...new Set(targetDirectoryNames.map(name => workspaceDirectoryNameSchema.parse(name)))]) {
71
72
  if (directoryName === 'basic')
72
73
  throw new Error('workspace_sync_target_invalid');
73
- await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
74
- if (await workspaceDirectoryOccupied(state, workspaceId, directoryName)) {
75
- skipped.push({ directoryName, reason: 'directory_busy' });
76
- return;
77
- }
78
- const root = await workspaceRoot(state, workspaceId, directoryName);
79
- const ledger = join(state.config.stateDir, 'directory-sync', workspaceId, `${directoryName}.json`);
80
- const applied = await readFile(ledger, 'utf8').then(value => JSON.parse(value), () => undefined);
81
- await assertCleanAndPushed(state, root, applied?.sourceCommit === commitHash ? applied.targetCommit : undefined);
82
- if (applied?.sourceCommit === commitHash && await git(state, root, 'rev-parse', 'HEAD') === applied.targetCommit) {
83
- synced.push(directoryName);
84
- return;
85
- }
86
- const containing = await git(state, root, 'merge-base', '--is-ancestor', commitHash, 'HEAD').then(() => true, () => false);
87
- if (containing) {
88
- synced.push(directoryName);
89
- return;
90
- }
91
- // Import the exact preserved basic commit, without fetching a moving remote branch.
92
- await execute('git', ['-c', 'core.hooksPath=/dev/null', '-C', root, 'fetch', '--no-tags', '--', basic, commitHash], { timeout: 30_000, maxBuffer: 1024 * 1024 });
93
- const equivalent = (await git(state, root, 'cherry', 'HEAD', commitHash)).split('\n').some(line => line === `- ${commitHash}`);
94
- if (!equivalent) {
95
- try {
96
- await git(state, root, '-c', 'user.name=PlayCraft', '-c', 'user.email=playcraft@localhost', 'cherry-pick', commitHash);
74
+ try {
75
+ await withDirectoryWrite(state, { workspaceId, directoryName, mode: 'exclusive', wait: false }, () => state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
76
+ if (await workspaceDirectoryOccupied(state, workspaceId, directoryName)) {
77
+ skipped.push({ directoryName, reason: 'directory_busy' });
78
+ return;
97
79
  }
98
- catch {
99
- conflicts.push(directoryName);
80
+ const root = await workspaceRoot(state, workspaceId, directoryName);
81
+ const ledger = join(state.config.stateDir, 'directory-sync', workspaceId, `${directoryName}.json`);
82
+ const applied = await readFile(ledger, 'utf8').then(value => JSON.parse(value), () => undefined);
83
+ await assertCleanAndPushed(state, root, applied?.sourceCommit === commitHash ? applied.targetCommit : undefined);
84
+ if (applied?.sourceCommit === commitHash && await git(state, root, 'rev-parse', 'HEAD') === applied.targetCommit) {
85
+ synced.push(directoryName);
100
86
  return;
101
87
  }
102
- }
103
- await mkdir(join(state.config.stateDir, 'directory-sync', workspaceId), { recursive: true, mode: 0o700 });
104
- const temporary = `${ledger}.${randomUUID()}.tmp`;
105
- await writeFile(temporary, JSON.stringify({ sourceCommit: commitHash, targetCommit: await git(state, root, 'rev-parse', 'HEAD') }), { mode: 0o600 });
106
- await rename(temporary, ledger);
107
- synced.push(directoryName);
108
- });
88
+ const containing = await git(state, root, 'merge-base', '--is-ancestor', commitHash, 'HEAD').then(() => true, () => false);
89
+ if (containing) {
90
+ synced.push(directoryName);
91
+ return;
92
+ }
93
+ // Import the exact preserved basic commit, without fetching a moving remote branch.
94
+ await execute('git', ['-c', 'core.hooksPath=/dev/null', '-C', root, 'fetch', '--no-tags', '--', basic, commitHash], { timeout: 30_000, maxBuffer: 1024 * 1024 });
95
+ const equivalent = (await git(state, root, 'cherry', 'HEAD', commitHash)).split('\n').some(line => line === `- ${commitHash}`);
96
+ if (!equivalent) {
97
+ try {
98
+ await git(state, root, '-c', 'user.name=PlayCraft', '-c', 'user.email=playcraft@localhost', 'cherry-pick', commitHash);
99
+ }
100
+ catch {
101
+ conflicts.push(directoryName);
102
+ return;
103
+ }
104
+ }
105
+ // private
106
+ await mkdir(join(state.config.stateDir, 'directory-sync', workspaceId), { recursive: true, mode: 0o700 });
107
+ const temporary = `${ledger}.${randomUUID()}.tmp`;
108
+ // private
109
+ await writeFile(temporary, JSON.stringify({ sourceCommit: commitHash, targetCommit: await git(state, root, 'rev-parse', 'HEAD') }), { mode: 0o600 });
110
+ await rename(temporary, ledger);
111
+ synced.push(directoryName);
112
+ }));
113
+ }
114
+ catch (error) {
115
+ if (error instanceof Error && error.message === 'directory_busy')
116
+ skipped.push({ directoryName, reason: 'directory_busy' });
117
+ else
118
+ throw error;
119
+ }
109
120
  }
110
121
  return { synced, skipped, conflicts, previewStale: true };
111
122
  }
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises';
3
+ import { lstat, open, readdir, realpath, rename, rm } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { z } from 'zod';
6
6
  import { directoryDeletePayloadSchema, directoryRenamePayloadSchema, hashOperationPayload, isMutableWorkspaceDirectoryPath, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { safeAssetTransferDirectory, syncAssetTransferDirectory } from './asset-transfer-storage.js';
8
+ import { ensureSharedDirectory } from './shared-write.js';
8
9
  export class DirectoryMutationError extends Error {
9
10
  code;
10
11
  constructor(code) {
@@ -60,6 +61,7 @@ async function validateTree(root, path) {
60
61
  async function saveRecord(directory, record) {
61
62
  await safeAssetTransferDirectory(directory);
62
63
  const temporary = join(directory, `${randomUUID()}.tmp`);
64
+ // private
63
65
  const file = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
64
66
  try {
65
67
  await file.writeFile(JSON.stringify(record));
@@ -183,8 +185,11 @@ export async function mutateWorkspaceDirectory(root, kind, input, options) {
183
185
  if (newPath) {
184
186
  // Reserve an absent destination atomically; only our recorded empty placeholder may be replaced.
185
187
  if (!destinationIdentity) {
186
- await mkdir(destination, { mode: 0o700 }).catch(error => { if (error.code === 'EEXIST')
187
- return fail('directory_exists'); throw error; });
188
+ if (await lstat(destination).catch(error => { if (missing(error))
189
+ return undefined; throw error; }))
190
+ return fail('directory_exists');
191
+ // A renamed directory is a workspace entry both identities must reach.
192
+ await ensureSharedDirectory(root, destination);
188
193
  record.reservation = await identity(destination);
189
194
  await saveRecord(directory, record);
190
195
  }
@@ -13,6 +13,7 @@ export class InvalidWorkspacePathError extends Error {
13
13
  }
14
14
  }
15
15
  export async function ensureWorkspaceRoot(root) {
16
+ // private
16
17
  await mkdir(root, { recursive: true, mode: 0o700 });
17
18
  return realpath(root);
18
19
  }
@@ -37,8 +38,10 @@ export async function resolveWorkspaceFile(root, relativePath) {
37
38
  }
38
39
  export async function writeWorkspaceFile(root, relativePath, content) {
39
40
  const target = await resolveWorkspaceFile(root, relativePath);
41
+ // private
40
42
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
41
43
  const bytes = Buffer.from(content, 'utf8');
44
+ // private
42
45
  await writeFile(target, bytes, { mode: 0o600 });
43
46
  return { path: relativePath, contentHash: hashWorkspaceFileContent(content), size: bytes.byteLength };
44
47
  }
@@ -134,6 +137,7 @@ async function assertParentInsideRoot(rootReal, parent) {
134
137
  catch (error) {
135
138
  if (error.code !== 'ENOENT')
136
139
  throw error;
140
+ // private
137
141
  await mkdir(parent, { recursive: true, mode: 0o700 });
138
142
  const parentReal = await realpath(parent);
139
143
  assertInsideRoot(rootReal, parentReal);
@@ -240,6 +240,7 @@ async function configureGitFile(root, stateDir, commands, repositoryUrl) {
240
240
  gitDirectory = await open(join(pinnedRoot, '.git'), directoryFlags);
241
241
  const gitPath = process.platform === 'linux' ? `/proc/self/fd/${gitDirectory.fd}` : join(root, '.git');
242
242
  const configPath = join(gitPath, 'config');
243
+ // private
243
244
  lock = await open(join(gitPath, 'config.lock'), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o640);
244
245
  lockPath = join(gitPath, 'config.lock');
245
246
  const source = await open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -254,9 +255,11 @@ async function configureGitFile(root, stateDir, commands, repositoryUrl) {
254
255
  finally {
255
256
  await source.close();
256
257
  }
258
+ // private
257
259
  await mkdir(stateDir, { recursive: true, mode: 0o700 });
258
260
  temporary = await mkdtemp(join(stateDir, 'git-config-'));
259
261
  const staging = join(temporary, 'config');
262
+ // private
260
263
  await writeFile(staging, content, { mode: 0o600 });
261
264
  await clearManagedGitConfig(staging, repositoryUrl);
262
265
  for (const args of commands)
@@ -39,6 +39,7 @@ export class WorkspaceGitDirectStore {
39
39
  throw new Error('git_control_directory_rejected');
40
40
  }
41
41
  for (const path of [stateDir, directory]) {
42
+ // private
42
43
  if (path === directory && create)
43
44
  await mkdir(path, { mode: 0o700 }).catch(error => {
44
45
  if (error.code !== 'EEXIST')
@@ -105,6 +106,7 @@ export class WorkspaceGitDirectStore {
105
106
  if (rejectedPassword !== undefined && old?.access?.password !== rejectedPassword)
106
107
  return;
107
108
  const temporary = join(path, `${randomUUID()}.tmp`);
109
+ // private
108
110
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
109
111
  try {
110
112
  await handle.writeFile(JSON.stringify({ identity: this.identity(), binding, access }));