@playcraft/cli 0.1.55-beta → 0.1.56-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/commands/build.js +38 -0
- package/dist/workspace-runtime/previews/finalize.js +64 -0
- package/dist/workspace-runtime/previews/publish.js +31 -8
- package/dist/workspace-runtime/server/http.js +5 -5
- package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
- package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
- package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
- package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
- package/dist/workspace-runtime/workspaces/git.js +13 -0
- package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
- package/dist/workspace-runtime/workspaces/parameters.js +61 -21
- package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
- package/package.json +3 -3
- package/project-templates/empty/AGENTS.md +1 -1
- package/templates/preview-runtime/v1/README.md +8 -0
- package/templates/preview-runtime/v1/manifest.json +7 -7
- package/templates/preview-runtime/v1/runtime.d.mts +1 -1
- package/templates/preview-runtime/v1/runtime.d.ts +1 -1
- package/templates/preview-runtime/v1/runtime.mjs +238 -47
- package/templates/preview-runtime/v1/types/adapters.d.ts +3 -1
- package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
- package/templates/preview-runtime/v1/types/protocol.d.ts +37 -0
package/dist/commands/build.js
CHANGED
|
@@ -6,6 +6,10 @@ import { BaseBuilder, ViteBuilder, PlayableBuilder, PlayableAnalyzer, EngineDete
|
|
|
6
6
|
import { loadBuildConfig } from '../build-config.js';
|
|
7
7
|
import { cleanupBuildTempDirectory, createBuildTempDirectory } from './build-temp.js';
|
|
8
8
|
import inquirer from 'inquirer';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { generatedWorkspace } from '../workspace-runtime/workspaces/artifact-receipts.js';
|
|
11
|
+
import { isolateWorkspaceForBuild } from '../workspace-runtime/workspaces/delivery-build.js';
|
|
12
|
+
import { finalizeWorkspacePlayable, workspacePlayableSignature } from '../workspace-runtime/previews/finalize.js';
|
|
9
13
|
/**
|
|
10
14
|
* 类型守卫:检查对象是否符合 PlayableScriptsConfig 类型
|
|
11
15
|
*/
|
|
@@ -245,6 +249,38 @@ async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
|
|
|
245
249
|
export async function buildCommand(projectPath, options) {
|
|
246
250
|
// 解析项目路径 - 在 spinner 之前执行以便调试
|
|
247
251
|
const resolvedProjectPath = path.resolve(projectPath);
|
|
252
|
+
const workspace = options.workspaceArtifact !== false && process.env.PLAYCRAFT_BUILD_ISOLATED !== '1' && options.mode === 'full' && options.platform === 'playcraft'
|
|
253
|
+
&& (!options.format || options.format === 'html') ? await generatedWorkspace(resolvedProjectPath) : undefined;
|
|
254
|
+
const requestedOutput = options.output ? path.resolve(options.output) : path.join(resolvedProjectPath, 'dist');
|
|
255
|
+
const canonicalOutput = [path.join(resolvedProjectPath, 'dist'), path.join(resolvedProjectPath, 'dist/playable')].includes(requestedOutput);
|
|
256
|
+
if (workspace?.root === resolvedProjectPath) {
|
|
257
|
+
const prefix = requestedOutput.endsWith(path.sep) ? requestedOutput : `${requestedOutput}${path.sep}`;
|
|
258
|
+
if (requestedOutput === workspace.root || workspace.root.startsWith(prefix))
|
|
259
|
+
throw new Error('invalid_build_output');
|
|
260
|
+
const scratch = await fs.mkdtemp(path.join(tmpdir(), 'playcraft-workspace-build-'));
|
|
261
|
+
try {
|
|
262
|
+
const signature = canonicalOutput ? await workspacePlayableSignature(workspace.root) : undefined;
|
|
263
|
+
const project = path.join(scratch, 'project'), output = path.join(scratch, 'output');
|
|
264
|
+
await isolateWorkspaceForBuild(workspace.root, project);
|
|
265
|
+
await buildCommand(project, { ...options, workspaceArtifact: false, output });
|
|
266
|
+
if (canonicalOutput) {
|
|
267
|
+
const entries = await fs.readdir(output, { withFileTypes: true });
|
|
268
|
+
if (entries.length !== 1 || !entries[0].isFile() || !entries[0].name.endsWith('.html'))
|
|
269
|
+
throw new Error('preview_not_single_file');
|
|
270
|
+
await finalizeWorkspacePlayable(workspace.root, await fs.readFile(path.join(output, entries[0].name)), 'playcraft build', { expectedSignature: signature });
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
if (options.clean === true)
|
|
274
|
+
await fs.rm(requestedOutput, { recursive: true, force: true });
|
|
275
|
+
await fs.cp(output, requestedOutput, { recursive: true });
|
|
276
|
+
}
|
|
277
|
+
console.log(canonicalOutput ? path.join(workspace.root, 'dist/playable/index.html') : requestedOutput);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
await fs.rm(scratch, { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
248
284
|
console.log(`[DEBUG] projectPath: ${projectPath}`);
|
|
249
285
|
console.log(`[DEBUG] resolvedProjectPath: ${resolvedProjectPath}`);
|
|
250
286
|
console.log(`[DEBUG] cwd: ${process.cwd()}`);
|
|
@@ -982,6 +1018,8 @@ export async function buildCommand(projectPath, options) {
|
|
|
982
1018
|
if (error.stack) {
|
|
983
1019
|
console.error(pc.dim(error.stack));
|
|
984
1020
|
}
|
|
1021
|
+
if (options.workspaceArtifact === false)
|
|
1022
|
+
throw error;
|
|
985
1023
|
process.exit(1);
|
|
986
1024
|
}
|
|
987
1025
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import { PREVIEW_HTML_MAX_BYTES, WORKSPACE_PLAYABLE_OUTPUT_PATH, readWorkspaceRuntimeProfile, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { artifactPath } from '../workspaces/artifact-files.js';
|
|
6
|
+
import { captureGeneratedArtifact } from '../workspaces/artifact-receipts.js';
|
|
7
|
+
import { injectPreviewDiagnostics } from './diagnostics.js';
|
|
8
|
+
import { saveWorkspacePlayableOutput } from './output.js';
|
|
9
|
+
import { findExternalLocalReferences } from './single-file.js';
|
|
10
|
+
export function validateWorkspacePlayable(bytes) {
|
|
11
|
+
const html = bytes.toString('utf8');
|
|
12
|
+
if (!bytes.length || bytes.length > PREVIEW_HTML_MAX_BYTES || !/<(?:!doctype\s+html|html|head|title|body|script|canvas)\b/i.test(html)
|
|
13
|
+
|| findExternalLocalReferences(html).length || readWorkspaceRuntimeProfile(html)) {
|
|
14
|
+
throw new Error('preview_not_single_file');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Reads one stable regular file, never a redirected path or a partially copied output. */
|
|
18
|
+
export async function readWorkspacePlayable(root) {
|
|
19
|
+
const file = await open(await artifactPath(root, WORKSPACE_PLAYABLE_OUTPUT_PATH), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
20
|
+
try {
|
|
21
|
+
const before = await file.stat();
|
|
22
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > PREVIEW_HTML_MAX_BYTES)
|
|
23
|
+
throw new Error('preview_not_single_file');
|
|
24
|
+
const bytes = await file.readFile();
|
|
25
|
+
const after = await file.stat();
|
|
26
|
+
if (before.size !== bytes.length || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
|
|
27
|
+
throw new Error('artifact_file_changed');
|
|
28
|
+
return { bytes, signature: JSON.stringify([before.dev, before.ino, before.size, before.mtimeMs, before.ctimeMs]) };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await file.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function workspacePlayableSignature(root) {
|
|
35
|
+
try {
|
|
36
|
+
const file = await open(await artifactPath(root, WORKSPACE_PLAYABLE_OUTPUT_PATH), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
37
|
+
try {
|
|
38
|
+
const stat = await file.stat();
|
|
39
|
+
if (!stat.isFile() || stat.nlink !== 1)
|
|
40
|
+
throw new Error('preview_not_single_file');
|
|
41
|
+
return JSON.stringify([stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs]);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await file.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT')
|
|
49
|
+
return undefined;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** All build entry points retain the same validated bytes and an immutable generation receipt. */
|
|
54
|
+
export async function finalizeWorkspacePlayable(root, built, tool, options = {}) {
|
|
55
|
+
validateWorkspacePlayable(built);
|
|
56
|
+
const bytes = injectPreviewDiagnostics(built);
|
|
57
|
+
validateWorkspacePlayable(bytes);
|
|
58
|
+
if ('expectedSignature' in options && await workspacePlayableSignature(root) !== options.expectedSignature)
|
|
59
|
+
throw new Error('preview_source_changed');
|
|
60
|
+
const current = await readWorkspacePlayable(root).catch(() => undefined);
|
|
61
|
+
if (!current?.bytes.equals(bytes))
|
|
62
|
+
await saveWorkspacePlayableOutput(root, bytes);
|
|
63
|
+
await captureGeneratedArtifact(WORKSPACE_PLAYABLE_OUTPUT_PATH, tool, {}, root, createHash('sha256').update(bytes).digest('hex'), options.source);
|
|
64
|
+
}
|
|
@@ -2,14 +2,18 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
|
-
import { lstat, open, readdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { lstat, mkdtemp, open, readdir, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { PREVIEW_HTML_MAX_BYTES, PREVIEW_LOG_TAIL_MAX, WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, previewPublishResultSchema, previewReservationSchema, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
8
9
|
import { excludeFromGit, readDependencies, scheduleDependencyInstall, } from '../workspaces/dependencies.js';
|
|
9
10
|
import { runDeveloperCommand } from '../workspaces/developer-command.js';
|
|
10
11
|
import { revisionScope, workspaceRoot } from '../workspaces/registry.js';
|
|
11
12
|
import { classifyPreviewOutput, findExternalLocalReferences } from './single-file.js';
|
|
12
|
-
import {
|
|
13
|
+
import { finalizeWorkspacePlayable, workspacePlayableSignature } from './finalize.js';
|
|
14
|
+
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
15
|
+
import { isolateWorkspaceForBuild } from '../workspaces/delivery-build.js';
|
|
16
|
+
import { openScratchToDevelopment } from '../workspaces/directory-access.js';
|
|
13
17
|
import { injectPreviewDiagnostics } from './diagnostics.js';
|
|
14
18
|
import { OperationLogStore } from '../workspaces/operation-log.js';
|
|
15
19
|
import { prepareRuntimePreview, RuntimePreviewBuildError } from './runtime-preview.js';
|
|
@@ -21,7 +25,7 @@ const BUILDER_TEMP_FOLDER = '.playcraft-temp';
|
|
|
21
25
|
* The generic single-file build. `--mode full` keeps the builder from asking which stages to run.
|
|
22
26
|
* Group-writable output lets the control user, a member of the development group, remove it.
|
|
23
27
|
*/
|
|
24
|
-
const BUILD_COMMAND = 'umask 002 && playcraft build --mode full --platform playcraft --format html';
|
|
28
|
+
const BUILD_COMMAND = 'umask 002 && PLAYCRAFT_BUILD_ISOLATED=1 playcraft build --mode full --platform playcraft --format html';
|
|
25
29
|
class PreviewFailure extends Error {
|
|
26
30
|
code;
|
|
27
31
|
constructor(code) {
|
|
@@ -73,6 +77,8 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
73
77
|
const output = { log: '' };
|
|
74
78
|
const operationLog = new OperationLogStore(state.config.stateDir);
|
|
75
79
|
let logChain = Promise.resolve();
|
|
80
|
+
let remotePreview = false;
|
|
81
|
+
let buildCopy;
|
|
76
82
|
const appendLog = (text) => {
|
|
77
83
|
logChain = logChain.then(() => operationLog.append(operation.operationId, text)).catch(() => undefined);
|
|
78
84
|
};
|
|
@@ -80,12 +86,27 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
80
86
|
try {
|
|
81
87
|
await ensureDependencies(state, revisionScope(state, workspaceId, operation.directoryName) ?? workspaceId, root, output);
|
|
82
88
|
const runtimePreview = await prepareRuntimePreview(state, operation, root);
|
|
89
|
+
remotePreview = Boolean(runtimePreview);
|
|
83
90
|
if (runtimePreview)
|
|
84
91
|
await excludeFromGit(root, '/.playcraft-editor-preview/');
|
|
85
92
|
await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
|
|
86
93
|
await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
|
|
87
94
|
await excludeFromGit(root, `/${WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY}/`);
|
|
88
|
-
|
|
95
|
+
if (!runtimePreview) {
|
|
96
|
+
const signature = await workspacePlayableSignature(root);
|
|
97
|
+
buildCopy = await mkdtemp(join(tmpdir(), 'playcraft-preview-build-'));
|
|
98
|
+
// The build runs as the development identity while this copy belongs to the controller.
|
|
99
|
+
await openScratchToDevelopment(buildCopy, (await lstat(root)).gid);
|
|
100
|
+
const project = join(buildCopy, 'project'), staged = join(buildCopy, 'output');
|
|
101
|
+
await isolateWorkspaceForBuild(root, project);
|
|
102
|
+
await buildPreview(state, project, staged, operation.operationId, output, appendLog);
|
|
103
|
+
await finalizeWorkspacePlayable(root, await readSingleHtml(staged), 'preview.publish', {
|
|
104
|
+
expectedSignature: signature, source: { operationId: operation.operationId, ...(operation.sessionId ? { sessionId: operation.sessionId } : {}) },
|
|
105
|
+
});
|
|
106
|
+
await refreshWorkspaceArtifacts(state, workspaceId, undefined, operation.directoryName ?? 'basic');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await buildPreview(state, root, outputDir, operation.operationId, output, appendLog, runtimePreview.command);
|
|
89
110
|
const runtimeBuild = runtimePreview ? await runtimePreview.finish() : undefined;
|
|
90
111
|
const built = runtimeBuild?.bytes ?? await readSingleHtml(outputDir);
|
|
91
112
|
const bytes = injectPreviewDiagnostics(built);
|
|
@@ -94,8 +115,6 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
94
115
|
if (findExternalLocalReferences(bytes.toString('utf8')).length > 0) {
|
|
95
116
|
throw new PreviewFailure('preview_not_single_file');
|
|
96
117
|
}
|
|
97
|
-
if (!runtimePreview)
|
|
98
|
-
await saveWorkspacePlayableOutput(root, bytes);
|
|
99
118
|
const contentDigest = createHash('sha256').update(bytes).digest('hex');
|
|
100
119
|
const contentMd5 = createHash('md5').update(bytes).digest('base64');
|
|
101
120
|
const source = await sourceRevision(root);
|
|
@@ -149,9 +168,11 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
149
168
|
return published;
|
|
150
169
|
}
|
|
151
170
|
catch (error) {
|
|
152
|
-
const code = error instanceof
|
|
171
|
+
const code = error instanceof RuntimePreviewBuildError ? error.code : previewFailureCode(error);
|
|
172
|
+
if (code === 'dependencies_not_ready' && output.log)
|
|
173
|
+
appendLog(`${output.log}\n`);
|
|
153
174
|
appendLog(`\n[${code}] ${error instanceof Error ? error.message : code}\n`);
|
|
154
|
-
if (code !== 'publish_conflict') {
|
|
175
|
+
if (remotePreview && code !== 'publish_conflict') {
|
|
155
176
|
await postControl(state, '/runtime/internal/preview-failures', {
|
|
156
177
|
workspaceId,
|
|
157
178
|
directoryName: operation.directoryName ?? 'basic',
|
|
@@ -166,6 +187,8 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
166
187
|
finally {
|
|
167
188
|
await logChain;
|
|
168
189
|
await rm(outputDir, { recursive: true, force: true }).catch(() => undefined);
|
|
190
|
+
if (buildCopy)
|
|
191
|
+
await rm(buildCopy, { recursive: true, force: true }).catch(() => undefined);
|
|
169
192
|
}
|
|
170
193
|
}
|
|
171
194
|
export function previewFailureCode(error) {
|
|
@@ -479,10 +479,10 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
479
479
|
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
480
480
|
return;
|
|
481
481
|
}
|
|
482
|
-
await workspaceRoot(state, workspaceId);
|
|
482
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
483
483
|
writeJson(response, 200, url.searchParams.get('refresh') === 'false'
|
|
484
|
-
? await new WorkspaceArtifactStore(state.config.stateDir, workspaceId).list()
|
|
485
|
-
: await refreshWorkspaceArtifacts(state, workspaceId));
|
|
484
|
+
? await new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName).list()
|
|
485
|
+
: await refreshWorkspaceArtifacts(state, workspaceId, undefined, directoryName));
|
|
486
486
|
return;
|
|
487
487
|
}
|
|
488
488
|
if (request.method === 'GET' && url.pathname === '/runtime/artifact-content') {
|
|
@@ -493,7 +493,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
493
493
|
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
494
494
|
return;
|
|
495
495
|
}
|
|
496
|
-
await workspaceRoot(state, workspaceId);
|
|
496
|
+
await workspaceRoot(state, workspaceId, directoryName);
|
|
497
497
|
const parsed = workspaceArtifactContentQuerySchema.safeParse({
|
|
498
498
|
artifactId: url.searchParams.get('artifactId'), versionId: url.searchParams.get('versionId'),
|
|
499
499
|
...(url.searchParams.has('path') ? { path: url.searchParams.get('path') } : {}),
|
|
@@ -505,7 +505,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
505
505
|
return;
|
|
506
506
|
}
|
|
507
507
|
try {
|
|
508
|
-
writeJson(response, 200, await new WorkspaceArtifactStore(state.config.stateDir, workspaceId).content(parsed.data));
|
|
508
|
+
writeJson(response, 200, await new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName).content(parsed.data));
|
|
509
509
|
}
|
|
510
510
|
catch (error) {
|
|
511
511
|
const reason = error instanceof Error ? error.message : 'artifact_unavailable';
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
2
|
import { workspaceRoot } from './registry.js';
|
|
3
3
|
import { WorkspaceArtifactStore } from './artifacts.js';
|
|
4
|
-
|
|
4
|
+
import { finalizeWorkspacePlayable, readWorkspacePlayable, validateWorkspacePlayable, workspacePlayableSignature } from '../previews/finalize.js';
|
|
5
|
+
import { watch } from 'chokidar';
|
|
6
|
+
import { join, relative, sep } from 'node:path';
|
|
7
|
+
import { readdir } from 'node:fs/promises';
|
|
8
|
+
import { generatedWorkspace } from './artifact-receipts.js';
|
|
9
|
+
export async function refreshWorkspaceArtifacts(state, workspaceId, origin, directoryName = 'basic') {
|
|
5
10
|
if (state.journal.destroyed())
|
|
6
11
|
throw new Error('runtime_destroyed');
|
|
7
|
-
const root = await workspaceRoot(state, workspaceId);
|
|
8
|
-
const
|
|
12
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
13
|
+
const active = state.journal.listSessions().some(session => session.workspaceId === workspaceId
|
|
14
|
+
&& (session.directoryName ?? 'basic') === directoryName && ['starting', 'running', 'waiting_for_user'].includes(session.turnStatus ?? ''));
|
|
15
|
+
const result = await new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName)
|
|
16
|
+
.discover(root, origin?.source, origin?.paths, origin?.playable ?? (active ? { mode: 'hold' } : {}));
|
|
9
17
|
if (result.changed && !state.journal.destroyed()) {
|
|
10
18
|
const event = await state.journal.allocateSourceEvent({
|
|
11
19
|
runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
|
|
12
20
|
workspaceId, type: 'artifact.updated', occurredAt: result.list.scannedAt,
|
|
13
|
-
payload: { workspaceId, artifacts: result.list.artifacts.map(artifact => ({ id: artifact.id, currentVersionId: artifact.currentVersionId })) },
|
|
21
|
+
payload: { workspaceId, directoryName, artifacts: result.list.artifacts.map(artifact => ({ id: artifact.id, currentVersionId: artifact.currentVersionId })) },
|
|
14
22
|
});
|
|
15
23
|
// The durable source stream remains authoritative when the callback is temporarily unavailable.
|
|
16
24
|
void fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
|
|
@@ -23,33 +31,138 @@ export async function refreshWorkspaceArtifacts(state, workspaceId, origin) {
|
|
|
23
31
|
}
|
|
24
32
|
/** Completed commands and turns reconcile outputs, including work done before a failed turn. */
|
|
25
33
|
export function observeWorkspaceArtifacts(state) {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const task = refreshWorkspaceArtifacts(state, workspaceId).catch(() => undefined);
|
|
30
|
-
running.add(task);
|
|
31
|
-
void task.finally(() => running.delete(task));
|
|
32
|
-
};
|
|
34
|
+
const running = new Map();
|
|
35
|
+
const starts = new Map();
|
|
36
|
+
const deferred = new Map();
|
|
33
37
|
const stop = state.journal.onSourceEvent((event) => {
|
|
34
|
-
if (!['file.written', 'files.changed', 'filesystem.changed', 'item/completed', 'turn.completed', 'turn/completed', 'turn.failed', 'turn.interrupted'].includes(event.type))
|
|
38
|
+
if (!['file.written', 'files.changed', 'filesystem.changed', 'item/started', 'item/completed', 'turn/started', 'turn.started', 'turn.completed', 'turn/completed', 'turn.failed', 'turn.interrupted'].includes(event.type))
|
|
35
39
|
return;
|
|
36
40
|
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
|
|
37
41
|
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
|
|
38
|
-
const
|
|
42
|
+
const session = sessionId ? state.journal.getSession(sessionId) : undefined;
|
|
43
|
+
const workspaceId = typeof payload.workspaceId === 'string' ? payload.workspaceId : session?.workspaceId;
|
|
39
44
|
if (!workspaceId)
|
|
40
45
|
return;
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
const directoryName = typeof payload.directoryName === 'string' ? payload.directoryName : session?.directoryName ?? 'basic';
|
|
47
|
+
if (directoryName === 'root')
|
|
48
|
+
return;
|
|
49
|
+
const scope = `${workspaceId}:${directoryName}`;
|
|
50
|
+
const item = payload.item && typeof payload.item === 'object' ? payload.item : undefined;
|
|
51
|
+
const turn = payload.turn && typeof payload.turn === 'object' ? payload.turn : undefined;
|
|
52
|
+
const turnId = String(payload.turnId ?? turn?.id ?? session?.activeTurnId ?? 'turn');
|
|
53
|
+
const turnKey = `${encodeURIComponent(String(payload.threadId ?? session?.threadId ?? sessionId))}/${encodeURIComponent(turnId)}`;
|
|
54
|
+
const command = item?.type === 'commandExecution';
|
|
55
|
+
if (event.type.startsWith('item/') && !command)
|
|
56
|
+
return;
|
|
57
|
+
const executionId = command ? `${turnKey}/${encodeURIComponent(String(item?.id))}` : turnKey;
|
|
58
|
+
const started = event.type.endsWith('started');
|
|
59
|
+
const terminal = command || event.type.startsWith('turn');
|
|
60
|
+
const success = command ? item?.status === 'completed' && item.exitCode === 0
|
|
61
|
+
: !['turn.failed', 'turn.interrupted'].includes(event.type) && (turn?.status ?? payload.status ?? 'completed') === 'completed';
|
|
62
|
+
const task = (running.get(scope) ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
63
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
64
|
+
const store = new WorkspaceArtifactStore(state.config.stateDir, workspaceId, directoryName);
|
|
65
|
+
const source = { kind: 'generated', ...(sessionId ? { sessionId } : {}),
|
|
66
|
+
...(typeof payload.operationId === 'string' ? { operationId: payload.operationId } : {}), tool: command ? 'agent command' : 'agent turn' };
|
|
67
|
+
if (started) {
|
|
68
|
+
await refreshWorkspaceArtifacts(state, workspaceId, { source, paths: [], playable: { mode: 'hold', executionId } }, directoryName);
|
|
69
|
+
starts.set(executionId, await workspacePlayableSignature(root).catch(() => undefined));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let force = false;
|
|
73
|
+
let promote = terminal && success;
|
|
74
|
+
const candidate = terminal ? await readWorkspacePlayable(root).catch(() => undefined) : undefined;
|
|
75
|
+
if (terminal && success) {
|
|
76
|
+
force = command && starts.has(executionId) && starts.get(executionId) !== candidate?.signature;
|
|
77
|
+
const occupied = await store.hasPendingPlayableExecution(executionId);
|
|
78
|
+
promote = (!command || force) && !occupied;
|
|
79
|
+
if (command && force && occupied && candidate) {
|
|
80
|
+
try {
|
|
81
|
+
validateWorkspacePlayable(candidate.bytes);
|
|
82
|
+
deferred.set(scope, { bytes: candidate.bytes, source });
|
|
83
|
+
}
|
|
84
|
+
catch { /* An incomplete output never becomes a deferred successful candidate. */ }
|
|
85
|
+
}
|
|
86
|
+
if (promote && candidate && (force || !await store.rejectedPlayable(candidate.bytes))) {
|
|
87
|
+
await finalizeWorkspacePlayable(root, candidate.bytes, source.tool, { expectedSignature: candidate.signature, source }).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const unrelatedFailure = terminal && !success && candidate && (starts.has(executionId) && starts.get(executionId) === candidate.signature
|
|
91
|
+
|| await store.isCurrentPlayable(candidate.bytes));
|
|
92
|
+
starts.delete(executionId);
|
|
93
|
+
await refreshWorkspaceArtifacts(state, workspaceId, { source, paths: [], playable: {
|
|
94
|
+
mode: terminal ? success ? promote ? 'success' : 'skip' : unrelatedFailure ? 'skip' : 'failure' : 'hold', ...(terminal ? { executionId } : {}), force,
|
|
95
|
+
} }, directoryName);
|
|
96
|
+
const waiting = deferred.get(scope);
|
|
97
|
+
if (waiting && !await store.hasPendingPlayableExecution(executionId)) {
|
|
98
|
+
deferred.delete(scope);
|
|
99
|
+
const current = await readWorkspacePlayable(root).catch(() => undefined);
|
|
100
|
+
if (current?.bytes.equals(waiting.bytes) && !await store.rejectedPlayable(current.bytes)) {
|
|
101
|
+
await finalizeWorkspacePlayable(root, waiting.bytes, waiting.source.tool, { expectedSignature: current.signature, source: waiting.source });
|
|
102
|
+
await refreshWorkspaceArtifacts(state, workspaceId, { source: waiting.source, paths: [], playable: { mode: 'success', force: true } }, directoryName);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}).catch(() => undefined);
|
|
106
|
+
running.set(scope, task);
|
|
45
107
|
});
|
|
108
|
+
const receiptScope = (path) => {
|
|
109
|
+
const parts = relative(state.config.workspaceRoot, path).split(sep).filter(Boolean);
|
|
110
|
+
const offset = state.config.workspaceMode === 'multi' ? 2 : 0;
|
|
111
|
+
const directoryName = offset ? parts[1] : 'basic';
|
|
112
|
+
if (offset && (parts[0]?.startsWith('.') || parts.length > 1 && !/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName)))
|
|
113
|
+
return undefined;
|
|
114
|
+
const tail = parts.slice(offset);
|
|
115
|
+
if (tail.some((part, index) => index === 0 ? part !== '.playcraft' : index === 1 ? part !== 'artifact-receipts' : index === 2 ? !/^[a-f0-9-]{36}\.json$/.test(part) : true))
|
|
116
|
+
return undefined;
|
|
117
|
+
return { parts, offset, directoryName: directoryName ?? 'basic' };
|
|
118
|
+
};
|
|
119
|
+
// Only completed generation receipts are observed; project files and build output are excluded.
|
|
120
|
+
const queueReceipt = (path) => {
|
|
121
|
+
const found = receiptScope(path);
|
|
122
|
+
if (!found || found.parts.length !== found.offset + 3)
|
|
123
|
+
return;
|
|
124
|
+
const scope = `${found.offset ? found.parts[0] : 'single'}:${found.directoryName}`;
|
|
125
|
+
const task = (running.get(scope) ?? Promise.resolve()).catch(() => undefined)
|
|
126
|
+
.then(async () => {
|
|
127
|
+
const workspaceId = found.offset ? found.parts[0] : (await generatedWorkspace(state.config.workspaceRoot))?.workspaceId;
|
|
128
|
+
if (workspaceId)
|
|
129
|
+
await refreshWorkspaceArtifacts(state, workspaceId, undefined, found.directoryName);
|
|
130
|
+
}).catch(() => undefined);
|
|
131
|
+
running.set(scope, task);
|
|
132
|
+
};
|
|
133
|
+
const receipts = watch(state.config.workspaceRoot, {
|
|
134
|
+
persistent: false, followSymlinks: false, depth: state.config.workspaceMode === 'multi' ? 5 : 3,
|
|
135
|
+
ignored: path => !receiptScope(path),
|
|
136
|
+
}).on('add', queueReceipt).on('ready', () => {
|
|
137
|
+
// Reconcile receipts created while the initial directory watches were being attached.
|
|
138
|
+
const task = (async () => {
|
|
139
|
+
let roots = [state.config.workspaceRoot];
|
|
140
|
+
if (state.config.workspaceMode === 'multi') {
|
|
141
|
+
roots = [];
|
|
142
|
+
for (const entry of await readdir(state.config.workspaceRoot, { withFileTypes: true })) {
|
|
143
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
144
|
+
continue;
|
|
145
|
+
const container = join(state.config.workspaceRoot, entry.name);
|
|
146
|
+
for (const directory of await readdir(container, { withFileTypes: true })) {
|
|
147
|
+
if (directory.isDirectory() && /^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directory.name))
|
|
148
|
+
roots.push(join(container, directory.name));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const root of roots) {
|
|
153
|
+
const directory = join(root, '.playcraft/artifact-receipts');
|
|
154
|
+
const names = await readdir(directory).catch(() => []);
|
|
155
|
+
for (const name of names)
|
|
156
|
+
if (/^[a-f0-9-]{36}\.json$/.test(name))
|
|
157
|
+
queueReceipt(join(directory, name));
|
|
158
|
+
}
|
|
159
|
+
})().catch(() => undefined);
|
|
160
|
+
running.set('receipt-startup', task);
|
|
161
|
+
}).on('error', () => undefined);
|
|
46
162
|
return async () => {
|
|
47
163
|
stop();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
pending.clear();
|
|
53
|
-
await Promise.all([...running]);
|
|
164
|
+
await receipts.close();
|
|
165
|
+
await running.get('receipt-startup');
|
|
166
|
+
await Promise.all([...running.values()]);
|
|
54
167
|
};
|
|
55
168
|
}
|
|
@@ -56,7 +56,9 @@ export async function snapshotArtifactFile(root, path, objects, role = 'primary'
|
|
|
56
56
|
const before = await source.stat();
|
|
57
57
|
if (!before.isFile() || before.nlink !== 1 || before.size > WORKSPACE_ARTIFACT_MAX_BYTES)
|
|
58
58
|
throw new Error('artifact_file_unavailable');
|
|
59
|
-
|
|
59
|
+
// Objects are written by whichever identity generates the artifact and read by the other.
|
|
60
|
+
output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o660);
|
|
61
|
+
await output.chmod(0o660);
|
|
60
62
|
const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(64 * 1024);
|
|
61
63
|
let size = 0;
|
|
62
64
|
for (;;) {
|
|
@@ -4,10 +4,11 @@ import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { isWorkspaceArtifactOutputPath, workspaceArtifactReceiptSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { artifactDirectory, artifactFormat, relativeArtifactPath, snapshotArtifactBundle } from './artifact-files.js';
|
|
7
|
+
import { shareWorkspaceAssetDirectory } from './asset-publication-access.js';
|
|
7
8
|
import { excludeFromGit } from './dependencies.js';
|
|
8
9
|
export const ARTIFACT_RECEIPTS_DIRECTORY = '.playcraft/artifact-receipts';
|
|
9
10
|
export const artifactReceiptSchema = workspaceArtifactReceiptSchema;
|
|
10
|
-
async function generatedWorkspace(cwd) {
|
|
11
|
+
export async function generatedWorkspace(cwd) {
|
|
11
12
|
for (let root = resolve(cwd);; root = dirname(root)) {
|
|
12
13
|
const file = join(root, '.playcraft/workspace-context.json');
|
|
13
14
|
try {
|
|
@@ -32,8 +33,23 @@ async function generatedWorkspace(cwd) {
|
|
|
32
33
|
return undefined;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The receipt tree is written by the development identity and read by the controller. A directory
|
|
38
|
+
* the other identity created cannot be re-shaped here; its owner repairs it on the next write and
|
|
39
|
+
* readers keep working from whatever already carries the shared bits.
|
|
40
|
+
*/
|
|
41
|
+
async function shareArtifactDirectory(root, directory) {
|
|
42
|
+
try {
|
|
43
|
+
await shareWorkspaceAssetDirectory(root, directory);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const code = error.code;
|
|
47
|
+
if (code !== 'EACCES' && code !== 'EPERM')
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
35
51
|
/** Preserve final outputs before another command overwrites them; intermediate project assets are not registered. */
|
|
36
|
-
export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd = process.cwd()) {
|
|
52
|
+
export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd = process.cwd(), expectedDigest, origin) {
|
|
37
53
|
const context = await generatedWorkspace(cwd);
|
|
38
54
|
if (!context)
|
|
39
55
|
return;
|
|
@@ -49,17 +65,24 @@ export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd
|
|
|
49
65
|
return;
|
|
50
66
|
const receipts = await artifactDirectory(context.root, ARTIFACT_RECEIPTS_DIRECTORY);
|
|
51
67
|
const objects = await artifactDirectory(context.root, `${ARTIFACT_RECEIPTS_DIRECTORY}/objects`);
|
|
68
|
+
// Generations are written as the development identity while the controller scans this tree.
|
|
69
|
+
await shareArtifactDirectory(context.root, receipts);
|
|
70
|
+
await shareArtifactDirectory(context.root, objects);
|
|
52
71
|
const thumbnailPath = metadata.thumbnailPath ? relativeArtifactPath(context.root, resolve(cwd, metadata.thumbnailPath)) : undefined;
|
|
53
72
|
const files = await snapshotArtifactBundle(context.root, path, objects, thumbnailPath);
|
|
73
|
+
if (expectedDigest && files.find(file => file.role === 'primary')?.sha256 !== expectedDigest)
|
|
74
|
+
throw new Error('artifact_file_changed');
|
|
54
75
|
const receipt = artifactReceiptSchema.parse({
|
|
55
76
|
id: randomUUID(), workspaceId: context.workspaceId, path,
|
|
56
|
-
source: { kind: 'generated', tool, generatedAt: new Date().toISOString()
|
|
77
|
+
source: { kind: 'generated', tool, generatedAt: new Date().toISOString(),
|
|
78
|
+
...(origin?.sessionId ? { sessionId: origin.sessionId } : {}), ...(origin?.operationId ? { operationId: origin.operationId } : {}) }, files,
|
|
57
79
|
...(metadata.width ? { width: metadata.width } : {}), ...(metadata.height ? { height: metadata.height } : {}),
|
|
58
80
|
...(metadata.duration !== undefined ? { duration: metadata.duration } : {}),
|
|
59
81
|
});
|
|
60
82
|
const temporary = join(receipts, `${receipt.id}.tmp`);
|
|
61
|
-
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
83
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o660);
|
|
62
84
|
try {
|
|
85
|
+
await handle.chmod(0o660);
|
|
63
86
|
await handle.writeFile(`${JSON.stringify(receipt)}\n`);
|
|
64
87
|
await handle.sync();
|
|
65
88
|
await handle.close();
|