@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.
- package/dist/workspace-runtime/persistence/journal.js +7 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +5 -5
- package/dist/workspace-runtime/production/content-changes.js +36 -0
- package/dist/workspace-runtime/production/stages.js +33 -2
- package/dist/workspace-runtime/production/storage.js +19 -4
- package/dist/workspace-runtime/workspaces/files.js +1 -1
- package/package.json +3 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
55
|
-
"@playcraft/common": "^0.1.
|
|
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",
|