@playcraft/cli 0.1.56-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 (101) hide show
  1. package/dist/assets/client.js +6 -4
  2. package/dist/commands/tools-3d.js +15 -0
  3. package/dist/commands/tools-generation.js +3 -2
  4. package/dist/commands/workspace.js +43 -0
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace/messages.js +10 -0
  11. package/dist/workspace-runtime/batch/deliver.js +140 -80
  12. package/dist/workspace-runtime/batch/freeze.js +18 -6
  13. package/dist/workspace-runtime/batch/generate.js +68 -64
  14. package/dist/workspace-runtime/batch/prepare.js +109 -137
  15. package/dist/workspace-runtime/batch/records.js +4 -0
  16. package/dist/workspace-runtime/batch/routes.js +10 -2
  17. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  18. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  19. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  20. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  21. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  22. package/dist/workspace-runtime/codex/app-server.js +1 -0
  23. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  24. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  25. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  26. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  27. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  28. package/dist/workspace-runtime/persistence/journal.js +34 -0
  29. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  30. package/dist/workspace-runtime/previews/output.js +2 -0
  31. package/dist/workspace-runtime/previews/publish.js +20 -5
  32. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  33. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  34. package/dist/workspace-runtime/serve.js +19 -32
  35. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  36. package/dist/workspace-runtime/server/directory-access.js +414 -0
  37. package/dist/workspace-runtime/server/dispatch.js +61 -23
  38. package/dist/workspace-runtime/server/endpoints.js +1 -0
  39. package/dist/workspace-runtime/server/http.js +52 -10
  40. package/dist/workspace-runtime/server/operation-record.js +2 -0
  41. package/dist/workspace-runtime/server/terminal-host.js +43 -8
  42. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  43. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  44. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  45. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  46. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  47. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  48. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  49. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  50. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  51. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  52. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  53. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  54. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  55. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  56. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  57. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  58. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  59. package/dist/workspace-runtime/workspaces/dependencies.js +105 -71
  60. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  61. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  62. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  63. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  64. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  65. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  66. package/dist/workspace-runtime/workspaces/files.js +4 -0
  67. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  68. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  69. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  70. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  71. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  72. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  73. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  74. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  75. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  76. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  77. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  78. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  79. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  80. package/dist/workspace-runtime/workspaces/platform-export.js +190 -0
  81. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  82. package/dist/workspace-runtime/workspaces/registry.js +11 -0
  83. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  84. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  85. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  86. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  87. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  88. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  89. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  90. package/package.json +5 -4
  91. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  92. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  93. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
  94. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  95. package/templates/preview-runtime/v1/README.md +4 -0
  96. package/templates/preview-runtime/v1/manifest.json +6 -6
  97. package/templates/preview-runtime/v1/runtime.mjs +368 -22
  98. package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
  99. package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
  100. package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
  101. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { link, lstat, mkdir, open, readdir, realpath, rename, rm, unlink } from 'node:fs/promises';
4
- import { join } from 'node:path';
4
+ import { join, relative } from 'node:path';
5
5
  import { ASSET_TRANSFER_BODY_MAX_BYTES, ASSET_TRANSFER_CHUNK_BYTES, ASSET_TRANSFER_FILE_MAX_BYTES, assetBundleReceiptSchema, assetTransferChunkSchema, assetTransferManifestSchema, assetTransferStatusSchema, hashAssetTransferManifest, isAssetBundleReceiptForManifest, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { ensureSharedDirectory } from './shared-write.js';
6
7
  export class AssetTransferError extends Error {
7
8
  code;
8
9
  status;
@@ -27,9 +28,19 @@ export async function safeAssetTransferDirectory(root, path = '', create = false
27
28
  if (part === '.' || part === '..' || /[\\:\x00-\x1f\x7f]/.test(part))
28
29
  failPath();
29
30
  current = join(current, part);
30
- if (create)
31
+ // The scratch root is shared by both identities; each transaction below it stays private.
32
+ if (create && relative(root, current) === '.playcraft-temp') {
33
+ await ensureSharedDirectory(root, current).catch(error => {
34
+ if (error.message === 'invalid_shared_directory')
35
+ failPath();
36
+ throw error;
37
+ });
38
+ }
39
+ else if (create) {
40
+ // private
31
41
  await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
32
42
  throw error; });
43
+ }
33
44
  const stat = await lstat(current);
34
45
  if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(current) !== current)
35
46
  failPath();
@@ -93,6 +104,7 @@ export async function hashAssetTransferFile(path, limit = ASSET_TRANSFER_FILE_MA
93
104
  export async function saveAssetTransfer(transfer) {
94
105
  await safeAssetTransferDirectory(transfer.directory);
95
106
  const temporary = join(transfer.directory, `record-${randomUUID()}.tmp`);
107
+ // private
96
108
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
97
109
  try {
98
110
  await handle.writeFile(JSON.stringify(transfer.record));
@@ -194,6 +206,7 @@ export async function withAssetTransfer(root, scope, id, action) {
194
206
  }
195
207
  async function saveProgress(transfer, files) {
196
208
  const temporary = join(transfer.directory, `progress-${randomUUID()}.tmp`);
209
+ // private
197
210
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
198
211
  try {
199
212
  await handle.writeFile(JSON.stringify(files));
@@ -292,6 +305,7 @@ async function createAssetTransferUnlocked(root, scope, input) {
292
305
  const directory = await safeAssetTransferDirectory(rootReal, `.playcraft-temp/asset-transfers/${manifest.transferId}`, true);
293
306
  const record = { version: 1, createdAt: Date.now(), scope, manifest, manifestHash: hashAssetTransferManifest(manifest), status: 'uploading' };
294
307
  const temporary = join(directory, `create-${randomUUID()}.tmp`);
308
+ // private
295
309
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
296
310
  try {
297
311
  await handle.writeFile(JSON.stringify(record));
@@ -366,6 +380,7 @@ export async function putAssetTransferChunk(root, scope, id, input) {
366
380
  throw error;
367
381
  }
368
382
  const temporary = join(directory, `chunk-${randomUUID()}.tmp`);
383
+ // private
369
384
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
370
385
  try {
371
386
  await handle.writeFile(bytes);
@@ -13,6 +13,7 @@ async function attachmentRoot(root) {
13
13
  let directory = root;
14
14
  for (const part of WORKSPACE_USER_UPLOADS_DIRECTORY.split('/')) {
15
15
  directory = join(directory, part);
16
+ // shared
16
17
  await mkdir(directory, { mode: 0o755 }).catch((error) => {
17
18
  if (error.code !== 'EEXIST')
18
19
  throw error;
@@ -62,6 +63,7 @@ export async function uploadWorkspaceAttachment(root, body) {
62
63
  const uploads = await attachmentRoot(root);
63
64
  const directory = join(uploads, attachment.id);
64
65
  const metadataDirectory = join(uploads, '.metadata');
66
+ // shared
65
67
  await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => {
66
68
  if (error.code !== 'EEXIST')
67
69
  throw error;
@@ -69,8 +71,11 @@ export async function uploadWorkspaceAttachment(root, body) {
69
71
  if (!(await lstat(metadataDirectory)).isDirectory())
70
72
  throw new Error('invalid_attachment');
71
73
  // Codex can run as a different user from the sandbox control process.
74
+ // shared
72
75
  await mkdir(directory, { mode: 0o755 });
76
+ // shared
73
77
  await writeFile(join(directory, attachment.name), bytes, { flag: 'wx', mode: 0o644 });
78
+ // shared
74
79
  await writeFile(join(metadataDirectory, `${attachment.id}.json`), JSON.stringify(attachment), {
75
80
  flag: 'wx',
76
81
  mode: 0o644,
@@ -88,11 +93,14 @@ export async function uploadWorkspaceVideoAttachment(root, request, query) {
88
93
  await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
89
94
  const uploads = await attachmentRoot(root);
90
95
  const id = randomUUID(), directory = join(uploads, id), metadataDirectory = join(uploads, '.metadata');
96
+ // shared
91
97
  await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => { if (error.code !== 'EEXIST')
92
98
  throw error; });
93
99
  if (!(await lstat(metadataDirectory)).isDirectory())
94
100
  throw new Error('invalid_attachment');
101
+ // shared
95
102
  await mkdir(directory, { mode: 0o755 });
103
+ // shared
96
104
  const handle = await open(join(directory, name), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
97
105
  try {
98
106
  let size = 0;
@@ -112,6 +120,7 @@ export async function uploadWorkspaceVideoAttachment(root, request, query) {
112
120
  if (contentType !== media.mimeType && !(contentType === 'video/quicktime' && media.mimeType === 'video/mp4'))
113
121
  throw new Error('invalid_attachment');
114
122
  const attachment = { id, name, size, ...media, sha256: await hashAttachment(handle) };
123
+ // shared
115
124
  await writeFile(join(metadataDirectory, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
116
125
  return attachment;
117
126
  }
@@ -1,11 +1,12 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { constants } from 'node:fs';
3
3
  import { createHash } from 'node:crypto';
4
- import { lstat, mkdir, open, realpath, rm, writeFile } from 'node:fs/promises';
4
+ import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises';
5
5
  import { dirname, isAbsolute, join, relative, sep } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { batchCommitSourceVersion } from '@playcraft/common/cloud-workspace/runtime';
8
8
  import { validateProjectSkillsSnapshot } from '@playcraft/common/project-skills';
9
+ import { ensureSharedDirectory, writeSharedFile } from './shared-write.js';
9
10
  const execute = promisify(execFile);
10
11
  const ID = /^[A-Za-z0-9_-]{1,128}$/;
11
12
  const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
@@ -131,8 +132,9 @@ export async function checkoutBatchCommit(input) {
131
132
  if (await lstat(target).catch(() => undefined))
132
133
  continue;
133
134
  const body = await readArtifact(root, path);
134
- await mkdir(dirname(target), { recursive: true });
135
- await writeFile(target, body, { flag: 'wx', mode: 0o600 });
135
+ // The checkout is handed to the development identity, so its archives must be group readable.
136
+ await ensureSharedDirectory(input.destination, dirname(target));
137
+ await writeSharedFile(input.destination, target, body);
136
138
  }
137
139
  }
138
140
  catch (cause) {
@@ -1,12 +1,13 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { link, lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises';
3
+ import { link, lstat, open, realpath, rename, unlink } from 'node:fs/promises';
4
4
  import { basename, join } from 'node:path';
5
5
  import { fileBinaryWritePayloadSchema } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { artifactPath } from './artifact-files.js';
7
7
  import { sampleDiskFile } from './disk-snapshot.js';
8
8
  import { InvalidWorkspacePathError } from './files.js';
9
- import { shareWorkspaceAssetDirectory, shareWorkspaceAssetFile } from './asset-publication-access.js';
9
+ import { shareWorkspaceAssetFile } from './asset-publication-access.js';
10
+ import { ensureSharedDirectory } from './shared-write.js';
10
11
  export class BinaryWorkspaceWriteError extends Error {
11
12
  code;
12
13
  constructor(code) {
@@ -45,14 +46,17 @@ export async function writeWorkspaceBinaryFile(root, input) {
45
46
  throw new InvalidWorkspacePathError(payload.path);
46
47
  for (const part of parts.slice(0, -1)) {
47
48
  parent = join(parent, part);
48
- let created = false;
49
- await mkdir(parent, { mode: 0o700 }).then(() => { created = true; }).catch(error => { if (error.code !== 'EEXIST')
50
- throw error; });
49
+ const existing = await lstat(parent).catch((error) => {
50
+ if (error.code === 'ENOENT')
51
+ return undefined;
52
+ throw error;
53
+ });
54
+ // Only new parents are shared; an existing directory keeps whatever access its owner chose.
55
+ if (!existing)
56
+ await ensureSharedDirectory(rootReal, parent);
51
57
  const stat = await lstat(parent);
52
58
  if (!stat.isDirectory() || stat.isSymbolicLink())
53
59
  throw new InvalidWorkspacePathError(payload.path);
54
- if (created)
55
- await shareWorkspaceAssetDirectory(rootReal, parent);
56
60
  }
57
61
  const parentStat = await lstat(parent);
58
62
  const target = join(parent, basename(payload.path));
@@ -61,6 +65,7 @@ export async function writeWorkspaceBinaryFile(root, input) {
61
65
  let staged = false;
62
66
  try {
63
67
  await verifyPreconditions(rootReal, payload);
68
+ // shared
64
69
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
65
70
  staged = true;
66
71
  try {
@@ -5,6 +5,7 @@ import { excludeFromGit } from './dependencies.js';
5
5
  /** Non-secret routing hint; the business service still authorizes the selected workspace. */
6
6
  export async function writeWorkspaceCliContext(root, workspaceId) {
7
7
  const management = join(root, '.playcraft');
8
+ // shared
8
9
  await mkdir(management, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
9
10
  throw error; });
10
11
  const stat = await lstat(management);
@@ -12,6 +13,7 @@ export async function writeWorkspaceCliContext(root, workspaceId) {
12
13
  throw new Error('workspace_skills_invalid');
13
14
  const temporary = join(management, `workspace-context.${randomUUID()}.tmp`);
14
15
  try {
16
+ // shared
15
17
  await writeFile(temporary, `${JSON.stringify({ schemaVersion: 'playcraft.workspace-context.v1', workspaceId })}\n`, { flag: 'wx', mode: 0o644 });
16
18
  await rename(temporary, join(management, 'workspace-context.json'));
17
19
  await excludeFromGit(root, '/.playcraft/workspace-context.json');
@@ -76,6 +76,7 @@ async function renew(state, entry) {
76
76
  await mkdir(config.workspaceRoot, { recursive: true });
77
77
  const temporary = join(config.workspaceRoot, `.playcraft-credential-${randomUUID()}.tmp`);
78
78
  try {
79
+ // private
79
80
  await writeFile(temporary, JSON.stringify({
80
81
  apiUrl: credential.apiUrl,
81
82
  token: credential.token,
@@ -1,15 +1,17 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { copyFile, cp, chmod, lstat, mkdir, mkdtemp, readdir, readlink, rename, rm, stat, symlink } from 'node:fs/promises';
3
+ import { copyFile, cp, lstat, mkdir, mkdtemp, readdir, readlink, rename, rm, stat, symlink } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { basename, dirname, join, relative, sep } from 'node:path';
6
6
  import { createPlatformAdapter, getChannelRuleProfile, } from '@playcraft/build';
7
7
  import { DELIVERY_QUEUE_LIMIT, isWorkspaceArtifactOutputPath, } from '@playcraft/common/cloud-workspace/runtime';
8
- import { workspaceRoot } from './registry.js';
8
+ import { revisionScope, workspaceRoot } from './registry.js';
9
9
  import { excludeFromGit, ensureWorkspaceDependencies } from './dependencies.js';
10
10
  import { listWorkspaceBuildInputs } from './git.js';
11
11
  import { runDeveloperCommand } from './developer-command.js';
12
12
  import { openScratchToDevelopment } from './directory-access.js';
13
+ import { ensureSharedDirectory } from './shared-write.js';
14
+ import { driftScope, permissionDriftFromOutput, repairWorkspaceShares } from './workspace-shares.js';
13
15
  import { DeliveryStore } from './delivery-store.js';
14
16
  import { DeliveryValidationError, validateDeliveryArtifact } from './delivery.js';
15
17
  export const DELIVERY_BUILD_TIMEOUT_MS = 10 * 60_000;
@@ -196,7 +198,7 @@ async function copyToDist(root, staging, channel, format) {
196
198
  throw new DeliveryBuildError('delivery_output_missing');
197
199
  const target = join(root, relative);
198
200
  const directory = dirname(target);
199
- await mkdir(directory, { recursive: true });
201
+ await ensureSharedDirectory(root, directory);
200
202
  const temporary = join(directory, `.${basename(file)}.${randomUUID()}.tmp`);
201
203
  try {
202
204
  await copyFile(join(staging, file), temporary);
@@ -249,9 +251,8 @@ async function copyInput(root, destination, path) {
249
251
  * caches, logs and local outputs stay behind. Without a repository the whole tree is copied.
250
252
  */
251
253
  export async function isolateWorkspaceForBuild(root, destination) {
252
- await mkdir(destination, { recursive: true });
253
254
  // The build runs as the development identity and writes its scratch folder inside this copy.
254
- await chmod(destination, 0o2770);
255
+ await ensureSharedDirectory(root, destination);
255
256
  const modules = join(root, 'node_modules');
256
257
  const modulesEntry = await lstat(modules).catch(() => undefined);
257
258
  if (modulesEntry?.isDirectory() || modulesEntry?.isSymbolicLink()) {
@@ -319,11 +320,10 @@ async function executeBuild(state, input, controller) {
319
320
  await openScratchToDevelopment(scratch, (await lstat(root)).gid);
320
321
  staging = join(scratch, 'output');
321
322
  const project = join(scratch, 'project');
322
- await mkdir(staging, { recursive: true });
323
- await chmod(staging, 0o2770);
323
+ await ensureSharedDirectory(root, staging);
324
324
  await excludeFromGit(root, `/${STAGING_DIRECTORY}/`);
325
325
  await excludeFromGit(root, '/dist/');
326
- const dependencies = await ensureWorkspaceDependencies(state, input.workspaceId, root);
326
+ const dependencies = await ensureWorkspaceDependencies(state, revisionScope(state, input.workspaceId, input.directoryName) ?? input.workspaceId, root);
327
327
  if (!dependencies.ok) {
328
328
  if (dependencies.logTail) {
329
329
  await store.appendLog(input.buildId, `\n${dependencies.logTail}\n`);
@@ -338,7 +338,8 @@ async function executeBuild(state, input, controller) {
338
338
  }
339
339
  const planned = process.env.PLAYCRAFT_DELIVERY_BUILD_COMMAND || 'playcraft build';
340
340
  const command = `umask 002 && NO_COLOR=1 CI=1 ${planned} . --platform ${input.payload.channel} --format ${input.payload.format} --output ${shellQuote(staging)} --mode full`;
341
- const outcome = await runDeveloperCommand(state, {
341
+ let outputTail = '';
342
+ const runBuild = () => runDeveloperCommand(state, {
342
343
  processId: `delivery-${input.buildId}`,
343
344
  cwd: project,
344
345
  command,
@@ -348,10 +349,28 @@ async function executeBuild(state, input, controller) {
348
349
  const text = String(chunk);
349
350
  if (!text)
350
351
  return;
352
+ outputTail = (outputTail + text).slice(-64 * 1024);
351
353
  logChain = logChain.then(() => store.appendLog(input.buildId, text)).catch(() => undefined);
352
354
  },
353
355
  });
354
- if (outcome.canceled) {
356
+ let outcome = await runBuild();
357
+ let permissionDrift = false;
358
+ if (outcome.code !== 0 && !outcome.canceled && !outcome.timedOut) {
359
+ // The build runs as the development identity; an entry only that identity owns repairs its
360
+ // own shared subtree, then the build is retried exactly once.
361
+ const drift = permissionDriftFromOutput(outputTail);
362
+ if (drift) {
363
+ permissionDrift = true;
364
+ await repairWorkspaceShares(state, root, await driftScope(root, drift.path)).catch(() => undefined);
365
+ outcome = await runBuild();
366
+ permissionDrift = outcome.code !== 0;
367
+ }
368
+ }
369
+ if (outcome.code === undefined) {
370
+ status = 'failed';
371
+ error = 'delivery_process_unknown';
372
+ }
373
+ else if (outcome.canceled) {
355
374
  status = 'canceled';
356
375
  error = 'delivery_canceled';
357
376
  }
@@ -361,7 +380,7 @@ async function executeBuild(state, input, controller) {
361
380
  }
362
381
  else if (outcome.code !== 0) {
363
382
  status = 'failed';
364
- error = 'delivery_build_failed';
383
+ error = permissionDrift ? 'workspace_permission_drift' : 'delivery_build_failed';
365
384
  }
366
385
  else {
367
386
  const output = await copyToDist(root, staging, input.payload.channel, input.payload.format);
@@ -24,6 +24,7 @@ export class DeliveryStore {
24
24
  this.directory = workspaceDirectory(stateDir, workspaceId);
25
25
  }
26
26
  async ready() {
27
+ // private
27
28
  await mkdir(this.directory, { recursive: true, mode: 0o700 });
28
29
  return this.directory;
29
30
  }
@@ -42,6 +43,7 @@ export class DeliveryStore {
42
43
  async writeJson(path, value) {
43
44
  const temporary = `${path}.${randomUUID()}.tmp`;
44
45
  try {
46
+ // private
45
47
  await writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
46
48
  await rename(temporary, path);
47
49
  }
@@ -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;
@@ -15,16 +16,21 @@ const SHARE_WAIT_MS = 60_000;
15
16
  * prompts, and writes plain lines to the log. Package scripts run: the workspace is the user's own
16
17
  * sandbox and the install runs as the development user.
17
18
  */
18
- const INSTALL_COMMAND = 'pnpm install --no-frozen-lockfile --store-dir /var/cache/playcraft/pnpm-store --reporter=append-only --config.confirm-modules-purge=false';
19
- /** Installs whenever there is a package.json; the hash says whether a finished install is current. */
19
+ export function dependencyInstallCommand(state, options = {}) {
20
+ const store = process.env.PLAYCRAFT_PNPM_STORE ?? (process.platform === 'linux' ? '/var/cache/playcraft/pnpm-store' : join(state.config.stateDir, 'pnpm-store'));
21
+ const quotedStore = `'${store.replaceAll("'", "'\\''")}'`;
22
+ return `pnpm install --${options.frozenLockfile ? 'frozen' : 'no-frozen'}-lockfile --store-dir ${quotedStore} --reporter=append-only --config.confirm-modules-purge=false`;
23
+ }
24
+ /** pnpm owns installation; other package managers' lockfiles remain untouched. */
20
25
  export async function planInstall(root) {
21
26
  const manifest = await readOptional(join(root, 'package.json'));
22
27
  if (manifest === undefined)
23
28
  return { kind: 'skip' };
24
- const lock = (await readOptional(join(root, 'pnpm-lock.yaml'))) ?? '';
29
+ const lock = await readOptional(join(root, 'pnpm-lock.yaml'));
25
30
  return {
26
31
  kind: 'install',
27
- lockHash: createHash('sha256').update(manifest).update('\0').update(lock).digest('hex'),
32
+ lockHash: createHash('sha256').update(manifest).update('\0').update(lock ?? '').digest('hex'),
33
+ hasLockfile: lock !== undefined,
28
34
  };
29
35
  }
30
36
  const running = new WeakMap();
@@ -39,17 +45,18 @@ export function scheduleDependencyInstall(state, workspaceId, root, shouldStart)
39
45
  running.set(state, inFlight);
40
46
  const existing = inFlight.get(workspaceId);
41
47
  if (existing?.root === root) {
48
+ void adoptDirectoryAccess(state, `managed:${existing.writeId}`).catch(() => undefined);
42
49
  if (!shouldStart)
43
50
  existing.guards = undefined;
44
51
  else
45
52
  existing.guards?.push(shouldStart);
46
53
  return existing.task;
47
54
  }
48
- 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 };
49
56
  const begin = () => {
50
57
  if (state.journal.destroyed() || (scheduled.guards && !scheduled.guards.some((guard) => guard())))
51
58
  return;
52
- return installOnce(state, workspaceId, root);
59
+ return installOnce(state, workspaceId, root, scheduled.writeId);
53
60
  };
54
61
  const task = (existing ? existing.task.then(begin) : begin() ?? Promise.resolve())
55
62
  .catch((error) => {
@@ -86,8 +93,8 @@ export async function ensureWorkspaceDependencies(state, workspaceId, root) {
86
93
  };
87
94
  }
88
95
  /**
89
- * An install cut off by a restart left no process behind (the launcher stops the group when its
90
- * 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.
91
98
  */
92
99
  export async function recoverInterruptedInstalls(journal) {
93
100
  const now = new Date().toISOString();
@@ -101,72 +108,96 @@ export async function recoverInterruptedInstalls(journal) {
101
108
  finishedAt: now,
102
109
  });
103
110
  }
104
- for (const write of journal.listManagedWrites()) {
105
- if (write.writeId.startsWith(WRITE_PREFIX) && !write.stoppedAt) {
106
- await journal.putManagedWrite({ ...write, stoppedAt: now });
107
- }
108
- }
109
111
  }
110
- async function installOnce(state, workspaceId, root) {
111
- const plan = await planInstall(root);
112
- const current = state.journal.getWorkspaceDependencies(workspaceId);
113
- if (plan.kind === 'skip') {
114
- if (current?.status !== 'none') {
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
+ try {
118
+ if (state.journal.listManagedWrites().some(writer => writer.workspaceId === workspaceId &&
119
+ writer.writeId !== writeId && writer.writeId.startsWith(WRITE_PREFIX) && !writer.stoppedAt)) {
115
120
  await state.journal.putWorkspaceDependencies({
116
- workspaceId,
117
- status: 'none',
118
- finishedAt: new Date().toISOString(),
121
+ workspaceId, status: 'failed', error: 'dependency_run_unconfirmed', finishedAt: new Date().toISOString(),
119
122
  });
123
+ return;
120
124
  }
121
- return;
122
- }
123
- if (current?.status === 'ready' &&
124
- current.lockHash === plan.lockHash &&
125
- (await isDirectory(join(root, 'node_modules')))) {
126
- return;
127
- }
128
- await excludeFromGit(root);
129
- const startedAt = new Date().toISOString();
130
- const writeId = `${WRITE_PREFIX}${workspaceId}-${randomUUID()}`;
131
- // A checkpoint waits for this write: node_modules is half written until the install ends.
132
- await state.journal.putManagedWrite({ writeId, workspaceId, source: 'background', startedAt });
133
- await state.journal.putWorkspaceDependencies({
134
- workspaceId,
135
- status: 'installing',
136
- startedAt,
137
- });
138
- let outcome;
139
- try {
140
- // Through the launcher the install runs as the development group, which cannot enter the
141
- // checkout until Runtime has opened it; a local install runs as the owner and need not wait.
142
- const shared = !launcherSocket(state) || (await waitUntilShared(root));
143
- outcome = shared
144
- ? await runInstall(state, workspaceId, root)
145
- : { code: undefined, timedOut: false, log: '', error: 'workspace_not_shared' };
146
- }
147
- finally {
148
- await state.journal.putManagedWrite({
149
- 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({
150
155
  workspaceId,
151
- source: 'background',
156
+ status: 'installing',
152
157
  startedAt,
153
- 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),
154
195
  });
155
196
  }
156
- const ok = outcome.code === 0 && !outcome.timedOut;
157
- await state.journal.putWorkspaceDependencies({
158
- workspaceId,
159
- status: ok ? 'ready' : 'failed',
160
- ...(ok
161
- ? { lockHash: plan.lockHash }
162
- : {
163
- error: outcome.error ??
164
- (outcome.timedOut ? 'install_timeout' : `install_exit_${outcome.code ?? 'lost'}`),
165
- }),
166
- startedAt,
167
- finishedAt: new Date().toISOString(),
168
- logTail: tail(outcome.log),
169
- });
197
+ finally {
198
+ if (!processMayRun || processStopped)
199
+ release();
200
+ }
170
201
  }
171
202
  /**
172
203
  * Runs as the development user through the launcher, so the packages belong to the same user as
@@ -174,12 +205,12 @@ async function installOnce(state, workspaceId, root) {
174
205
  */
175
206
  async function runInstall(state, workspaceId, root) {
176
207
  // Tests replace the install; a real registry is never reached from them.
177
- const store = process.env.PLAYCRAFT_PNPM_STORE ?? (process.platform === 'linux' ? '/var/cache/playcraft/pnpm-store' : join(state.config.stateDir, 'pnpm-store'));
178
- const quotedStore = `'${store.replaceAll("'", "'\\''")}'`;
179
- const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || INSTALL_COMMAND.replace('/var/cache/playcraft/pnpm-store', quotedStore);
208
+ const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || dependencyInstallCommand(state);
180
209
  const logDir = join(state.config.stateDir, 'dependency-logs');
210
+ // private
181
211
  await mkdir(logDir, { recursive: true, mode: 0o700 });
182
212
  const logPath = join(logDir, `${workspaceId}.log`);
213
+ // private
183
214
  await writeFile(logPath, `$ ${planned}\n`, { mode: 0o600 });
184
215
  let log = '';
185
216
  let pending = Promise.resolve();
@@ -242,6 +273,7 @@ export async function excludeFromGit(root, entry = '/node_modules/') {
242
273
  });
243
274
  const info = await open(join(gitPath, 'info'), flags);
244
275
  handles.push(info);
276
+ // shared
245
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);
246
278
  const metadata = await file.stat();
247
279
  if (!metadata.isFile() || metadata.nlink !== 1 || metadata.size > 1024 * 1024) {
@@ -265,8 +297,10 @@ async function readOptional(path) {
265
297
  try {
266
298
  return await readFile(path, 'utf8');
267
299
  }
268
- catch {
269
- return undefined;
300
+ catch (error) {
301
+ if (error.code === 'ENOENT')
302
+ return undefined;
303
+ throw error;
270
304
  }
271
305
  }
272
306
  async function isDirectory(path) {
@@ -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
  }