@playcraft/cli 0.1.31 → 0.1.33

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 (27) hide show
  1. package/README.md +18 -0
  2. package/dist/commands/workspace.js +35 -0
  3. package/dist/index.js +2 -0
  4. package/dist/workspace/messages.js +35 -0
  5. package/dist/workspace/transfer-client.js +174 -0
  6. package/dist/workspace-runtime/previews/output.js +36 -0
  7. package/dist/workspace-runtime/previews/publish.js +4 -1
  8. package/dist/workspace-runtime/production/storage.js +36 -8
  9. package/dist/workspace-runtime/server/dispatch.js +65 -7
  10. package/dist/workspace-runtime/server/http.js +91 -2
  11. package/dist/workspace-runtime/server/workspace-transfer.js +99 -0
  12. package/dist/workspace-runtime/workspaces/archive.js +207 -0
  13. package/dist/workspace-runtime/workspaces/artifact-files.js +4 -2
  14. package/dist/workspace-runtime/workspaces/artifact-receipts.js +3 -3
  15. package/dist/workspace-runtime/workspaces/artifacts.js +38 -16
  16. package/dist/workspace-runtime/workspaces/attachment-content.js +16 -4
  17. package/dist/workspace-runtime/workspaces/attachments.js +7 -1
  18. package/dist/workspace-runtime/workspaces/directories.js +8 -1
  19. package/dist/workspace-runtime/workspaces/directory-attachments.js +245 -0
  20. package/dist/workspace-runtime/workspaces/git.js +148 -7
  21. package/dist/workspace-runtime/workspaces/instructions.js +56 -0
  22. package/dist/workspace-runtime/workspaces/library-attachments.js +268 -0
  23. package/dist/workspace-runtime/workspaces/registry.js +2 -0
  24. package/dist/workspace-runtime/workspaces/transfer.js +288 -0
  25. package/dist/workspace-runtime/workspaces/turn-changes.js +72 -0
  26. package/package.json +3 -3
  27. package/project-templates/empty/AGENTS.md +14 -0
@@ -13,6 +13,8 @@ import { desktopHost, desktopRoute } from './desktop-host.js';
13
13
  import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
14
14
  import { readAttachmentUpload, uploadWorkspaceAttachment, uploadWorkspaceVideoAttachment } from '../workspaces/attachments.js';
15
15
  import { serveWorkspaceAttachment } from '../workspaces/attachment-content.js';
16
+ import { importWorkspaceLibraryAttachment, listWorkspaceLibraryAttachments, listWorkspaceLibraryImportReceipts, readLibraryAttachmentUpload } from '../workspaces/library-attachments.js';
17
+ import { beginWorkspaceDirectoryAttachment, uploadWorkspaceDirectoryFile, completeWorkspaceDirectoryAttachment, cancelWorkspaceDirectoryAttachment } from '../workspaces/directory-attachments.js';
16
18
  import { readWorkspaceSkills } from '../workspaces/skills.js';
17
19
  import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
18
20
  import { join } from 'node:path';
@@ -26,6 +28,8 @@ import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
26
28
  import { WorkspaceArtifactStore } from '../workspaces/artifacts.js';
27
29
  import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-workspace/runtime';
28
30
  import { searchWorkspaceFiles } from '../workspaces/files-search.js';
31
+ import { serveWorkspaceArchive } from '../workspaces/archive.js';
32
+ import { handleWorkspaceTransfer } from './workspace-transfer.js';
29
33
  export async function handleRuntimeRequest(state, request, response) {
30
34
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
31
35
  const directoryName = url.searchParams.get('directoryName') ?? 'basic';
@@ -167,10 +171,82 @@ export async function handleRuntimeRequest(state, request, response) {
167
171
  await handleSourceEvents(state, request, response, url);
168
172
  return;
169
173
  }
174
+ if (url.pathname === '/runtime/attachments/library' && ['GET', 'POST'].includes(request.method ?? '')) {
175
+ if (writeDestroyed(state, response))
176
+ return;
177
+ const workspaceId = url.searchParams.get('workspaceId') ?? '';
178
+ const root = await workspaceRoot(state, workspaceId, directoryName);
179
+ if (request.method === 'GET') {
180
+ const sessionId = url.searchParams.get('sessionId') ?? undefined;
181
+ writeJson(response, 200, { attachments: await listWorkspaceLibraryAttachments(root, sessionId),
182
+ importReceipts: await listWorkspaceLibraryImportReceipts(root, sessionId) });
183
+ }
184
+ else {
185
+ if (await productionDirectoryBusy(state, workspaceId, directoryName)) {
186
+ writeJson(response, 409, { error: 'batch_workspace_readonly' });
187
+ return;
188
+ }
189
+ try {
190
+ const attachment = await importWorkspaceLibraryAttachment(root, await readLibraryAttachmentUpload(request));
191
+ await state.journal.allocateSourceEvent({
192
+ runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
193
+ workspaceId, type: 'attachments.imported', occurredAt: new Date().toISOString(),
194
+ payload: { workspaceId, directoryName, attachmentId: attachment.id },
195
+ });
196
+ writeJson(response, 200, attachment);
197
+ }
198
+ catch (error) {
199
+ const code = error instanceof Error && ['library_asset_too_large', 'library_asset_changed', 'library_attachment_limit'].includes(error.message)
200
+ ? error.message : 'invalid_library_attachment';
201
+ writeJson(response, 400, { error: code });
202
+ }
203
+ }
204
+ return;
205
+ }
206
+ const directoryUpload = /^\/runtime\/attachments\/directory(?:\/([^/]+)(?:\/(file|complete))?)?$/.exec(url.pathname);
207
+ if (directoryUpload && ['POST', 'PUT', 'DELETE'].includes(request.method ?? '')) {
208
+ if (writeDestroyed(state, response))
209
+ return;
210
+ const workspaceId = url.searchParams.get('workspaceId') ?? '';
211
+ if (await productionDirectoryBusy(state, workspaceId, directoryName)) {
212
+ writeJson(response, 409, { error: 'batch_workspace_readonly' });
213
+ return;
214
+ }
215
+ const root = await workspaceRoot(state, workspaceId, directoryName);
216
+ const [, uploadId, action] = directoryUpload;
217
+ try {
218
+ if (!uploadId && request.method === 'POST')
219
+ writeJson(response, 200, await beginWorkspaceDirectoryAttachment(root, await readAttachmentUpload(request)));
220
+ else if (uploadId && action === 'file' && request.method === 'PUT')
221
+ writeJson(response, 200, await uploadWorkspaceDirectoryFile(root, uploadId, url.searchParams.get('path') ?? '', request));
222
+ else if (uploadId && action === 'complete' && request.method === 'POST') {
223
+ const attachment = await completeWorkspaceDirectoryAttachment(root, uploadId);
224
+ await state.journal.allocateSourceEvent({
225
+ runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
226
+ workspaceId, type: 'attachments.imported', occurredAt: new Date().toISOString(),
227
+ payload: { workspaceId, directoryName, attachmentId: attachment.id },
228
+ });
229
+ writeJson(response, 200, attachment);
230
+ }
231
+ else if (uploadId && !action && request.method === 'DELETE')
232
+ writeJson(response, 200, await cancelWorkspaceDirectoryAttachment(root, uploadId));
233
+ else
234
+ writeJson(response, 400, { error: 'invalid_attachment' });
235
+ }
236
+ catch (error) {
237
+ writeJson(response, 400, { error: error instanceof Error && error.message === 'video_probe_unavailable' ? 'video_probe_unavailable' : 'invalid_attachment' });
238
+ }
239
+ return;
240
+ }
170
241
  if (request.method === 'POST' && url.pathname === '/runtime/attachments/video') {
171
242
  if (writeDestroyed(state, response))
172
243
  return;
173
- const root = await workspaceRoot(state, url.searchParams.get('workspaceId') ?? '');
244
+ const workspaceId = url.searchParams.get('workspaceId') ?? '';
245
+ if (await productionDirectoryBusy(state, workspaceId, directoryName)) {
246
+ writeJson(response, 409, { error: 'batch_workspace_readonly' });
247
+ return;
248
+ }
249
+ const root = await workspaceRoot(state, workspaceId, directoryName);
174
250
  try {
175
251
  writeJson(response, 200, await uploadWorkspaceVideoAttachment(root, request, { name: url.searchParams.get('name') }));
176
252
  }
@@ -200,7 +276,7 @@ export async function handleRuntimeRequest(state, request, response) {
200
276
  if (writeDestroyed(state, response))
201
277
  return;
202
278
  const root = await workspaceRoot(state, url.searchParams.get('workspaceId') ?? '', directoryName);
203
- await serveWorkspaceAttachment(root, url.searchParams.get('attachmentId') ?? '', request, response);
279
+ await serveWorkspaceAttachment(root, url.searchParams.get('attachmentId') ?? '', request, response, url.searchParams.get('filePath') ?? undefined);
204
280
  return;
205
281
  }
206
282
  if (request.method === 'GET' && url.pathname === '/runtime/artifacts') {
@@ -245,6 +321,19 @@ export async function handleRuntimeRequest(state, request, response) {
245
321
  }
246
322
  return;
247
323
  }
324
+ if (['GET', 'POST'].includes(request.method ?? '') && url.pathname === '/runtime/git-bundle') {
325
+ if (writeDestroyed(state, response))
326
+ return;
327
+ await handleWorkspaceTransfer(state, request, response, url);
328
+ return;
329
+ }
330
+ if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/archive') {
331
+ if (writeDestroyed(state, response))
332
+ return;
333
+ const root = await sessionWorkspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined, directoryName);
334
+ await serveWorkspaceArchive(root, request, response, { excludedRoots: [state.config.stateDir] });
335
+ return;
336
+ }
248
337
  if (request.method === 'GET' && url.pathname === '/runtime/files') {
249
338
  if (writeDestroyed(state, response))
250
339
  return;
@@ -0,0 +1,99 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { applyWorkspaceTransfer, createWorkspaceTransfer, WORKSPACE_TRANSFER_MAX_BYTES, WorkspaceTransferError } from '../workspaces/transfer.js';
5
+ import { revisionScope, sessionWorkspaceRoot } from '../workspaces/registry.js';
6
+ import { workspaceDirectoryOccupied } from '../workspaces/directory-maintenance.js';
7
+ import { publishSourceEvents } from './dispatch.js';
8
+ export async function handleWorkspaceTransfer(state, request, response, url) {
9
+ const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
10
+ const directoryName = url.searchParams.get('directoryName') ?? 'basic';
11
+ const root = await sessionWorkspaceRoot(state, workspaceId, directoryName);
12
+ const controller = new AbortController();
13
+ const abort = () => controller.abort();
14
+ const close = () => { if (!response.writableEnded)
15
+ abort(); };
16
+ request.once('aborted', abort);
17
+ response.once('close', close);
18
+ try {
19
+ if (request.method === 'GET') {
20
+ const snapshot = await createWorkspaceTransfer(root);
21
+ try {
22
+ controller.signal.throwIfAborted();
23
+ response.writeHead(200, { 'content-type': 'application/octet-stream', 'cache-control': 'private, no-store',
24
+ 'content-length': (await stat(snapshot.bundlePath)).size, 'x-content-type-options': 'nosniff',
25
+ 'x-playcraft-workspace-tree': snapshot.tree, 'x-playcraft-workspace-head': snapshot.head });
26
+ await pipeline(createReadStream(snapshot.bundlePath), response, { signal: controller.signal });
27
+ }
28
+ finally {
29
+ await snapshot.cleanup();
30
+ }
31
+ return;
32
+ }
33
+ const expectedTree = request.headers['x-playcraft-workspace-tree'];
34
+ if (typeof expectedTree !== 'string' || !/^[a-f0-9]{40,64}$/.test(expectedTree))
35
+ throw new WorkspaceTransferError('invalid_workspace_transfer');
36
+ if (Number(request.headers['content-length'] ?? 0) > WORKSPACE_TRANSFER_MAX_BYTES)
37
+ throw new WorkspaceTransferError('workspace_transfer_too_large');
38
+ const chunks = [];
39
+ let size = 0;
40
+ for await (const chunk of request) {
41
+ size += chunk.length;
42
+ if (size > WORKSPACE_TRANSFER_MAX_BYTES)
43
+ throw new WorkspaceTransferError('workspace_transfer_too_large');
44
+ chunks.push(Buffer.from(chunk));
45
+ }
46
+ const directories = directoryName === 'root'
47
+ ? [...new Set(['root', 'basic', ...(state.journal.getWorkspace(workspaceId ?? '')?.directories ?? []).map(item => item.directoryName)])].sort()
48
+ : [...new Set(['root', directoryName])].sort();
49
+ const lockKeys = [...new Set([...directories.map(name => `directory:${workspaceId}:${name}`),
50
+ ...directories.map(name => `workspace:${revisionScope(state, workspaceId, name) ?? 'single'}`)])];
51
+ const locked = async (index) => index < lockKeys.length
52
+ ? state.journal.exclusive(lockKeys[index], () => locked(index + 1))
53
+ : apply();
54
+ const apply = async () => {
55
+ controller.signal.throwIfAborted();
56
+ if (state.journal.destroyed())
57
+ throw new WorkspaceTransferError('runtime_destroyed');
58
+ for (const directory of directories) {
59
+ if (await workspaceDirectoryOccupied(state, workspaceId ?? 'workspace', directory))
60
+ throw new WorkspaceTransferError('workspace_transfer_busy');
61
+ }
62
+ return applyWorkspaceTransfer(root, Buffer.concat(chunks, size), expectedTree);
63
+ };
64
+ const result = await locked(0);
65
+ if (result.changedPaths.length > 0) {
66
+ const groups = new Map([[directoryName, result.changedPaths]]);
67
+ if (directoryName === 'root')
68
+ for (const path of result.changedPaths) {
69
+ const [directory, ...parts] = path.split('/');
70
+ if (directory && directories.includes(directory) && parts.length)
71
+ groups.set(directory, [...(groups.get(directory) ?? []), parts.join('/')]);
72
+ }
73
+ const events = [];
74
+ for (const [directory, paths] of groups)
75
+ events.push(await state.journal.allocateSourceEvent({
76
+ runtimeId: state.config.runtimeId, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
77
+ workspaceId, type: 'files.changed', occurredAt: new Date().toISOString(), payload: { workspaceId, directoryName: directory, paths },
78
+ }));
79
+ await publishSourceEvents(state, events);
80
+ }
81
+ writeJson(response, 200, result);
82
+ }
83
+ catch (error) {
84
+ if (response.headersSent)
85
+ response.destroy(error instanceof Error ? error : undefined);
86
+ else if (!controller.signal.aborted) {
87
+ const code = error instanceof WorkspaceTransferError ? error.code : 'workspace_transfer_failed';
88
+ writeJson(response, code === 'workspace_transfer_too_large' ? 413 : code === 'invalid_workspace_transfer' ? 400 : 409, { error: code });
89
+ }
90
+ }
91
+ finally {
92
+ request.off('aborted', abort);
93
+ response.off('close', close);
94
+ }
95
+ }
96
+ function writeJson(response, status, body) {
97
+ response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'private, no-store' });
98
+ response.end(JSON.stringify(body));
99
+ }
@@ -0,0 +1,207 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, opendir, realpath } from 'node:fs/promises';
3
+ import { join, relative, sep } from 'node:path';
4
+ import { Readable, Transform } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { createDeflateRaw } from 'node:zlib';
7
+ import { isCredentialFile } from './git.js';
8
+ const EXCLUDED_NAMES = new Set(['.git', 'node_modules', '.playcraft.json', '.playcraft-workspace.json']);
9
+ const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024;
10
+ const MAX_ENTRIES = 50_000;
11
+ const UTF8_DESCRIPTOR_FLAGS = 0x0808;
12
+ const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
13
+ let crc = value;
14
+ for (let bit = 0; bit < 8; bit += 1)
15
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
16
+ return crc >>> 0;
17
+ });
18
+ /** Exports working files, including ignored deliverables, without following symbolic links. */
19
+ export async function createWorkspaceArchive(root, options = {}) {
20
+ const rootReal = await realpath(root);
21
+ const excludedRoots = await Promise.all((options.excludedRoots ?? []).map(path => realpath(path).catch(() => path)));
22
+ const entries = [];
23
+ let bytes = 0;
24
+ const walk = async (directory) => {
25
+ options.signal?.throwIfAborted();
26
+ const info = await lstat(directory);
27
+ if (!info.isDirectory() || info.isSymbolicLink() || !inside(rootReal, await realpath(directory)))
28
+ throw new Error('workspace_archive_changed');
29
+ for await (const entry of await opendir(directory)) {
30
+ options.signal?.throwIfAborted();
31
+ if (EXCLUDED_NAMES.has(entry.name) || entry.name.startsWith('.playcraft-credential-'))
32
+ continue;
33
+ const target = join(directory, entry.name);
34
+ const path = relative(rootReal, target).split(sep).join('/');
35
+ if (isCredentialFile(path) || /(?:^|\/)\.playcraft\/(?:workspace-context(?:\.[^/]*)?|video-replication)(?:\/|$)/.test(path))
36
+ continue;
37
+ if (excludedRoots.some(excluded => inside(excluded, target)))
38
+ continue;
39
+ const info = await lstat(target);
40
+ if (info.isSymbolicLink())
41
+ continue;
42
+ if (!inside(rootReal, await realpath(target)))
43
+ throw new Error('workspace_archive_changed');
44
+ if (info.isDirectory()) {
45
+ await walk(target);
46
+ continue;
47
+ }
48
+ if (!info.isFile())
49
+ continue;
50
+ if (Buffer.byteLength(path) > 65_535 || /[\0\\]/.test(path))
51
+ throw new Error('workspace_archive_invalid_path');
52
+ bytes += info.size;
53
+ if (bytes > (options.maxSourceBytes ?? MAX_SOURCE_BYTES) || entries.length >= (options.maxEntries ?? MAX_ENTRIES))
54
+ throw new Error('workspace_archive_too_large');
55
+ entries.push({ path, target, info });
56
+ }
57
+ };
58
+ await walk(rootReal);
59
+ entries.sort((left, right) => left.path.localeCompare(right.path));
60
+ return Readable.from(zipFiles(rootReal, entries, options.signal));
61
+ }
62
+ /** Standard ZIP (ZIP archive format, 压缩归档格式) records are streamed with bounded buffers. */
63
+ async function* zipFiles(root, entries, signal) {
64
+ const central = [];
65
+ let offset = 0;
66
+ for (const entry of entries) {
67
+ signal?.throwIfAborted();
68
+ if (!inside(root, await realpath(entry.target)))
69
+ throw new Error('workspace_archive_changed');
70
+ const handle = await open(entry.target, constants.O_RDONLY | constants.O_NOFOLLOW);
71
+ try {
72
+ const info = await handle.stat();
73
+ if (!info.isFile() || info.dev !== entry.info.dev || info.ino !== entry.info.ino || info.size !== entry.info.size
74
+ || info.mtimeMs !== entry.info.mtimeMs || info.ctimeMs !== entry.info.ctimeMs)
75
+ throw new Error('workspace_archive_changed');
76
+ const name = Buffer.from(entry.path);
77
+ const stamp = zipTimestamp(info.mtime);
78
+ const header = Buffer.alloc(30);
79
+ header.writeUInt32LE(0x04034b50, 0);
80
+ header.writeUInt16LE(20, 4);
81
+ header.writeUInt16LE(UTF8_DESCRIPTOR_FLAGS, 6);
82
+ header.writeUInt16LE(8, 8);
83
+ header.writeUInt32LE(stamp, 10);
84
+ header.writeUInt16LE(name.length, 26);
85
+ const start = offset;
86
+ yield header;
87
+ yield name;
88
+ offset += header.length + name.length;
89
+ let crc = 0xffffffff;
90
+ let size = 0;
91
+ let compressedSize = 0;
92
+ const checksum = new Transform({ transform(chunk, _encoding, done) {
93
+ size += chunk.length;
94
+ if (size > info.size) {
95
+ done(new Error('workspace_archive_changed'));
96
+ return;
97
+ }
98
+ for (const byte of chunk)
99
+ crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
100
+ done(null, chunk);
101
+ } });
102
+ const compressed = createDeflateRaw();
103
+ const completion = pipeline(handle.createReadStream({ autoClose: false }), checksum, compressed, { signal });
104
+ void completion.catch(() => undefined);
105
+ try {
106
+ for await (const chunk of compressed) {
107
+ signal?.throwIfAborted();
108
+ compressedSize += chunk.length;
109
+ offset += chunk.length;
110
+ yield chunk;
111
+ }
112
+ await completion;
113
+ }
114
+ finally {
115
+ compressed.destroy();
116
+ checksum.destroy();
117
+ await completion.catch(() => undefined);
118
+ }
119
+ if (size !== info.size)
120
+ throw new Error('workspace_archive_changed');
121
+ const after = await handle.stat();
122
+ if (after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs)
123
+ throw new Error('workspace_archive_changed');
124
+ crc = (crc ^ 0xffffffff) >>> 0;
125
+ const descriptor = Buffer.alloc(16);
126
+ descriptor.writeUInt32LE(0x08074b50, 0);
127
+ descriptor.writeUInt32LE(crc, 4);
128
+ descriptor.writeUInt32LE(compressedSize, 8);
129
+ descriptor.writeUInt32LE(size, 12);
130
+ yield descriptor;
131
+ offset += descriptor.length;
132
+ const record = Buffer.alloc(46);
133
+ record.writeUInt32LE(0x02014b50, 0);
134
+ record.writeUInt16LE(0x0314, 4);
135
+ record.writeUInt16LE(20, 6);
136
+ record.writeUInt16LE(UTF8_DESCRIPTOR_FLAGS, 8);
137
+ record.writeUInt16LE(8, 10);
138
+ record.writeUInt32LE(stamp, 12);
139
+ record.writeUInt32LE(crc, 16);
140
+ record.writeUInt32LE(compressedSize, 20);
141
+ record.writeUInt32LE(size, 24);
142
+ record.writeUInt16LE(name.length, 28);
143
+ record.writeUInt32LE(((info.mode & 0xffff) << 16) >>> 0, 38);
144
+ record.writeUInt32LE(start, 42);
145
+ central.push(record, name);
146
+ }
147
+ finally {
148
+ await handle.close();
149
+ }
150
+ }
151
+ const centralStart = offset;
152
+ for (const record of central) {
153
+ signal?.throwIfAborted();
154
+ yield record;
155
+ offset += record.length;
156
+ }
157
+ const end = Buffer.alloc(22);
158
+ end.writeUInt32LE(0x06054b50, 0);
159
+ end.writeUInt16LE(entries.length, 8);
160
+ end.writeUInt16LE(entries.length, 10);
161
+ end.writeUInt32LE(offset - centralStart, 12);
162
+ end.writeUInt32LE(centralStart, 16);
163
+ yield end;
164
+ }
165
+ export async function serveWorkspaceArchive(root, request, response, options = {}) {
166
+ const controller = new AbortController();
167
+ const abort = () => controller.abort();
168
+ const close = () => { if (!response.writableEnded)
169
+ abort(); };
170
+ request.once('aborted', abort);
171
+ response.once('close', close);
172
+ try {
173
+ const archive = await createWorkspaceArchive(root, { ...options, signal: controller.signal });
174
+ response.writeHead(200, {
175
+ 'content-type': 'application/zip', 'content-disposition': 'attachment; filename="workspace.zip"',
176
+ 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff',
177
+ });
178
+ if (request.method === 'HEAD') {
179
+ archive.destroy();
180
+ response.end();
181
+ return;
182
+ }
183
+ await pipeline(archive, response, { signal: controller.signal });
184
+ }
185
+ catch (error) {
186
+ if (response.headersSent)
187
+ response.destroy(error instanceof Error ? error : undefined);
188
+ else if (!controller.signal.aborted) {
189
+ const reason = error instanceof Error ? error.message : 'workspace_archive_failed';
190
+ response.writeHead(reason === 'workspace_archive_too_large' ? 413 : 409, { 'content-type': 'application/json', 'cache-control': 'private, no-store' });
191
+ response.end(JSON.stringify({ error: reason.startsWith('workspace_archive_') ? reason : 'workspace_archive_failed' }));
192
+ }
193
+ }
194
+ finally {
195
+ request.off('aborted', abort);
196
+ response.off('close', close);
197
+ }
198
+ }
199
+ function inside(root, target) {
200
+ const path = relative(root, target);
201
+ return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !path.startsWith(sep));
202
+ }
203
+ function zipTimestamp(date) {
204
+ const year = Math.min(2107, Math.max(1980, date.getUTCFullYear()));
205
+ return (((year - 1980) << 25) | ((date.getUTCMonth() + 1) << 21) | (date.getUTCDate() << 16)
206
+ | (date.getUTCHours() << 11) | (date.getUTCMinutes() << 5) | (date.getUTCSeconds() >>> 1)) >>> 0;
207
+ }
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { lstat, mkdir, open, readFile, realpath, link, unlink } from 'node:fs/promises';
4
4
  import { extname, join, posix, resolve, sep } from 'node:path';
5
- import { WORKSPACE_ARTIFACT_MAX_BYTES, workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { WORKSPACE_ARTIFACT_MAX_BYTES, isWorkspaceArtifactOutputPath, workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
6
6
  const formats = {
7
7
  '.png': ['image', 'image/png'], '.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'],
8
8
  '.webp': ['image', 'image/webp'], '.gif': ['image', 'image/gif'], '.avif': ['image', 'image/avif'], '.svg': ['image', 'image/svg+xml'],
@@ -105,7 +105,7 @@ function resourcePath(primary, uri) {
105
105
  throw new Error('invalid_artifact_resource');
106
106
  const decoded = decodeURIComponent(uri);
107
107
  const path = posix.normalize(posix.join(posix.dirname(primary), decoded));
108
- if (!workspaceRelativePathSchema.safeParse(path).success || path.split('/').some(part => part.startsWith('.')))
108
+ if (!isWorkspaceArtifactOutputPath(path) || path.split('/').some(part => part.startsWith('.')))
109
109
  throw new Error('invalid_artifact_resource');
110
110
  return path;
111
111
  }
@@ -173,6 +173,8 @@ export async function artifactResources(root, path) {
173
173
  return [...resources].sort();
174
174
  }
175
175
  export async function snapshotArtifactBundle(root, path, objects, thumbnailPath) {
176
+ if (!isWorkspaceArtifactOutputPath(path) || (thumbnailPath !== undefined && !isWorkspaceArtifactOutputPath(thumbnailPath)))
177
+ throw new Error('invalid_artifact_path');
176
178
  const resources = await artifactResources(root, path);
177
179
  const files = [await snapshotArtifactFile(root, path, objects)];
178
180
  for (const resource of resources)
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { lstat, open, rename, unlink } from 'node:fs/promises';
4
4
  import { dirname, join, resolve } from 'node:path';
5
- import { workspaceArtifactReceiptSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { isWorkspaceArtifactOutputPath, workspaceArtifactReceiptSchema } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { artifactDirectory, artifactFormat, relativeArtifactPath, snapshotArtifactBundle } from './artifact-files.js';
7
7
  import { excludeFromGit } from './dependencies.js';
8
8
  export const ARTIFACT_RECEIPTS_DIRECTORY = '.playcraft/artifact-receipts';
@@ -32,7 +32,7 @@ async function generatedWorkspace(cwd) {
32
32
  return undefined;
33
33
  }
34
34
  }
35
- /** Generation finishes only after its bytes have been preserved, even if the next command overwrites the output. */
35
+ /** Preserve final outputs before another command overwrites them; intermediate project assets are not registered. */
36
36
  export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd = process.cwd()) {
37
37
  const context = await generatedWorkspace(cwd);
38
38
  if (!context)
@@ -45,7 +45,7 @@ export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd
45
45
  catch {
46
46
  return;
47
47
  }
48
- if (!artifactFormat(path))
48
+ if (!isWorkspaceArtifactOutputPath(path) || !artifactFormat(path))
49
49
  return;
50
50
  const receipts = await artifactDirectory(context.root, ARTIFACT_RECEIPTS_DIRECTORY);
51
51
  const objects = await artifactDirectory(context.root, `${ARTIFACT_RECEIPTS_DIRECTORY}/objects`);
@@ -3,13 +3,22 @@ import { constants } from 'node:fs';
3
3
  import { lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises';
4
4
  import { basename, join } from 'node:path';
5
5
  import sharp from 'sharp';
6
- import { workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
8
8
  import { ARTIFACT_RECEIPTS_DIRECTORY, readArtifactReceipt } from './artifact-receipts.js';
9
- const skippedDirectories = new Set(['node_modules', 'dist', 'build', 'coverage', 'vendor', 'tmp', 'temp', 'cache']);
10
9
  const serial = new Map();
11
10
  function digest(text) { return createHash('sha256').update(text).digest('hex'); }
12
11
  function artifactId(workspaceId, path) { return `artifact_${digest(`${workspaceId}\0${path}`).slice(0, 40)}`; }
12
+ function outputArtifacts(artifacts) {
13
+ return artifacts.flatMap(artifact => {
14
+ const versions = artifact.versions.filter(isWorkspaceArtifactOutputVersion);
15
+ if (!versions.length)
16
+ return [];
17
+ const current = versions.find(version => version.id === artifact.currentVersionId)
18
+ ?? versions.reduce((latest, version) => version.createdAt > latest.createdAt ? version : latest);
19
+ return [{ ...artifact, versions, currentVersionId: current.id, updatedAt: current.createdAt }];
20
+ }).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
21
+ }
13
22
  /** Catalogs and immutable bytes are control-owned, outside the mutable checkout. */
14
23
  export class WorkspaceArtifactStore {
15
24
  stateDir;
@@ -73,13 +82,13 @@ export class WorkspaceArtifactStore {
73
82
  }
74
83
  async list() {
75
84
  const catalog = await this.read(await this.directory());
76
- return workspaceArtifactListSchema.parse({ artifacts: catalog.artifacts, scannedAt: catalog.scannedAt });
85
+ return workspaceArtifactListSchema.parse({ artifacts: outputArtifacts(catalog.artifacts), scannedAt: catalog.scannedAt });
77
86
  }
78
87
  async scan(root, source, sourcePaths) {
79
88
  const directory = await this.directory();
80
89
  const objects = await artifactDirectory(directory, 'objects');
81
90
  const catalog = await this.read(directory);
82
- const original = JSON.stringify(catalog.artifacts);
91
+ const original = JSON.stringify(outputArtifacts(catalog.artifacts));
83
92
  const receipts = await this.receipts(root, catalog);
84
93
  for (const receipt of receipts) {
85
94
  try {
@@ -106,12 +115,13 @@ export class WorkspaceArtifactStore {
106
115
  const candidates = await discoverPaths(root);
107
116
  // Models are inspected before images so their explicitly referenced textures stay under the model.
108
117
  candidates.sort((a, b) => Number(artifactFormat(b)?.[0] === 'model') - Number(artifactFormat(a)?.[0] === 'model') || a.localeCompare(b));
109
- const members = new Set(catalog.artifacts.flatMap(artifact => artifact.versions.flatMap(version => version.files.filter(file => file.role !== 'primary').map(file => file.path))));
118
+ const visible = outputArtifacts(catalog.artifacts);
119
+ const members = new Set(visible.flatMap(artifact => artifact.versions.flatMap(version => version.files.filter(file => file.role !== 'primary').map(file => file.path))));
110
120
  for (const path of candidates) {
111
121
  if (members.has(path))
112
122
  continue;
113
123
  try {
114
- const current = catalog.artifacts.find(artifact => artifact.id === artifactId(this.workspaceId, path));
124
+ const current = visible.find(artifact => artifact.id === artifactId(this.workspaceId, path));
115
125
  const version = current?.versions.find(entry => entry.id === current.currentVersionId);
116
126
  const knownWrite = sourcePaths.includes(path) && Boolean(source.operationId) && version?.source.operationId !== source.operationId;
117
127
  const observation = catalog.observations[path];
@@ -145,13 +155,13 @@ export class WorkspaceArtifactStore {
145
155
  }
146
156
  catalog.artifacts = catalog.artifacts.filter(artifact => {
147
157
  const current = artifact.versions.find(version => version.id === artifact.currentVersionId);
148
- return current.source.kind !== 'discovered' || !members.has(current.path);
158
+ return !isWorkspaceArtifactOutputVersion(current) || current.source.kind !== 'discovered' || !members.has(current.path);
149
159
  });
150
160
  catalog.artifacts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
151
161
  catalog.scannedAt = new Date().toISOString();
152
- const list = workspaceArtifactListSchema.parse({ artifacts: catalog.artifacts, scannedAt: catalog.scannedAt });
162
+ const list = workspaceArtifactListSchema.parse({ artifacts: outputArtifacts(catalog.artifacts), scannedAt: catalog.scannedAt });
153
163
  await this.write(directory, catalog);
154
- return { list, changed: original !== JSON.stringify(catalog.artifacts) };
164
+ return { list, changed: original !== JSON.stringify(list.artifacts) };
155
165
  }
156
166
  add(catalog, version) {
157
167
  const id = artifactId(this.workspaceId, version.path);
@@ -186,7 +196,7 @@ export class WorkspaceArtifactStore {
186
196
  continue;
187
197
  try {
188
198
  const receipt = await readArtifactReceipt(join(directory, name));
189
- if (receipt.workspaceId === this.workspaceId && `${receipt.id}.json` === name)
199
+ if (receipt.workspaceId === this.workspaceId && `${receipt.id}.json` === name && isWorkspaceArtifactOutputVersion(receipt))
190
200
  receipts.push(receipt);
191
201
  }
192
202
  catch { /* Malformed output metadata is never trusted as a completed artifact. */ }
@@ -208,7 +218,7 @@ export class WorkspaceArtifactStore {
208
218
  const query = workspaceArtifactContentQuerySchema.parse(input);
209
219
  const directory = await this.directory(), catalog = await this.read(directory);
210
220
  const artifact = catalog.artifacts.find(item => item.id === query.artifactId);
211
- const version = artifact?.versions.find(item => item.id === query.versionId);
221
+ const version = artifact?.versions.find(item => item.id === query.versionId && isWorkspaceArtifactOutputVersion(item));
212
222
  const file = version?.files.find(item => item.path === (query.path ?? version.path));
213
223
  if (!file)
214
224
  throw new Error('artifact_not_found');
@@ -244,14 +254,26 @@ async function fileSignature(root, paths) {
244
254
  }
245
255
  return JSON.stringify(records);
246
256
  }
247
- async function discoverPaths(root, directory = '', result = []) {
248
- for (const entry of await readdir(join(root, directory), { withFileTypes: true })) {
257
+ async function discoverPaths(root, directory = WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, result = []) {
258
+ if (directory !== WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY && !isWorkspaceArtifactOutputPath(directory))
259
+ throw new Error('invalid_artifact_path');
260
+ let target;
261
+ try {
262
+ target = await artifactPath(root, directory);
263
+ }
264
+ catch (error) {
265
+ if (error.code === 'ENOENT')
266
+ return result;
267
+ throw error;
268
+ }
269
+ if (!(await lstat(target)).isDirectory())
270
+ throw new Error('invalid_artifact_directory');
271
+ for (const entry of await readdir(target, { withFileTypes: true })) {
249
272
  if (entry.name.startsWith('.') || entry.isSymbolicLink())
250
273
  continue;
251
- const path = directory ? `${directory}/${entry.name}` : entry.name;
274
+ const path = `${directory}/${entry.name}`;
252
275
  if (entry.isDirectory()) {
253
- if (!skippedDirectories.has(entry.name))
254
- await discoverPaths(root, path, result);
276
+ await discoverPaths(root, path, result);
255
277
  }
256
278
  else if (entry.isFile() && artifactFormat(path))
257
279
  result.push(path);