@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.
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 +22 -5
  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 +76 -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
@@ -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);
@@ -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 > 8192)
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 > 8192)
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 || directory?.status !== 'reclaimed')
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 {