@playcraft/cli 0.1.30 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/commands/workspace.js +35 -0
- package/dist/index.js +2 -0
- package/dist/workspace/messages.js +35 -0
- package/dist/workspace/transfer-client.js +174 -0
- package/dist/workspace-runtime/previews/output.js +36 -0
- package/dist/workspace-runtime/previews/publish.js +4 -1
- package/dist/workspace-runtime/production/native.js +8 -1
- package/dist/workspace-runtime/production/stages.js +6 -3
- package/dist/workspace-runtime/production/storage.js +36 -8
- package/dist/workspace-runtime/server/dispatch.js +22 -5
- package/dist/workspace-runtime/server/http.js +91 -2
- package/dist/workspace-runtime/server/workspace-transfer.js +99 -0
- package/dist/workspace-runtime/workspaces/archive.js +207 -0
- package/dist/workspace-runtime/workspaces/artifact-files.js +4 -2
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +3 -3
- package/dist/workspace-runtime/workspaces/artifacts.js +38 -16
- package/dist/workspace-runtime/workspaces/attachment-content.js +16 -4
- package/dist/workspace-runtime/workspaces/attachments.js +7 -1
- package/dist/workspace-runtime/workspaces/directories.js +15 -5
- package/dist/workspace-runtime/workspaces/directory-access.js +12 -4
- package/dist/workspace-runtime/workspaces/directory-attachments.js +245 -0
- package/dist/workspace-runtime/workspaces/git.js +76 -7
- package/dist/workspace-runtime/workspaces/instructions.js +56 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +268 -0
- package/dist/workspace-runtime/workspaces/registry.js +11 -1
- package/dist/workspace-runtime/workspaces/transfer.js +288 -0
- package/dist/workspace-runtime/workspaces/turn-changes.js +72 -0
- package/package.json +3 -3
- 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
|
|
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 (!
|
|
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
|
-
/**
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
248
|
-
|
|
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 =
|
|
274
|
+
const path = `${directory}/${entry.name}`;
|
|
252
275
|
if (entry.isDirectory()) {
|
|
253
|
-
|
|
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);
|