@playcraft/cli 0.1.31 → 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/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 +8 -1
- 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 +2 -0
- 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
|
@@ -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);
|
|
@@ -4,6 +4,8 @@ import { extname } from 'node:path';
|
|
|
4
4
|
import { pipeline } from 'node:stream/promises';
|
|
5
5
|
import { attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, WORKSPACE_USER_UPLOADS_DIRECTORY, WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { artifactMime, artifactPath } from './artifact-files.js';
|
|
7
|
+
import { openLibraryAttachmentFile, readLibraryAttachmentMetadata } from './library-attachments.js';
|
|
8
|
+
import { openWorkspaceDirectoryFile, readWorkspaceDirectoryMetadata } from './directory-attachments.js';
|
|
7
9
|
const additionalMediaTypes = {
|
|
8
10
|
'.aac': 'audio/aac', '.opus': 'audio/ogg', '.oga': 'audio/ogg', '.aif': 'audio/aiff', '.aiff': 'audio/aiff',
|
|
9
11
|
'.bmp': 'image/bmp', '.ico': 'image/vnd.microsoft.icon', '.heic': 'image/heic', '.heif': 'image/heif',
|
|
@@ -24,13 +26,13 @@ function attachmentRange(header, size) {
|
|
|
24
26
|
const end = first === undefined ? size - 1 : Math.min(last ?? size - 1, size - 1);
|
|
25
27
|
return start >= size || start > end ? null : { start, end };
|
|
26
28
|
}
|
|
27
|
-
async function openAttachment(root, id) {
|
|
29
|
+
async function openAttachment(root, id, filePath) {
|
|
28
30
|
const metadataPath = await artifactPath(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata/${id}.json`);
|
|
29
31
|
const metadataFile = await open(metadataPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
30
32
|
let metadata;
|
|
31
33
|
try {
|
|
32
34
|
const stat = await metadataFile.stat();
|
|
33
|
-
if (!stat.isFile() || stat.nlink !== 1 || stat.size >
|
|
35
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024)
|
|
34
36
|
throw new Error('attachment_unavailable');
|
|
35
37
|
metadata = JSON.parse(await metadataFile.readFile('utf8'));
|
|
36
38
|
if (metadata.id !== id || !attachmentUploadSchema.shape.name.safeParse(metadata.name).success)
|
|
@@ -39,6 +41,16 @@ async function openAttachment(root, id) {
|
|
|
39
41
|
finally {
|
|
40
42
|
await metadataFile.close();
|
|
41
43
|
}
|
|
44
|
+
if (metadata.origin?.kind === 'asset-library') {
|
|
45
|
+
const library = await readLibraryAttachmentMetadata(root, id);
|
|
46
|
+
return openLibraryAttachmentFile(root, library, filePath);
|
|
47
|
+
}
|
|
48
|
+
if (metadata.kind === 'directory') {
|
|
49
|
+
const directory = await readWorkspaceDirectoryMetadata(root, id);
|
|
50
|
+
return openWorkspaceDirectoryFile(root, directory, filePath);
|
|
51
|
+
}
|
|
52
|
+
if (filePath !== undefined)
|
|
53
|
+
throw new Error('attachment_unavailable');
|
|
42
54
|
const path = await artifactPath(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}/${metadata.name}`);
|
|
43
55
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
44
56
|
try {
|
|
@@ -56,7 +68,7 @@ async function openAttachment(root, id) {
|
|
|
56
68
|
}
|
|
57
69
|
}
|
|
58
70
|
/** Serve only registered references; no caller-supplied file paths reach the filesystem. */
|
|
59
|
-
export async function serveWorkspaceAttachment(root, attachmentId, request, response) {
|
|
71
|
+
export async function serveWorkspaceAttachment(root, attachmentId, request, response, filePath) {
|
|
60
72
|
if (!attachmentIdsSchema.safeParse([attachmentId]).success) {
|
|
61
73
|
response.writeHead(400, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
62
74
|
response.end(JSON.stringify({ error: 'invalid_attachment' }));
|
|
@@ -64,7 +76,7 @@ export async function serveWorkspaceAttachment(root, attachmentId, request, resp
|
|
|
64
76
|
}
|
|
65
77
|
let content;
|
|
66
78
|
try {
|
|
67
|
-
content = await openAttachment(root, attachmentId);
|
|
79
|
+
content = await openAttachment(root, attachmentId, filePath);
|
|
68
80
|
}
|
|
69
81
|
catch {
|
|
70
82
|
response.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
@@ -6,6 +6,8 @@ import { WORKSPACE_USER_UPLOADS_DIRECTORY, attachmentIdsSchema, attachmentUpload
|
|
|
6
6
|
import { NativePreparationError } from '../codex/native-adapter.js';
|
|
7
7
|
import { excludeFromGit } from './dependencies.js';
|
|
8
8
|
import { hashAttachment, probeAttachmentVideo } from './video-attachments.js';
|
|
9
|
+
import { resolveLibraryAttachment } from './library-attachments.js';
|
|
10
|
+
import { resolveWorkspaceDirectoryAttachment } from './directory-attachments.js';
|
|
9
11
|
async function attachmentRoot(root) {
|
|
10
12
|
// Reject redirected upload directories, including symlinks that stay inside the workspace.
|
|
11
13
|
let directory = root;
|
|
@@ -136,13 +138,17 @@ export async function resolveWorkspaceAttachments(root, input) {
|
|
|
136
138
|
let metadata;
|
|
137
139
|
try {
|
|
138
140
|
const stat = await metadataHandle.stat();
|
|
139
|
-
if (!stat.isFile() || stat.nlink !== 1 || stat.size >
|
|
141
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024)
|
|
140
142
|
throw new Error('invalid_attachment');
|
|
141
143
|
metadata = JSON.parse(await metadataHandle.readFile('utf8'));
|
|
142
144
|
}
|
|
143
145
|
finally {
|
|
144
146
|
await metadataHandle.close();
|
|
145
147
|
}
|
|
148
|
+
if (metadata.origin?.kind === 'asset-library')
|
|
149
|
+
return resolveLibraryAttachment(root, id);
|
|
150
|
+
if (metadata.kind === 'directory')
|
|
151
|
+
return resolveWorkspaceDirectoryAttachment(root, id);
|
|
146
152
|
attachmentUploadSchema.shape.name.parse(metadata.name);
|
|
147
153
|
if (metadata.id !== id)
|
|
148
154
|
throw new Error('invalid_attachment');
|
|
@@ -11,6 +11,7 @@ import { readDependencies, scheduleDependencyInstall } from './dependencies.js';
|
|
|
11
11
|
import { revisionScope } from './registry.js';
|
|
12
12
|
import { ensureWorkspaceSkills } from './skills.js';
|
|
13
13
|
import { checkoutBatchCommit, freezeBatchCommit } from './batch-commit.js';
|
|
14
|
+
import { ensureWorkspaceArtifactInstructions } from './instructions.js';
|
|
14
15
|
const execute = promisify(execFile);
|
|
15
16
|
/** Trusted Runtime registration clones an immutable local commit; it never follows basic's current branch head. */
|
|
16
17
|
export async function registerWorkspaceDirectory(state, input, leaseFence, sourceDirectoryName) {
|
|
@@ -68,6 +69,7 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
|
|
|
68
69
|
await rm(staging, { recursive: true, force: true });
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
await ensureWorkspaceArtifactInstructions(root);
|
|
71
73
|
await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
|
|
72
74
|
await state.journal.exclusive(`workspace:${directory.workspaceId}`, async () => {
|
|
73
75
|
state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, leaseFence);
|
|
@@ -87,8 +89,12 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
|
|
|
87
89
|
await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
88
90
|
const binding = state.journal.getWorkspace(workspaceId);
|
|
89
91
|
const directory = binding?.directories?.find(item => item.directoryName === directoryName);
|
|
90
|
-
if (!binding
|
|
92
|
+
if (!binding)
|
|
91
93
|
return;
|
|
94
|
+
if (directory?.status !== 'reclaimed') {
|
|
95
|
+
await ensureWorkspaceArtifactInstructions(await workspaceRoot(state, workspaceId, directoryName));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
92
98
|
if (binding.targetKind === 'standalone' || !state.gitCredentials)
|
|
93
99
|
throw new WorkspaceContextError('workspace_recovery_required');
|
|
94
100
|
const preparation = await state.gitCredentials.prepareDirectory(workspaceId, directoryName);
|
|
@@ -98,6 +104,7 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
|
|
|
98
104
|
if (!await lstat(root).catch(() => undefined))
|
|
99
105
|
await prepareWorkspaceCheckout(state, target, preparation, state.acceptedLeaseFence, directoryName, true);
|
|
100
106
|
await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
|
|
107
|
+
await ensureWorkspaceArtifactInstructions(root);
|
|
101
108
|
await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
|
|
102
109
|
await state.journal.putWorkspace({ ...binding, directories: binding.directories?.map(item => item.directoryName === directoryName ? { ...item, status: 'ready' } : item) });
|
|
103
110
|
try {
|