@playcraft/cli 0.1.55-beta → 0.1.57-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 (43) hide show
  1. package/dist/assets/client.js +6 -4
  2. package/dist/commands/build.js +38 -0
  3. package/dist/commands/tools-3d.js +15 -0
  4. package/dist/commands/tools-generation.js +3 -2
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace-runtime/batch/prepare.js +9 -52
  11. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  12. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  13. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  14. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  15. package/dist/workspace-runtime/previews/finalize.js +64 -0
  16. package/dist/workspace-runtime/previews/publish.js +31 -8
  17. package/dist/workspace-runtime/serve.js +8 -30
  18. package/dist/workspace-runtime/server/http.js +5 -5
  19. package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
  20. package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
  21. package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
  22. package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
  23. package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
  24. package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
  25. package/dist/workspace-runtime/workspaces/dependencies.js +24 -10
  26. package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
  27. package/dist/workspace-runtime/workspaces/git.js +13 -0
  28. package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
  29. package/dist/workspace-runtime/workspaces/parameters.js +61 -21
  30. package/dist/workspace-runtime/workspaces/platform-export.js +185 -0
  31. package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
  32. package/package.json +3 -3
  33. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  34. package/project-templates/empty/AGENTS.md +1 -1
  35. package/templates/preview-runtime/v1/README.md +12 -0
  36. package/templates/preview-runtime/v1/manifest.json +8 -8
  37. package/templates/preview-runtime/v1/runtime.d.mts +1 -1
  38. package/templates/preview-runtime/v1/runtime.d.ts +1 -1
  39. package/templates/preview-runtime/v1/runtime.mjs +591 -54
  40. package/templates/preview-runtime/v1/types/adapters.d.ts +6 -1
  41. package/templates/preview-runtime/v1/types/preview.d.ts +2 -0
  42. package/templates/preview-runtime/v1/types/protocol.d.ts +56 -0
  43. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -6,6 +6,7 @@ import { basename, dirname } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
8
  import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
9
+ import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
9
10
  const credentialSchema = z.object({
10
11
  token: z.string().min(1), expiresAt: z.string().datetime(),
11
12
  configId: z.string().uuid(), model: z.string().min(1).max(128),
@@ -25,8 +26,10 @@ export class WorkspaceModelBroker {
25
26
  pending = new Map();
26
27
  requests = new Set();
27
28
  sessionRequests = new Map();
29
+ generation;
28
30
  constructor(state) {
29
31
  this.state = state;
32
+ this.generation = new WorkspaceGenerationProvenanceBroker(state);
30
33
  }
31
34
  async start() {
32
35
  if (this.server)
@@ -67,8 +70,10 @@ export class WorkspaceModelBroker {
67
70
  }
68
71
  // Resolve current model metadata and permission at every native turn boundary.
69
72
  const credential = await this.credential(entry, true);
73
+ const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
70
74
  if (!route || route.epoch !== this.state.runtimeEpoch) {
71
- route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url') };
75
+ route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
76
+ generationCapability: randomBytes(32).toString('base64url') };
72
77
  this.routes.set(routeKey, route);
73
78
  }
74
79
  // Each session pins its prepared model; shared credential renewal cannot change an active turn.
@@ -77,12 +82,16 @@ export class WorkspaceModelBroker {
77
82
  if (!this.valid(entry) || this.routes.get(routeKey) !== route)
78
83
  throw new Error('model_access_denied');
79
84
  route.selected = { entry, model: credential.model,
80
- ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId } } : {}) };
85
+ ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId } } : {}) };
81
86
  },
82
87
  model: credential.model,
83
88
  modelConfigId: credential.configId,
84
89
  modelProvider: 'playcraft_workspace',
85
90
  config: {
91
+ ...(sessionId ? { 'shell_environment_policy.set': {
92
+ PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
93
+ PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
94
+ } } : {}),
86
95
  'model_providers.playcraft_workspace': {
87
96
  name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
88
97
  wire_api: 'responses', requires_openai_auth: false,
@@ -142,18 +151,28 @@ export class WorkspaceModelBroker {
142
151
  const cancel = () => abort.abort();
143
152
  response.once('close', cancel);
144
153
  try {
145
- const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/responses$/.exec(request.url ?? '');
154
+ const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance)$/.exec(request.url ?? '');
146
155
  const route = match ? this.routes.get(match[1]) : undefined;
147
156
  const selected = route?.selected;
148
157
  const entry = selected?.entry;
149
158
  const presented = Buffer.from(request.headers.authorization ?? '');
150
- const expected = Buffer.from(route ? `Bearer ${route.capability}` : '');
159
+ const generationRequest = match?.[2] === 'generation-provenance';
160
+ const expected = Buffer.from(route ? `Bearer ${generationRequest ? route.generationCapability : route.capability}` : '');
151
161
  if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
152
162
  || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
153
163
  response.writeHead(403);
154
164
  response.end();
155
165
  return;
156
166
  }
167
+ if (generationRequest) {
168
+ if (!selected?.feedback) {
169
+ response.writeHead(403);
170
+ response.end();
171
+ return;
172
+ }
173
+ await this.generation.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
174
+ return;
175
+ }
157
176
  activeRoute = route;
158
177
  const active = this.sessionRequests.get(route) ?? new Set();
159
178
  active.add(abort);
@@ -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) {
@@ -2,7 +2,6 @@ import { closeCliCredentials } from './workspaces/cli-credentials.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, readdir } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
- import { createPlatformAdapter } from '@playcraft/build';
6
5
  import { INCOMPATIBLE_EXIT_CODE, negotiateProtocol, transitionOperation, directoryMutationReceiptSchema, productionSessionAttemptId, } from '@playcraft/common/cloud-workspace/runtime';
7
6
  import { RuntimeJournal } from './persistence/journal.js';
8
7
  import { createCodexAppServerAdapter } from './codex/app-server.js';
@@ -32,7 +31,7 @@ import { revisionScope } from './workspaces/registry.js';
32
31
  import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
33
32
  import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
34
33
  import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
35
- import { submitTrustedOperation } from './server/submit-operation.js';
34
+ import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
36
35
  export async function startWorkspaceRuntime(config, options = {}) {
37
36
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
38
37
  const negotiated = negotiateProtocol(config);
@@ -355,6 +354,7 @@ function closeServer(server) {
355
354
  function formatHost(host) {
356
355
  return host.includes(':') ? `[${host}]` : host;
357
356
  }
357
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
358
358
  function deliveryToolHandlers(state) {
359
359
  const contextFor = async (input) => {
360
360
  if (input.workspaceId)
@@ -368,37 +368,15 @@ function deliveryToolHandlers(state) {
368
368
  const { workspaceId, directoryName } = await contextFor(input);
369
369
  if (directoryName === 'root')
370
370
  throw new Error('invalid_workspace_directory');
371
- const builds = [];
372
- for (const channel of input.channels) {
373
- const operation = await submitTrustedOperation(state, {
374
- kind: 'delivery.build',
375
- workspaceId,
376
- directoryName,
377
- ...(input.sessionId ? { sessionId: input.sessionId } : {}),
378
- clientRequestId: `${input.clientRequestId.slice(0, 80)}:${channel}`,
379
- payload: {
380
- channel,
381
- format: input.format ?? createPlatformAdapter({ platform: channel, format: 'html' }).getDefaultFormat(),
382
- actor: 'agent',
383
- },
384
- });
385
- let record;
386
- for (let attempt = 0; attempt < 40 && !record; attempt++) {
387
- record = await new DeliveryStore(state.config.stateDir, workspaceId).read(operation.operationId);
388
- if (!record)
389
- await new Promise(resolve => setTimeout(resolve, 25));
390
- }
391
- builds.push({
392
- buildId: operation.operationId,
393
- channel,
394
- status: record?.status ?? operation.status,
395
- ...(record?.queuePosition ? { queuePosition: record.queuePosition } : {}),
396
- });
397
- }
398
- return { builds };
371
+ // Exports go through the platform; a standalone workspace becomes a project first.
372
+ return startPlatformExport(state, { ...input, workspaceId, directoryName });
399
373
  },
400
374
  async get(input) {
401
375
  const { workspaceId, directoryName } = await contextFor(input);
376
+ const sandboxBuild = input.buildId !== undefined && UUID.test(input.buildId);
377
+ if (!sandboxBuild && await platformExportTarget(state, { workspaceId, directoryName })) {
378
+ return readPlatformExport(state, { ...input, workspaceId, directoryName });
379
+ }
402
380
  const store = new DeliveryStore(state.config.stateDir, workspaceId);
403
381
  const record = input.buildId
404
382
  ? await store.read(input.buildId)
@@ -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();