@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.
Files changed (25) hide show
  1. package/dist/commands/build.js +38 -0
  2. package/dist/workspace-runtime/previews/finalize.js +64 -0
  3. package/dist/workspace-runtime/previews/publish.js +31 -8
  4. package/dist/workspace-runtime/server/http.js +5 -5
  5. package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
  6. package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
  7. package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
  8. package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
  9. package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
  10. package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
  11. package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
  12. package/dist/workspace-runtime/workspaces/git.js +13 -0
  13. package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
  14. package/dist/workspace-runtime/workspaces/parameters.js +61 -21
  15. package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
  16. package/package.json +3 -3
  17. package/project-templates/empty/AGENTS.md +1 -1
  18. package/templates/preview-runtime/v1/README.md +8 -0
  19. package/templates/preview-runtime/v1/manifest.json +7 -7
  20. package/templates/preview-runtime/v1/runtime.d.mts +1 -1
  21. package/templates/preview-runtime/v1/runtime.d.ts +1 -1
  22. package/templates/preview-runtime/v1/runtime.mjs +238 -47
  23. package/templates/preview-runtime/v1/types/adapters.d.ts +3 -1
  24. package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
  25. package/templates/preview-runtime/v1/types/protocol.d.ts +37 -0
@@ -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 { saveWorkspacePlayableOutput } from './output.js';
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
- await buildPreview(state, root, outputDir, operation.operationId, output, appendLog, runtimePreview?.command);
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 PreviewFailure || error instanceof RuntimePreviewBuildError ? error.code : 'preview_build_failed';
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
- export async function refreshWorkspaceArtifacts(state, workspaceId, origin) {
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 result = await new WorkspaceArtifactStore(state.config.stateDir, workspaceId).discover(root, origin?.source, origin?.paths);
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 pending = new Map();
27
- const running = new Set();
28
- const reconcile = (workspaceId) => {
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 workspaceId = typeof payload.workspaceId === 'string' ? payload.workspaceId : sessionId ? state.journal.getSession(sessionId)?.workspaceId : undefined;
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 timer = pending.get(workspaceId);
42
- if (timer)
43
- clearTimeout(timer);
44
- pending.set(workspaceId, setTimeout(() => { pending.delete(workspaceId); reconcile(workspaceId); }, 200));
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
- for (const [workspaceId, timer] of pending) {
49
- clearTimeout(timer);
50
- reconcile(workspaceId);
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
- output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
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() }, files,
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, 0o600);
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();