@playcraft/cli 0.1.37-beta → 0.1.38-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.
@@ -296,9 +296,16 @@ export class RuntimeJournal {
296
296
  }
297
297
  apply(dump) {
298
298
  const group = dump.snapshot.executionGroup;
299
+ const events = dump.snapshot.events ?? this.snapshot.events;
300
+ // Keep history in place: copying it on every streamed delta grows quadratically.
301
+ for (const event of dump.committedEvents ?? [])
302
+ events[sourceEventKey(event.source)] = event;
303
+ for (const event of dump.eventChanges ?? [])
304
+ events[sourceEventKey(event.source)] = event;
299
305
  this.snapshot = {
300
306
  ...emptySnapshot(),
301
307
  ...dump.snapshot,
308
+ events,
302
309
  executionGroup: {
303
310
  stopConfirmed: group?.stopConfirmed === true,
304
311
  replaced: group?.replaced === true,
@@ -83,8 +83,7 @@ function dispatch(type, payload) {
83
83
  case 'allocateEvent':
84
84
  return allocateEvent(database, body);
85
85
  case 'commitIdentity':
86
- commitIdentity(database, body.event);
87
- return;
86
+ return { eventChanges: commitIdentity(database, body.event).committedEvents };
88
87
  case 'ingest':
89
88
  return ingest(database, body);
90
89
  case 'bindTurn':
@@ -108,7 +107,7 @@ function dispatch(type, payload) {
108
107
  }
109
108
  });
110
109
  const extra = write();
111
- return { ...dump(database), ...(extra && typeof extra === 'object' ? extra : {}) };
110
+ return { ...dump(database, type === 'reclaim'), ...(extra && typeof extra === 'object' ? extra : {}) };
112
111
  }
113
112
  function openDatabase(payload) {
114
113
  if (db)
@@ -820,13 +819,14 @@ function reclaim(database, minRetainMs) {
820
819
  }
821
820
  }
822
821
  }
823
- function dump(database) {
822
+ function dump(database, includeEvents = true) {
824
823
  const snapshot = {
825
824
  workspaces: bucketObject(database, 'workspaces'),
826
825
  workspaceFileRevisions: workspaceFiles(database),
827
826
  operations: operationsObject(database),
828
827
  receipts: bucketObject(database, 'receipts'),
829
- events: eventsObject(database),
828
+ // Startup and reclamation replace history; ordinary writes return only their new events.
829
+ ...(includeEvents ? { events: eventsObject(database) } : {}),
830
830
  sessions: bucketObject(database, 'sessions'),
831
831
  terminals: bucketObject(database, 'terminals'),
832
832
  fileRevisions: bucketObject(database, 'file_revisions'),
@@ -0,0 +1,36 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execute = promisify(execFile);
4
+ const reference = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
5
+ const excludedDirectories = new Set(['node_modules', 'dist', 'build', 'coverage', 'docs', 'test', 'tests', '__tests__']);
6
+ const contentExtension = /\.(?:[cm]?[jt]sx?|vue|svelte|html?|css|s[ac]ss|less|json5?|ya?ml|xml|glsl|wgsl|vert|frag|lua|py|svg|png|jpe?g|webp|avif|gif|bmp|mp[34]|wav|ogg|m4a|webm|glb|gltf|fbx|obj|bin|atlas|woff2?|ttf|otf)$/i;
7
+ function contentPath(path) {
8
+ const parts = path.split('/');
9
+ if (parts.some(part => part.startsWith('.') || excludedDirectories.has(part)))
10
+ return false;
11
+ const name = parts.at(-1);
12
+ if (/^(?:package(?:-lock)?\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|tsconfig(?:\.[^.]+)?\.json|(?:vite|vitest|webpack|eslint|prettier)\.config\..+)$/i.test(name))
13
+ return false;
14
+ return contentExtension.test(name);
15
+ }
16
+ /** A completed native turn is useful only when code, runtime configuration, or assets changed. */
17
+ export async function requireProductionContentChanges(root, before, after) {
18
+ if (!reference.test(before) || !reference.test(after))
19
+ throw new Error('production_change_reference_invalid');
20
+ const { stdout } = await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root,
21
+ 'diff', '--raw', '--no-abbrev', '--no-renames', '-z', before, after, '--'], {
22
+ timeout: 90_000, maxBuffer: 64 * 1024 * 1024,
23
+ env: { PATH: process.env.PATH, HOME: root, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
24
+ });
25
+ const records = stdout.split('\0'), paths = [];
26
+ for (let index = 0; index + 1 < records.length; index += 2) {
27
+ const [oldMode, newMode, oldBlob, newBlob] = records[index].slice(1).split(' ');
28
+ const path = records[index + 1];
29
+ if ((oldMode === '100644' || oldMode === '100755' || newMode === '100644' || newMode === '100755')
30
+ && oldBlob !== newBlob && contentPath(path))
31
+ paths.push(path);
32
+ }
33
+ if (!paths.length)
34
+ throw new Error('production_generation_no_content_change');
35
+ return paths;
36
+ }
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
3
3
  import { lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { batchBaselineSnapshotSchema, batchSourceSnapshotSchema } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { batchBaselineSnapshotSchema, batchSavedEvidenceSchema, batchSourceSnapshotSchema } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { checkoutBatchCommit, freezeBatchCommit } from '../workspaces/batch-commit.js';
8
8
  import { grantWorkspaceTraversal, openDirectoryToDevelopment } from '../workspaces/directory-access.js';
9
9
  import { registerWorkspaceDirectory } from '../workspaces/directories.js';
@@ -20,6 +20,8 @@ import { workspaceDirectoryOccupied } from '../workspaces/directory-maintenance.
20
20
  import { ensureWorkspaceSkills } from '../workspaces/skills.js';
21
21
  import { cachedProductionSourceArchive, captureProductionSourceArchive } from './source-archive.js';
22
22
  import { prepareWorkspaceCheckout } from '../workspaces/prepare.js';
23
+ import { snapshotWorkspaceTree } from '../workspaces/git.js';
24
+ import { requireProductionContentChanges } from './content-changes.js';
23
25
  const execute = promisify(execFile);
24
26
  const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
25
27
  const hash = (body) => createHash('sha256').update(body).digest('hex');
@@ -170,7 +172,15 @@ export async function executeProductionStage(state, input, processRecord, track)
170
172
  await registerWorkspaceDirectory(state, directory, state.acceptedLeaseFence, sourceDirectoryName);
171
173
  }
172
174
  const root = await workspaceRoot(state, workspaceId, input.directoryName);
175
+ const variantChanges = async (commitHash) => {
176
+ if (!directSource || resultId === input.task.attemptId)
177
+ return undefined;
178
+ const source = batchSourceSnapshotSchema.parse(manifest.sourceSnapshot);
179
+ return requireProductionContentChanges(root, source.commitHash, commitHash);
180
+ };
173
181
  if (stage === 'prepare') {
182
+ if (input.payload.repairCommitHash !== undefined && await git(root, 'rev-parse', 'HEAD') !== input.payload.repairCommitHash)
183
+ throw new Error('production_repair_commit_mismatch');
174
184
  await settleDependencyInstalls(state);
175
185
  if (!(await lstat(join(root, 'package.json')).catch(() => undefined)))
176
186
  return { prepared: true, directoryName: input.directoryName, emptySource: true };
@@ -200,15 +210,34 @@ export async function executeProductionStage(state, input, processRecord, track)
200
210
  return { prepared: true, directoryName: input.directoryName };
201
211
  }
202
212
  if (stage === 'generate') {
213
+ const beforeTree = await snapshotWorkspaceTree(root);
203
214
  await runProductionNative(state, input, root, processRecord, track);
204
215
  await resolveProductionDependencies(root);
205
- return { generated: true };
216
+ const afterTree = await snapshotWorkspaceTree(root);
217
+ const changedPaths = await requireProductionContentChanges(root, beforeTree, afterTree);
218
+ await variantChanges(afterTree);
219
+ return { generated: true, changes: { beforeTree, afterTree, changedPaths } };
206
220
  }
207
221
  if (stage === 'save') {
208
222
  const binding = state.journal.getWorkspace(workspaceId);
209
223
  const branch = input.directoryName === 'basic' ? binding.branchName : binding.directories?.find(value => value.directoryName === input.directoryName)?.branchName;
210
224
  if ((input.git && input.git.branchName !== branch) || await git(root, 'symbolic-ref', '--short', 'HEAD') !== branch)
211
225
  throw new Error('production_git_binding_mismatch');
226
+ if (input.payload.saved !== undefined) {
227
+ const saved = batchSavedEvidenceSchema.parse(input.payload.saved);
228
+ if (input.task.kind !== 'retry' || saved.attemptId === input.task.attemptId || saved.workspaceId !== workspaceId
229
+ || saved.resultVersionId !== resultId || saved.commitHash !== saved.pushedCommitHash)
230
+ throw new Error('production_recovery_saved_identity_mismatch');
231
+ if (await git(root, 'rev-parse', 'HEAD') !== saved.commitHash)
232
+ throw new Error('production_repair_commit_mismatch');
233
+ const snapshot = await freezeBatchCommit({ source: { workspaceId, root }, commitHash: saved.commitHash, snapshotId: `${input.task.attemptId}-save` });
234
+ if (snapshot.checksum !== saved.sourceDigest)
235
+ throw new Error('production_recovery_source_mismatch');
236
+ if (await snapshotWorkspaceTree(root) !== await git(root, 'rev-parse', `${saved.commitHash}^{tree}`))
237
+ throw new Error('production_recovery_worktree_changed');
238
+ await variantChanges(snapshot.commitHash);
239
+ return { snapshot, saved: { ...saved, attemptId: input.task.attemptId } };
240
+ }
212
241
  await run(root, 'chmod -R u+rwX,g+rwX .').catch(() => undefined);
213
242
  await excludeFromGit(root);
214
243
  await excludeFromGit(root, '/.env*');
@@ -220,6 +249,7 @@ export async function executeProductionStage(state, input, processRecord, track)
220
249
  await run(root, productionLocalCommitCommand(), env, true);
221
250
  const commitHash = await git(root, 'rev-parse', 'HEAD');
222
251
  const snapshot = await freezeBatchCommit({ source: { workspaceId, root }, commitHash, snapshotId: `${input.task.attemptId}-save` });
252
+ await variantChanges(snapshot.commitHash);
223
253
  return { snapshot, saved: { attemptId: input.task.attemptId, resultVersionId: resultId, workspaceId, sourceDigest: snapshot.checksum, commitHash, pushedCommitHash: commitHash } };
224
254
  }
225
255
  if (stage === 'freeze') {
@@ -237,6 +267,7 @@ export async function executeProductionStage(state, input, processRecord, track)
237
267
  const snapshot = (previous('freeze')?.snapshot ?? previous('save')?.snapshot);
238
268
  if (!snapshot || snapshot.commitHash !== saved.commitHash || snapshot.checksum !== saved.sourceDigest)
239
269
  throw new Error('production_saved_snapshot_mismatch');
270
+ await variantChanges(snapshot.commitHash);
240
271
  const destination = join(root, '.playcraft-temp', `production-${input.task.attemptId}`);
241
272
  const removeDestination = async () => {
242
273
  try {
@@ -1,4 +1,21 @@
1
1
  const taskUrl = (input) => `${input.callbackUrl.replace(/\/+$/, '')}/internal/cloud-production/tasks/${input.task.taskId}`;
2
+ async function objectFailure(response, input, operation) {
3
+ const value = await response.json().catch(() => undefined);
4
+ const body = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
5
+ const reason = [body.reason, body.code, body.message, body.error]
6
+ .find((value) => typeof value === 'string' && /^[a-z0-9_]{1,128}$/.test(value)
7
+ && !value.includes(input.runnerToken));
8
+ // Keep callback status and machine codes; never record its URL, headers, or response body.
9
+ try {
10
+ console.error(JSON.stringify({ event: 'production.object_transfer_failed', taskId: input.task.taskId,
11
+ attemptId: input.task.attemptId, workspaceId: input.task.workspaceId, directoryName: input.directoryName,
12
+ stage: input.stage, leaseFence: input.lease.fence, operation, status: response.status, reason }));
13
+ }
14
+ catch { /* Diagnostics must preserve the original failure. */ }
15
+ return new Error(operation === 'get'
16
+ ? response.status === 404 ? 'object_missing' : 'object_get_failed'
17
+ : response.status === 409 ? 'object_conflict' : 'object_put_failed');
18
+ }
2
19
  /** Build output travels through the Runtime that leased the task; engineering files travel through Git. */
3
20
  export function productionOutputStore(input) {
4
21
  const object = (key) => `${taskUrl(input)}/objects/${key.split('/').map(encodeURIComponent).join('/')}`;
@@ -6,17 +23,15 @@ export function productionOutputStore(input) {
6
23
  return {
7
24
  async get(key) {
8
25
  const response = await fetch(object(key), { headers, redirect: 'error', signal: AbortSignal.timeout(60_000) });
9
- if (response.status === 404)
10
- throw new Error('object_missing');
11
26
  if (!response.ok)
12
- throw new Error('object_get_failed');
27
+ throw await objectFailure(response, input, 'get');
13
28
  return Buffer.from(await response.arrayBuffer());
14
29
  },
15
30
  async put(key, body) {
16
31
  const response = await fetch(object(key), { method: 'PUT', body: new Uint8Array(body), redirect: 'error', signal: AbortSignal.timeout(60_000),
17
32
  headers: { ...headers, 'content-type': 'application/octet-stream' } });
18
33
  if (!response.ok)
19
- throw new Error(response.status === 409 ? 'object_conflict' : 'object_put_failed');
34
+ throw await objectFailure(response, input, 'put');
20
35
  },
21
36
  };
22
37
  }
@@ -2,7 +2,7 @@ import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/pr
2
2
  import { dirname, join, relative, resolve, sep } from 'node:path';
3
3
  import { hashWorkspaceFileContent, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
4
  import { sampleDiskFile } from './disk-snapshot.js';
5
- const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
5
+ const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules', '.playcraft-temp']);
6
6
  const LARGE_FILE_STREAM_THRESHOLD = 8 * 1024 * 1024;
7
7
  export class InvalidWorkspacePathError extends Error {
8
8
  code = 'invalid_workspace_path';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.37-beta",
3
+ "version": "0.1.38-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -51,8 +51,8 @@
51
51
  "@gltf-transform/core": "^4.3.0",
52
52
  "@gltf-transform/extensions": "^4.3.0",
53
53
  "@gltf-transform/functions": "^4.3.0",
54
- "@playcraft/build": "^0.1.37-beta",
55
- "@playcraft/common": "^0.1.37-beta",
54
+ "@playcraft/build": "^0.1.38-beta",
55
+ "@playcraft/common": "^0.1.38-beta",
56
56
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
57
57
  "chokidar": "^4.0.3",
58
58
  "commander": "^13.1.0",