@playcraft/cli 0.1.23 → 0.1.25
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/dist/cli-root-help.js +1 -0
- package/dist/commands/playable.js +53 -0
- package/dist/commands/tools-3d.js +11 -0
- package/dist/commands/tools-batch.js +42 -14
- package/dist/commands/tools-generation.js +6 -0
- package/dist/commands/video.js +111 -0
- package/dist/index.js +29 -3
- package/dist/project-skills/builtin-import.js +225 -0
- package/dist/project-skills/commands.js +6 -1
- package/dist/project-skills/local-store.js +73 -1
- package/dist/project-skills/messages.js +8 -0
- package/dist/recording/messages.js +40 -0
- package/dist/recording/project-digest.js +85 -0
- package/dist/recording/recorder.js +293 -0
- package/dist/recording/scenario.js +50 -0
- package/dist/utils/agent-api-client.js +39 -3
- package/dist/utils/video-evidence-git.js +81 -0
- package/dist/video-replication/client.js +222 -0
- package/dist/video-replication/comparison-evidence.js +149 -0
- package/dist/video-replication/comparison-files.js +57 -0
- package/dist/video-replication/comparison-log.js +72 -0
- package/dist/video-replication/comparison-messages.js +104 -0
- package/dist/video-replication/comparison-output.js +63 -0
- package/dist/video-replication/comparison-preflight.js +66 -0
- package/dist/video-replication/comparison.js +216 -0
- package/dist/video-replication/evidence.js +165 -0
- package/dist/video-replication/media.js +111 -0
- package/dist/video-replication/messages.js +89 -0
- package/dist/workspace-runtime/codex/app-server.js +65 -12
- package/dist/workspace-runtime/codex/config-toml.js +3 -2
- package/dist/workspace-runtime/persistence/native-subagents.js +162 -0
- package/dist/workspace-runtime/persistence/sqlite-client.js +17 -1
- package/dist/workspace-runtime/persistence/sqlite-worker.js +116 -74
- package/dist/workspace-runtime/production/native.js +21 -1
- package/dist/workspace-runtime/production/save-command.js +5 -1
- package/dist/workspace-runtime/production/source-copy.js +91 -0
- package/dist/workspace-runtime/production/stages.js +48 -21
- package/dist/workspace-runtime/serve.js +3 -0
- package/dist/workspace-runtime/server/dispatch.js +34 -6
- package/dist/workspace-runtime/server/http.js +60 -3
- package/dist/workspace-runtime/server/terminal-host.js +2 -2
- package/dist/workspace-runtime/workspaces/artifact-events.js +55 -0
- package/dist/workspace-runtime/workspaces/artifact-files.js +195 -0
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +89 -0
- package/dist/workspace-runtime/workspaces/artifacts.js +260 -0
- package/dist/workspace-runtime/workspaces/attachments.js +70 -4
- package/dist/workspace-runtime/workspaces/batch-commit.js +1 -1
- package/dist/workspace-runtime/workspaces/cli-context.js +23 -0
- package/dist/workspace-runtime/workspaces/directories.js +10 -3
- package/dist/workspace-runtime/workspaces/directory-access.js +9 -0
- package/dist/workspace-runtime/workspaces/disk-snapshot.js +31 -0
- package/dist/workspace-runtime/workspaces/empty-template.js +12 -4
- package/dist/workspace-runtime/workspaces/files.js +8 -1
- package/dist/workspace-runtime/workspaces/prepare.js +2 -1
- package/dist/workspace-runtime/workspaces/registry.js +13 -5
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +45 -0
- package/dist/workspace-runtime/workspaces/skills.js +28 -18
- package/dist/workspace-runtime/workspaces/video-attachments.js +49 -0
- package/package.json +7 -4
- package/project-template/.claude/skills/playcraft-replica-validation/SKILL.md +25 -0
- package/project-template/.claude/skills/playcraft-replica-validation/references/contracts.md +79 -0
- package/project-template/.claude/skills/playcraft-video-understanding/SKILL.md +29 -0
- package/project-template/.claude/skills/playcraft-video-understanding/references/contracts.md +37 -0
- package/project-templates/empty/AGENTS.md +2 -0
- package/project-templates/empty/gitignore +2 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
|
|
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
|
+
const serial = new Map();
|
|
11
|
+
function digest(text) { return createHash('sha256').update(text).digest('hex'); }
|
|
12
|
+
function artifactId(workspaceId, path) { return `artifact_${digest(`${workspaceId}\0${path}`).slice(0, 40)}`; }
|
|
13
|
+
/** Catalogs and immutable bytes are control-owned, outside the mutable checkout. */
|
|
14
|
+
export class WorkspaceArtifactStore {
|
|
15
|
+
stateDir;
|
|
16
|
+
workspaceId;
|
|
17
|
+
constructor(stateDir, workspaceId) {
|
|
18
|
+
this.stateDir = stateDir;
|
|
19
|
+
this.workspaceId = workspaceId;
|
|
20
|
+
}
|
|
21
|
+
async directory() {
|
|
22
|
+
await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
|
|
23
|
+
return artifactDirectory(this.stateDir, `artifacts/${digest(this.workspaceId)}`);
|
|
24
|
+
}
|
|
25
|
+
async read(directory) {
|
|
26
|
+
const handle = await open(join(directory, 'catalog.json'), constants.O_RDONLY | constants.O_NOFOLLOW).catch(error => {
|
|
27
|
+
if (error.code === 'ENOENT')
|
|
28
|
+
return undefined;
|
|
29
|
+
throw error;
|
|
30
|
+
});
|
|
31
|
+
if (!handle)
|
|
32
|
+
return { artifacts: [], scannedAt: new Date().toISOString(), receipts: [], observations: {} };
|
|
33
|
+
try {
|
|
34
|
+
const stat = await handle.stat();
|
|
35
|
+
if (!stat.isFile() || stat.size > 32 * 1024 * 1024)
|
|
36
|
+
throw new Error('artifact_catalog_invalid');
|
|
37
|
+
const { receipts, observations = {}, ...body } = JSON.parse(await handle.readFile('utf8'));
|
|
38
|
+
if (!Array.isArray(receipts) || receipts.some(id => typeof id !== 'string'))
|
|
39
|
+
throw new Error('artifact_catalog_invalid');
|
|
40
|
+
return { ...workspaceArtifactListSchema.parse(body), receipts, observations };
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await handle.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async write(directory, catalog) {
|
|
47
|
+
const temporary = join(directory, `${randomUUID()}.tmp`);
|
|
48
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
49
|
+
try {
|
|
50
|
+
await handle.writeFile(JSON.stringify(catalog));
|
|
51
|
+
await handle.sync();
|
|
52
|
+
await handle.close();
|
|
53
|
+
await rename(temporary, join(directory, 'catalog.json'));
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle.close();
|
|
57
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
|
|
58
|
+
throw error; });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async discover(root, source = { kind: 'discovered' }, sourcePaths = []) {
|
|
62
|
+
const key = `${this.stateDir}\0${this.workspaceId}`;
|
|
63
|
+
const previous = serial.get(key) ?? Promise.resolve();
|
|
64
|
+
const task = previous.catch(() => undefined).then(() => this.scan(root, source, sourcePaths));
|
|
65
|
+
serial.set(key, task);
|
|
66
|
+
try {
|
|
67
|
+
return await task;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (serial.get(key) === task)
|
|
71
|
+
serial.delete(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async list() {
|
|
75
|
+
const catalog = await this.read(await this.directory());
|
|
76
|
+
return workspaceArtifactListSchema.parse({ artifacts: catalog.artifacts, scannedAt: catalog.scannedAt });
|
|
77
|
+
}
|
|
78
|
+
async scan(root, source, sourcePaths) {
|
|
79
|
+
const directory = await this.directory();
|
|
80
|
+
const objects = await artifactDirectory(directory, 'objects');
|
|
81
|
+
const catalog = await this.read(directory);
|
|
82
|
+
const original = JSON.stringify(catalog.artifacts);
|
|
83
|
+
const receipts = await this.receipts(root, catalog);
|
|
84
|
+
for (const receipt of receipts) {
|
|
85
|
+
try {
|
|
86
|
+
for (const file of receipt.files) {
|
|
87
|
+
const copied = await snapshotArtifactFile(root, `${ARTIFACT_RECEIPTS_DIRECTORY}/objects/${file.sha256}`, objects);
|
|
88
|
+
if (copied.sha256 !== file.sha256 || copied.size !== file.size)
|
|
89
|
+
throw new Error('artifact_receipt_digest_mismatch');
|
|
90
|
+
}
|
|
91
|
+
const primary = receipt.files.find(file => file.role === 'primary' && file.path === receipt.path);
|
|
92
|
+
if (!primary || !artifactFormat(receipt.path))
|
|
93
|
+
continue;
|
|
94
|
+
const version = {
|
|
95
|
+
id: `version_${receipt.id}`, createdAt: receipt.source.generatedAt ?? new Date().toISOString(),
|
|
96
|
+
path: receipt.path, mimeType: primary.mimeType, size: primary.size, sha256: primary.sha256,
|
|
97
|
+
files: receipt.files, source: receipt.source,
|
|
98
|
+
...(receipt.width ? { width: receipt.width } : {}), ...(receipt.height ? { height: receipt.height } : {}),
|
|
99
|
+
...(receipt.duration !== undefined ? { duration: receipt.duration } : {}),
|
|
100
|
+
};
|
|
101
|
+
this.add(catalog, version);
|
|
102
|
+
catalog.receipts.push(receipt.id);
|
|
103
|
+
}
|
|
104
|
+
catch { /* An incomplete receipt stays pending; the next scan retries its original bytes. */ }
|
|
105
|
+
}
|
|
106
|
+
const candidates = await discoverPaths(root);
|
|
107
|
+
// Models are inspected before images so their explicitly referenced textures stay under the model.
|
|
108
|
+
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))));
|
|
110
|
+
for (const path of candidates) {
|
|
111
|
+
if (members.has(path))
|
|
112
|
+
continue;
|
|
113
|
+
try {
|
|
114
|
+
const current = catalog.artifacts.find(artifact => artifact.id === artifactId(this.workspaceId, path));
|
|
115
|
+
const version = current?.versions.find(entry => entry.id === current.currentVersionId);
|
|
116
|
+
const knownWrite = sourcePaths.includes(path) && Boolean(source.operationId) && version?.source.operationId !== source.operationId;
|
|
117
|
+
const observation = catalog.observations[path];
|
|
118
|
+
if (!knownWrite && version && observation?.versionId === version.id && observation.signature === await fileSignature(root, version.files.map(file => file.path)))
|
|
119
|
+
continue;
|
|
120
|
+
const thumbnail = version?.files.find(file => file.role === 'thumbnail');
|
|
121
|
+
const thumbnailPath = thumbnail && await lstat(join(root, thumbnail.path)).then(stat => stat.isFile(), () => false) ? thumbnail.path : undefined;
|
|
122
|
+
const paths = [path, ...await artifactResources(root, path), ...(thumbnailPath ? [thumbnailPath] : [])];
|
|
123
|
+
const signature = await fileSignature(root, paths);
|
|
124
|
+
const files = await snapshotArtifactBundle(root, path, objects, thumbnailPath);
|
|
125
|
+
if (signature !== await fileSignature(root, files.map(file => file.path)))
|
|
126
|
+
continue;
|
|
127
|
+
for (const file of files)
|
|
128
|
+
if (file.role !== 'primary')
|
|
129
|
+
members.add(file.path);
|
|
130
|
+
if (!knownWrite && version && artifactBundleDigest(version.files) === artifactBundleDigest(files)) {
|
|
131
|
+
catalog.observations[path] = { signature, versionId: version.id };
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const primary = files[0];
|
|
135
|
+
const next = {
|
|
136
|
+
id: `version_${knownWrite ? source.operationId : randomUUID()}`, createdAt: new Date().toISOString(),
|
|
137
|
+
path, mimeType: primary.mimeType, size: primary.size, sha256: primary.sha256, files,
|
|
138
|
+
source: sourcePaths.includes(path) ? source : { kind: 'discovered' },
|
|
139
|
+
...await this.dimensions(objects, primary),
|
|
140
|
+
};
|
|
141
|
+
this.add(catalog, next);
|
|
142
|
+
catalog.observations[path] = { signature, versionId: next.id };
|
|
143
|
+
}
|
|
144
|
+
catch { /* Files being written or temporarily missing are retried, never published as complete. */ }
|
|
145
|
+
}
|
|
146
|
+
catalog.artifacts = catalog.artifacts.filter(artifact => {
|
|
147
|
+
const current = artifact.versions.find(version => version.id === artifact.currentVersionId);
|
|
148
|
+
return current.source.kind !== 'discovered' || !members.has(current.path);
|
|
149
|
+
});
|
|
150
|
+
catalog.artifacts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
|
|
151
|
+
catalog.scannedAt = new Date().toISOString();
|
|
152
|
+
const list = workspaceArtifactListSchema.parse({ artifacts: catalog.artifacts, scannedAt: catalog.scannedAt });
|
|
153
|
+
await this.write(directory, catalog);
|
|
154
|
+
return { list, changed: original !== JSON.stringify(catalog.artifacts) };
|
|
155
|
+
}
|
|
156
|
+
add(catalog, version) {
|
|
157
|
+
const id = artifactId(this.workspaceId, version.path);
|
|
158
|
+
let artifact = catalog.artifacts.find(item => item.id === id);
|
|
159
|
+
if (!artifact) {
|
|
160
|
+
artifact = { id, kind: artifactFormat(version.path)[0], name: basename(version.path), currentVersionId: version.id, updatedAt: version.createdAt, versions: [] };
|
|
161
|
+
catalog.artifacts.push(artifact);
|
|
162
|
+
}
|
|
163
|
+
if (!artifact.versions.some(item => item.id === version.id)) {
|
|
164
|
+
// Display ordering is monotonic; the original generation time stays in source.generatedAt.
|
|
165
|
+
const latest = Math.max(0, ...artifact.versions.map(item => Date.parse(item.createdAt)));
|
|
166
|
+
version.createdAt = new Date(Math.max(Date.parse(version.createdAt), latest + 1)).toISOString();
|
|
167
|
+
artifact.versions.unshift(version);
|
|
168
|
+
}
|
|
169
|
+
artifact.currentVersionId = version.id;
|
|
170
|
+
artifact.updatedAt = version.createdAt;
|
|
171
|
+
}
|
|
172
|
+
async receipts(root, catalog) {
|
|
173
|
+
let directory;
|
|
174
|
+
try {
|
|
175
|
+
const target = join(root, ARTIFACT_RECEIPTS_DIRECTORY);
|
|
176
|
+
if (!(await lstat(target)).isDirectory())
|
|
177
|
+
return [];
|
|
178
|
+
directory = await artifactDirectory(root, ARTIFACT_RECEIPTS_DIRECTORY);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const seen = new Set(catalog.receipts), receipts = [];
|
|
184
|
+
for (const name of await readdir(directory)) {
|
|
185
|
+
if (!/^[a-f0-9-]{36}\.json$/.test(name) || seen.has(name.slice(0, -5)))
|
|
186
|
+
continue;
|
|
187
|
+
try {
|
|
188
|
+
const receipt = await readArtifactReceipt(join(directory, name));
|
|
189
|
+
if (receipt.workspaceId === this.workspaceId && `${receipt.id}.json` === name)
|
|
190
|
+
receipts.push(receipt);
|
|
191
|
+
}
|
|
192
|
+
catch { /* Malformed output metadata is never trusted as a completed artifact. */ }
|
|
193
|
+
}
|
|
194
|
+
return receipts.sort((a, b) => (a.source.generatedAt ?? '').localeCompare(b.source.generatedAt ?? '') || a.id.localeCompare(b.id));
|
|
195
|
+
}
|
|
196
|
+
async dimensions(objects, file) {
|
|
197
|
+
if (!file.mimeType.startsWith('image/') || file.mimeType === 'image/svg+xml')
|
|
198
|
+
return {};
|
|
199
|
+
try {
|
|
200
|
+
const metadata = await sharp(join(objects, file.sha256), { limitInputPixels: 100_000_000 }).metadata();
|
|
201
|
+
return { ...(metadata.width ? { width: metadata.width } : {}), ...(metadata.height ? { height: metadata.height } : {}) };
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return {};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async content(input) {
|
|
208
|
+
const query = workspaceArtifactContentQuerySchema.parse(input);
|
|
209
|
+
const directory = await this.directory(), catalog = await this.read(directory);
|
|
210
|
+
const artifact = catalog.artifacts.find(item => item.id === query.artifactId);
|
|
211
|
+
const version = artifact?.versions.find(item => item.id === query.versionId);
|
|
212
|
+
const file = version?.files.find(item => item.path === (query.path ?? version.path));
|
|
213
|
+
if (!file)
|
|
214
|
+
throw new Error('artifact_not_found');
|
|
215
|
+
if (query.offset > file.size)
|
|
216
|
+
throw new Error('artifact_range_invalid');
|
|
217
|
+
const handle = await open(join(directory, 'objects', file.sha256), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
218
|
+
try {
|
|
219
|
+
const stat = await handle.stat();
|
|
220
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size !== file.size)
|
|
221
|
+
throw new Error('artifact_snapshot_invalid');
|
|
222
|
+
const bytes = Buffer.alloc(Math.min(query.length, file.size - query.offset));
|
|
223
|
+
let read = 0;
|
|
224
|
+
while (read < bytes.length) {
|
|
225
|
+
const part = await handle.read(bytes, read, bytes.length - read, query.offset + read);
|
|
226
|
+
if (!part.bytesRead)
|
|
227
|
+
throw new Error('artifact_snapshot_invalid');
|
|
228
|
+
read += part.bytesRead;
|
|
229
|
+
}
|
|
230
|
+
return { content: bytes.toString('base64'), mimeType: file.mimeType, totalBytes: file.size, offset: query.offset, sha256: file.sha256 };
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
await handle.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async function fileSignature(root, paths) {
|
|
238
|
+
const records = [];
|
|
239
|
+
for (const path of [...new Set(paths)].sort()) {
|
|
240
|
+
const stat = await lstat(await artifactPath(root, path));
|
|
241
|
+
if (!stat.isFile() || stat.nlink !== 1)
|
|
242
|
+
throw new Error('artifact_file_unavailable');
|
|
243
|
+
records.push([path, stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs]);
|
|
244
|
+
}
|
|
245
|
+
return JSON.stringify(records);
|
|
246
|
+
}
|
|
247
|
+
async function discoverPaths(root, directory = '', result = []) {
|
|
248
|
+
for (const entry of await readdir(join(root, directory), { withFileTypes: true })) {
|
|
249
|
+
if (entry.name.startsWith('.') || entry.isSymbolicLink())
|
|
250
|
+
continue;
|
|
251
|
+
const path = directory ? `${directory}/${entry.name}` : entry.name;
|
|
252
|
+
if (entry.isDirectory()) {
|
|
253
|
+
if (!skippedDirectories.has(entry.name))
|
|
254
|
+
await discoverPaths(root, path, result);
|
|
255
|
+
}
|
|
256
|
+
else if (entry.isFile() && artifactFormat(path))
|
|
257
|
+
result.push(path);
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { chmod, lstat, mkdir, open,
|
|
3
|
+
import { chmod, lstat, mkdir, open, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { WORKSPACE_USER_UPLOADS_DIRECTORY, attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { WORKSPACE_USER_UPLOADS_DIRECTORY, attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES, WORKSPACE_VIDEO_ATTACHMENT_MIME_TYPES, workspaceVideoAttachmentQuerySchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { NativePreparationError } from '../codex/native-adapter.js';
|
|
7
7
|
import { excludeFromGit } from './dependencies.js';
|
|
8
|
+
import { hashAttachment, probeAttachmentVideo } from './video-attachments.js';
|
|
8
9
|
async function attachmentRoot(root) {
|
|
9
10
|
// Reject redirected upload directories, including symlinks that stay inside the workspace.
|
|
10
11
|
let directory = root;
|
|
@@ -74,6 +75,52 @@ export async function uploadWorkspaceAttachment(root, body) {
|
|
|
74
75
|
});
|
|
75
76
|
return attachment;
|
|
76
77
|
}
|
|
78
|
+
/** Video bodies are streamed into an unpublished attachment and never decoded as base64. */
|
|
79
|
+
export async function uploadWorkspaceVideoAttachment(root, request, query) {
|
|
80
|
+
const { name } = workspaceVideoAttachmentQuerySchema.parse(query);
|
|
81
|
+
const contentType = String(request.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
|
|
82
|
+
const declaredSize = request.headers['content-length'];
|
|
83
|
+
if (!WORKSPACE_VIDEO_ATTACHMENT_MIME_TYPES.includes(contentType)
|
|
84
|
+
|| (declaredSize !== undefined && (!/^\d+$/.test(declaredSize) || Number(declaredSize) > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)))
|
|
85
|
+
throw new Error('invalid_attachment');
|
|
86
|
+
await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
|
|
87
|
+
const uploads = await attachmentRoot(root);
|
|
88
|
+
const id = randomUUID(), directory = join(uploads, id), metadataDirectory = join(uploads, '.metadata');
|
|
89
|
+
await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => { if (error.code !== 'EEXIST')
|
|
90
|
+
throw error; });
|
|
91
|
+
if (!(await lstat(metadataDirectory)).isDirectory())
|
|
92
|
+
throw new Error('invalid_attachment');
|
|
93
|
+
await mkdir(directory, { mode: 0o755 });
|
|
94
|
+
const handle = await open(join(directory, name), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
|
|
95
|
+
try {
|
|
96
|
+
let size = 0;
|
|
97
|
+
for await (const data of request) {
|
|
98
|
+
const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
99
|
+
size += bytes.length;
|
|
100
|
+
if (size > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)
|
|
101
|
+
throw new Error('invalid_attachment');
|
|
102
|
+
let offset = 0;
|
|
103
|
+
while (offset < bytes.length)
|
|
104
|
+
offset += (await handle.write(bytes, offset, bytes.length - offset, size - bytes.length + offset)).bytesWritten;
|
|
105
|
+
}
|
|
106
|
+
if (!size || (declaredSize !== undefined && Number(declaredSize) !== size))
|
|
107
|
+
throw new Error('invalid_attachment');
|
|
108
|
+
await handle.sync();
|
|
109
|
+
const media = await probeAttachmentVideo(handle);
|
|
110
|
+
if (contentType !== media.mimeType && !(contentType === 'video/quicktime' && media.mimeType === 'video/mp4'))
|
|
111
|
+
throw new Error('invalid_attachment');
|
|
112
|
+
const attachment = { id, name, size, ...media, sha256: await hashAttachment(handle) };
|
|
113
|
+
await writeFile(join(metadataDirectory, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
|
|
114
|
+
return attachment;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
await rm(directory, { recursive: true, force: true });
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await handle.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
77
124
|
export async function resolveWorkspaceAttachments(root, input) {
|
|
78
125
|
try {
|
|
79
126
|
const ids = attachmentIdsSchema.parse(input ?? []);
|
|
@@ -85,7 +132,17 @@ export async function resolveWorkspaceAttachments(root, input) {
|
|
|
85
132
|
await chmod(directory, 0o755);
|
|
86
133
|
if (!(await lstat(join(uploads, '.metadata'))).isDirectory())
|
|
87
134
|
throw new Error('invalid_attachment');
|
|
88
|
-
const
|
|
135
|
+
const metadataHandle = await open(join(uploads, '.metadata', `${id}.json`), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
136
|
+
let metadata;
|
|
137
|
+
try {
|
|
138
|
+
const stat = await metadataHandle.stat();
|
|
139
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 8192)
|
|
140
|
+
throw new Error('invalid_attachment');
|
|
141
|
+
metadata = JSON.parse(await metadataHandle.readFile('utf8'));
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
await metadataHandle.close();
|
|
145
|
+
}
|
|
89
146
|
attachmentUploadSchema.shape.name.parse(metadata.name);
|
|
90
147
|
if (metadata.id !== id)
|
|
91
148
|
throw new Error('invalid_attachment');
|
|
@@ -93,8 +150,17 @@ export async function resolveWorkspaceAttachments(root, input) {
|
|
|
93
150
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
94
151
|
try {
|
|
95
152
|
const stat = await handle.stat();
|
|
96
|
-
|
|
153
|
+
const video = metadata.mimeType?.startsWith('video/');
|
|
154
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size !== metadata.size || stat.size > (video ? WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES : WORKSPACE_ATTACHMENT_MAX_BYTES))
|
|
97
155
|
throw new Error('invalid_attachment');
|
|
156
|
+
if (video) {
|
|
157
|
+
const media = await probeAttachmentVideo(handle);
|
|
158
|
+
const sha256 = await hashAttachment(handle);
|
|
159
|
+
if (sha256 !== metadata.sha256 || media.mimeType !== metadata.mimeType || media.durationMs !== metadata.durationMs
|
|
160
|
+
|| media.width !== metadata.width || media.height !== metadata.height)
|
|
161
|
+
throw new Error('invalid_attachment');
|
|
162
|
+
return { ...metadata, ...media, path };
|
|
163
|
+
}
|
|
98
164
|
const bytes = await handle.readFile();
|
|
99
165
|
return { ...metadata, size: bytes.length, mimeType: imageMime(bytes), path };
|
|
100
166
|
}
|
|
@@ -12,7 +12,7 @@ const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
|
12
12
|
const PRIVATE_NAMES = new Set([
|
|
13
13
|
'.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store', '.config',
|
|
14
14
|
'.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json',
|
|
15
|
-
'credentials', 'secrets', 'id_rsa', 'id_ed25519', 'settings.local.json',
|
|
15
|
+
'credentials', 'secrets', 'id_rsa', 'id_ed25519', 'settings.local.json', '.playcraft.json', 'playcraft.config.json',
|
|
16
16
|
]);
|
|
17
17
|
const DEPENDENCY_LOCKS = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'bun.lock', 'bun.lockb'];
|
|
18
18
|
const SKILLS_DECLARATION = '.playcraft/skills.json';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { excludeFromGit } from './dependencies.js';
|
|
5
|
+
/** Non-secret routing hint; the business service still authorizes the selected workspace. */
|
|
6
|
+
export async function writeWorkspaceCliContext(root, workspaceId) {
|
|
7
|
+
const management = join(root, '.playcraft');
|
|
8
|
+
await mkdir(management, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
|
|
9
|
+
throw error; });
|
|
10
|
+
const stat = await lstat(management);
|
|
11
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
12
|
+
throw new Error('workspace_skills_invalid');
|
|
13
|
+
const temporary = join(management, `workspace-context.${randomUUID()}.tmp`);
|
|
14
|
+
try {
|
|
15
|
+
await writeFile(temporary, `${JSON.stringify({ schemaVersion: 'playcraft.workspace-context.v1', workspaceId })}\n`, { flag: 'wx', mode: 0o644 });
|
|
16
|
+
await rename(temporary, join(management, 'workspace-context.json'));
|
|
17
|
+
await excludeFromGit(root, '/.playcraft/workspace-context.json');
|
|
18
|
+
await excludeFromGit(root, '/.playcraft/video-replication/');
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await rm(temporary, { force: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -10,9 +10,10 @@ import { prepareWorkspaceCheckout } from './prepare.js';
|
|
|
10
10
|
import { readDependencies, scheduleDependencyInstall } from './dependencies.js';
|
|
11
11
|
import { revisionScope } from './registry.js';
|
|
12
12
|
import { ensureWorkspaceSkills } from './skills.js';
|
|
13
|
+
import { checkoutBatchCommit, freezeBatchCommit } from './batch-commit.js';
|
|
13
14
|
const execute = promisify(execFile);
|
|
14
15
|
/** Trusted Runtime registration clones an immutable local commit; it never follows basic's current branch head. */
|
|
15
|
-
export async function registerWorkspaceDirectory(state, input, leaseFence) {
|
|
16
|
+
export async function registerWorkspaceDirectory(state, input, leaseFence, sourceDirectoryName) {
|
|
16
17
|
const directory = workspaceDirectoryRegistrationSchema.parse(input);
|
|
17
18
|
if (directory.directoryName === 'basic')
|
|
18
19
|
throw new WorkspaceContextError('workspace_directory_binding_conflict');
|
|
@@ -43,11 +44,17 @@ export async function registerWorkspaceDirectory(state, input, leaseFence) {
|
|
|
43
44
|
throw new WorkspaceContextError('workspace_recovery_required');
|
|
44
45
|
if (!directory.sourceCommit)
|
|
45
46
|
throw new WorkspaceContextError('workspace_directory_source_commit_required');
|
|
46
|
-
const source = await workspaceRoot(state, directory.workspaceId, 'basic');
|
|
47
|
+
const source = await workspaceRoot(state, directory.workspaceId, sourceDirectoryName ?? 'basic');
|
|
47
48
|
await git(source, 'cat-file', '-e', `${directory.sourceCommit}^{commit}`);
|
|
48
49
|
const staging = join(parent, `.directory-${randomUUID()}`);
|
|
49
50
|
try {
|
|
50
|
-
|
|
51
|
+
if (sourceDirectoryName !== undefined) {
|
|
52
|
+
const frozen = await freezeBatchCommit({ source: { workspaceId: directory.workspaceId, root: source },
|
|
53
|
+
commitHash: directory.sourceCommit, snapshotId: `directory-${directory.remixId}` });
|
|
54
|
+
await checkoutBatchCommit({ source: { workspaceId: directory.workspaceId, root: source }, frozen, destination: staging });
|
|
55
|
+
}
|
|
56
|
+
else
|
|
57
|
+
await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, staging], { timeout: 90_000, maxBuffer: 1024 * 1024 });
|
|
51
58
|
await git(staging, 'checkout', '-b', directory.branchName, directory.sourceCommit);
|
|
52
59
|
await git(staging, 'remote', 'set-url', 'origin', binding.repositoryUrl);
|
|
53
60
|
await git(staging, 'config', 'user.name', 'PlayCraft');
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { chmod, chown, lstat, readdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
/** Grant development-group traversal of the workspace container without touching source checkouts. */
|
|
4
|
+
export async function grantWorkspaceTraversal(root, groupId) {
|
|
5
|
+
const stat = await lstat(root);
|
|
6
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
7
|
+
throw new Error('invalid_workspace_directory');
|
|
8
|
+
if (stat.gid !== groupId)
|
|
9
|
+
await chown(root, stat.uid, groupId);
|
|
10
|
+
await chmod(root, (stat.mode & 0o777) | 0o2010);
|
|
11
|
+
}
|
|
3
12
|
/** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
|
|
4
13
|
export async function openDirectoryToDevelopment(root, groupId) {
|
|
5
14
|
const visit = async (path) => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import { WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
export async function sampleDiskFile(path) {
|
|
6
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
7
|
+
try {
|
|
8
|
+
const before = await handle.stat();
|
|
9
|
+
if (!before.isFile() || before.size > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)
|
|
10
|
+
throw new Error('snapshot_body_missing');
|
|
11
|
+
const digest = createHash('sha256');
|
|
12
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
13
|
+
let position = 0;
|
|
14
|
+
for (;;) {
|
|
15
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
|
|
16
|
+
if (!bytesRead)
|
|
17
|
+
break;
|
|
18
|
+
digest.update(chunk.subarray(0, bytesRead));
|
|
19
|
+
position += bytesRead;
|
|
20
|
+
if (position > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)
|
|
21
|
+
throw new Error('snapshot_body_missing');
|
|
22
|
+
}
|
|
23
|
+
const after = await handle.stat();
|
|
24
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || position !== before.size)
|
|
25
|
+
throw new Error('snapshot_body_missing');
|
|
26
|
+
return { hash: digest.digest('hex'), size: position, mode: before.mode & 0o777 };
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await handle.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { cp, mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { resolveCliPackageRoot } from '../../scaffold/compose.js';
|
|
4
|
+
import { importBuiltinProjectSkills, selectPlatformSkillSnapshot } from '../../project-skills/builtin-import.js';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { canonicalProjectSkillsJson } from '@playcraft/common/project-skills';
|
|
4
7
|
import { syncLocalProjectSkills } from '../../project-skills/local-store.js';
|
|
5
|
-
export const
|
|
8
|
+
export const EMPTY_WORKSPACE_REPOSITORY_SKILLS = [
|
|
6
9
|
'playcraft-skill-recommender',
|
|
7
10
|
'playcraft-image-generation',
|
|
8
11
|
'playcraft-audio-generation',
|
|
9
12
|
'playcraft-image-processing',
|
|
10
13
|
'playcraft-build-optimizer',
|
|
11
14
|
];
|
|
15
|
+
export const EMPTY_WORKSPACE_PLATFORM_SKILLS = ['playcraft-video-understanding', 'playcraft-replica-validation'];
|
|
16
|
+
export const EMPTY_WORKSPACE_DEFAULT_SKILLS = [...EMPTY_WORKSPACE_REPOSITORY_SKILLS, ...EMPTY_WORKSPACE_PLATFORM_SKILLS];
|
|
12
17
|
/** Only called in a new, unpublished staging directory; existing workspaces are never scaffolded. */
|
|
13
|
-
export async function scaffoldEmptyWorkspace(root, skillCache) {
|
|
18
|
+
export async function scaffoldEmptyWorkspace(root, skillCache, access) {
|
|
19
|
+
const selected = await selectPlatformSkillSnapshot([...EMPTY_WORKSPACE_PLATFORM_SKILLS], access);
|
|
14
20
|
const cliRoot = resolveCliPackageRoot();
|
|
15
21
|
const template = join(cliRoot, 'project-templates', 'empty');
|
|
16
22
|
for (const [source, target] of [
|
|
@@ -21,12 +27,14 @@ export async function scaffoldEmptyWorkspace(root, skillCache) {
|
|
|
21
27
|
}
|
|
22
28
|
const skills = join(root, '.agents', 'skills');
|
|
23
29
|
await mkdir(skills, { recursive: true });
|
|
24
|
-
for (const name of
|
|
30
|
+
for (const name of EMPTY_WORKSPACE_REPOSITORY_SKILLS) {
|
|
25
31
|
await cp(join(cliRoot, 'project-template', '.claude', 'skills', name), join(skills, name), {
|
|
26
32
|
recursive: true,
|
|
27
33
|
force: false,
|
|
28
34
|
errorOnExist: true,
|
|
29
35
|
});
|
|
30
36
|
}
|
|
31
|
-
await syncLocalProjectSkills(root, skillCache);
|
|
37
|
+
const snapshot = await syncLocalProjectSkills(root, skillCache);
|
|
38
|
+
await importBuiltinProjectSkills(root, { names: [...EMPTY_WORKSPACE_PLATFORM_SKILLS], expectedDeclarationHash: snapshot.lock.declarationHash,
|
|
39
|
+
expectedLockHash: createHash('sha256').update(canonicalProjectSkillsJson(snapshot.lock)).digest('hex') }, skillCache, access, selected, true);
|
|
32
40
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { hashWorkspaceFileContent, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { sampleDiskFile } from './disk-snapshot.js';
|
|
4
5
|
const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
|
|
6
|
+
const LARGE_FILE_STREAM_THRESHOLD = 8 * 1024 * 1024;
|
|
5
7
|
export class InvalidWorkspacePathError extends Error {
|
|
6
8
|
code = 'invalid_workspace_path';
|
|
7
9
|
constructor(path) {
|
|
@@ -55,6 +57,11 @@ export async function listWorkspaceFiles(root, revisions) {
|
|
|
55
57
|
const files = [];
|
|
56
58
|
for (const relativePath of await walkWorkspaceFiles(rootReal, rootReal)) {
|
|
57
59
|
const target = join(rootReal, ...relativePath.split('/'));
|
|
60
|
+
if ((await lstat(target)).size > LARGE_FILE_STREAM_THRESHOLD) {
|
|
61
|
+
const sampled = await sampleDiskFile(target);
|
|
62
|
+
files.push({ path: relativePath, revision: revisions[relativePath]?.revision ?? 0, contentHash: sampled.hash, size: sampled.size });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
58
65
|
const content = await readFile(target, 'utf8');
|
|
59
66
|
files.push({
|
|
60
67
|
path: relativePath,
|
|
@@ -4,6 +4,7 @@ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, workspaceDirectoryNameSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { WorkspaceContextError } from './context-error.js';
|
|
7
|
+
import { workspacePlatformSkillAccess } from './skill-artifact-loader.js';
|
|
7
8
|
import { scaffoldEmptyWorkspace } from './empty-template.js';
|
|
8
9
|
/** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
|
|
9
10
|
export async function prepareWorkspaceCheckout(state, binding, input, leaseFence, directoryName = 'basic', recovery = false) {
|
|
@@ -96,7 +97,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
96
97
|
try {
|
|
97
98
|
if (standalone) {
|
|
98
99
|
await runGit(['init', '--initial-branch=main', staging], env, deadline);
|
|
99
|
-
await scaffoldEmptyWorkspace(staging, join(state.config.stateDir, 'skill-artifacts'));
|
|
100
|
+
await scaffoldEmptyWorkspace(staging, join(state.config.stateDir, 'skill-artifacts'), workspacePlatformSkillAccess(state, binding.workspaceId));
|
|
100
101
|
}
|
|
101
102
|
else {
|
|
102
103
|
const fork = binding.pendingFork;
|
|
@@ -7,7 +7,8 @@ import { prepareWorkspaceCheckout } from './prepare.js';
|
|
|
7
7
|
import { WorkspaceContextError } from './context-error.js';
|
|
8
8
|
import { scheduleDependencyInstall } from './dependencies.js';
|
|
9
9
|
import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
|
|
10
|
-
import { loadWorkspaceSkillArtifact } from './skill-artifact-loader.js';
|
|
10
|
+
import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skill-artifact-loader.js';
|
|
11
|
+
import { writeWorkspaceCliContext } from './cli-context.js';
|
|
11
12
|
export { WorkspaceContextError } from './context-error.js';
|
|
12
13
|
const exec = promisify(execFile);
|
|
13
14
|
export function revisionScope(state, workspaceId, directoryName = 'basic') {
|
|
@@ -88,7 +89,7 @@ export async function workspaceContextFromRoot(state, cwd) {
|
|
|
88
89
|
throw new WorkspaceContextError('workspace_not_registered');
|
|
89
90
|
return { workspaceId, directoryName };
|
|
90
91
|
}
|
|
91
|
-
export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
92
|
+
export async function registerWorkspace(state, input, leaseFence, preparation, readOnlyBasic = false) {
|
|
92
93
|
if (state.config.workspaceMode !== 'multi')
|
|
93
94
|
throw new WorkspaceContextError('multi_workspace_unavailable');
|
|
94
95
|
const parsed = workspaceRegistrationSchema.safeParse(input);
|
|
@@ -103,7 +104,9 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
103
104
|
state.acceptedLeaseFence = leaseFence;
|
|
104
105
|
await state.journal.setAcceptedLeaseFence(leaseFence);
|
|
105
106
|
const existing = state.journal.getWorkspace(binding.workspaceId);
|
|
106
|
-
|
|
107
|
+
const preservedLocalBasic = binding.targetKind !== 'standalone' && binding.preserveLocalBasic === true;
|
|
108
|
+
const localUpgrade = preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
|
|
109
|
+
if (existing && !sameWorkspaceRegistration(existing, binding) && !localUpgrade)
|
|
107
110
|
throw new WorkspaceContextError('workspace_binding_conflict');
|
|
108
111
|
const directories = binding.directories ?? existing?.directories;
|
|
109
112
|
if (directories?.some(directory => directory.directoryName === 'basic' && directory.status === 'reclaimed')) {
|
|
@@ -141,7 +144,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
141
144
|
})).stdout.trim();
|
|
142
145
|
try {
|
|
143
146
|
let checkoutMatches;
|
|
144
|
-
if (binding.targetKind === 'standalone') {
|
|
147
|
+
if (binding.targetKind === 'standalone' || preservedLocalBasic) {
|
|
145
148
|
const gitDirectory = await lstat(join(root, '.git'));
|
|
146
149
|
checkoutMatches = gitDirectory.isDirectory() && !gitDirectory.isSymbolicLink()
|
|
147
150
|
&& (await git('rev-parse', '--absolute-git-dir')) === join(root, '.git')
|
|
@@ -164,6 +167,10 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
164
167
|
throw new WorkspaceContextError('runtime_destroyed');
|
|
165
168
|
if (leaseFence < state.acceptedLeaseFence)
|
|
166
169
|
throw new WorkspaceContextError('stale_lease');
|
|
170
|
+
if (readOnlyBasic || preservedLocalBasic) {
|
|
171
|
+
await state.journal.putWorkspace({ ...binding, directories });
|
|
172
|
+
return binding;
|
|
173
|
+
}
|
|
167
174
|
if (preparation?.mode === 'clone' && (preparation.transport?.kind === 'git-gateway-v2' || isLocalWorkspaceGitAuthMode(preparation.authMode))) {
|
|
168
175
|
if (!state.gitCredentials)
|
|
169
176
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
@@ -175,11 +182,12 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
175
182
|
}
|
|
176
183
|
}
|
|
177
184
|
try {
|
|
178
|
-
await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
|
|
185
|
+
await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), workspacePlatformSkillAccess(state, binding.workspaceId));
|
|
179
186
|
}
|
|
180
187
|
catch {
|
|
181
188
|
throw new WorkspaceContextError('workspace_skills_unavailable');
|
|
182
189
|
}
|
|
190
|
+
await writeWorkspaceCliContext(root, binding.workspaceId);
|
|
183
191
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
184
192
|
try {
|
|
185
193
|
await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
|