@playcraft/cli 0.1.10 → 0.1.12
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/utils/agent-api-client.js +26 -11
- package/dist/workspace-runtime/codex/app-server.js +250 -50
- package/dist/workspace-runtime/codex/transcripts.js +94 -0
- package/dist/workspace-runtime/previews/publish.js +18 -0
- package/dist/workspace-runtime/serve.js +12 -0
- package/dist/workspace-runtime/server/dispatch.js +121 -9
- package/dist/workspace-runtime/server/http.js +35 -0
- package/dist/workspace-runtime/workspaces/attachments.js +109 -0
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +79 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +114 -0
- package/dist/workspace-runtime/workspaces/empty-template.js +32 -0
- package/dist/workspace-runtime/workspaces/file-snapshot.js +163 -22
- package/dist/workspace-runtime/workspaces/files.js +9 -8
- package/dist/workspace-runtime/workspaces/git.js +93 -1
- package/dist/workspace-runtime/workspaces/prepare.js +12 -2
- package/dist/workspace-runtime/workspaces/registry.js +12 -0
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +47 -0
- package/dist/workspace-runtime/workspaces/skills.js +179 -0
- package/package.json +3 -3
- package/project-templates/empty/AGENTS.md +23 -0
- package/project-templates/empty/gitignore +31 -0
|
@@ -1,7 +1,77 @@
|
|
|
1
1
|
import { chmod, lstat, mkdir, readdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
-
import { fileSnapshotDigest, fileSnapshotManifestKey,
|
|
3
|
+
import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotContentKey, nativeTranscriptKey, fileSnapshotManifestSchema, hashSnapshotBytes, isDisposableBackupPath, isWorkspaceAttachmentPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { UNPUSHED_BUNDLE, addBundleObjects, repositoryResolves } from './git.js';
|
|
4
5
|
const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
|
|
6
|
+
/** Build output, caches and scratch folders, at any depth: regenerated, so not kept in storage. */
|
|
7
|
+
const UNSTORED_DIRECTORY_NAMES = new Set([
|
|
8
|
+
'dist',
|
|
9
|
+
'dist-all',
|
|
10
|
+
'coverage',
|
|
11
|
+
'.vite',
|
|
12
|
+
'.cache',
|
|
13
|
+
'.turbo',
|
|
14
|
+
'.parcel-cache',
|
|
15
|
+
'.sass-cache',
|
|
16
|
+
'.next',
|
|
17
|
+
'.nuxt',
|
|
18
|
+
'.svelte-kit',
|
|
19
|
+
'tmp',
|
|
20
|
+
'.tmp',
|
|
21
|
+
'.temp',
|
|
22
|
+
]);
|
|
23
|
+
/** Scratch and cache files, at any depth. */
|
|
24
|
+
const UNSTORED_FILE = /(?:\.tmp|\.log|\.tsbuildinfo|^\.eslintcache)$/;
|
|
25
|
+
/**
|
|
26
|
+
* At the workspace root: the preview build's output, the builder's scratch folder, and skill
|
|
27
|
+
* artifacts the sandbox pulls from the registry by `.playcraft/skills.lock.json`.
|
|
28
|
+
*/
|
|
29
|
+
const UNSTORED_ROOT_DIRECTORIES = [
|
|
30
|
+
'.playcraft-preview-out/',
|
|
31
|
+
'.playcraft-temp/',
|
|
32
|
+
'.playcraft/skills/',
|
|
33
|
+
'ta-workspace/logs/',
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* Recordings, frames and reports the template's verify and record scripts write again on each run.
|
|
37
|
+
* The ring JSON evidence the build gate reads stays, as do the agent-edited profile and catalogs.
|
|
38
|
+
*/
|
|
39
|
+
const UNSTORED_RUN_OUTPUT = [
|
|
40
|
+
/^recordings\/(?:.+\.(?:mp4|webm)$|\.tmp-[^/]+\/|focus\/|clean\/)/,
|
|
41
|
+
/^validate\/(?:[^/]+\.md$|(?:ring\d+\/)?frames\/|ring\d+\/[^/]+\.mp4$)/,
|
|
42
|
+
];
|
|
43
|
+
/**
|
|
44
|
+
* Whether a path goes to checkpoint storage. Credentials, lock files, build output, caches and
|
|
45
|
+
* scratch files stay in the instance: its local copy keeps them, so restoring inside the instance
|
|
46
|
+
* does not remove them.
|
|
47
|
+
*/
|
|
48
|
+
export function keptInStorage(path) {
|
|
49
|
+
if (isWorkspaceAttachmentPath(path))
|
|
50
|
+
return true;
|
|
51
|
+
if (isDisposableBackupPath(path))
|
|
52
|
+
return false;
|
|
53
|
+
if (path.startsWith('.git/'))
|
|
54
|
+
return true;
|
|
55
|
+
if (UNSTORED_ROOT_DIRECTORIES.some((prefix) => path.startsWith(prefix)))
|
|
56
|
+
return false;
|
|
57
|
+
if (UNSTORED_RUN_OUTPUT.some((pattern) => pattern.test(path)))
|
|
58
|
+
return false;
|
|
59
|
+
const segments = path.split('/');
|
|
60
|
+
if (UNSTORED_FILE.test(segments[segments.length - 1]))
|
|
61
|
+
return false;
|
|
62
|
+
return !segments.slice(0, -1).some((segment) => UNSTORED_DIRECTORY_NAMES.has(segment));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Under `.git/`, what a `local` capture leaves to the remote: packs and their indexes, submodule
|
|
66
|
+
* repositories (`git submodule update` fetches them again), LFS objects, and template hooks.
|
|
67
|
+
*/
|
|
68
|
+
const REMOTE_GIT_DIRECTORIES = new Set([
|
|
69
|
+
'.git/objects/pack',
|
|
70
|
+
'.git/objects/info',
|
|
71
|
+
'.git/modules',
|
|
72
|
+
'.git/lfs',
|
|
73
|
+
'.git/hooks',
|
|
74
|
+
]);
|
|
5
75
|
const READ_ATTEMPTS = 3;
|
|
6
76
|
const realIo = {
|
|
7
77
|
async readdir(path) {
|
|
@@ -13,13 +83,28 @@ const realIo = {
|
|
|
13
83
|
readFile,
|
|
14
84
|
readlink,
|
|
15
85
|
};
|
|
16
|
-
export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo) {
|
|
86
|
+
export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo, options = {}) {
|
|
17
87
|
const entries = [];
|
|
18
88
|
const files = [];
|
|
19
89
|
const bodies = new Map();
|
|
20
90
|
const seen = new Set();
|
|
21
91
|
let status = 'complete';
|
|
22
|
-
|
|
92
|
+
const skip = (relativePath, name, directory) => {
|
|
93
|
+
if (name === '.' || name === '..')
|
|
94
|
+
return true;
|
|
95
|
+
if (directory && name === '.git')
|
|
96
|
+
return !(options.git && relativePath === '.git');
|
|
97
|
+
// A submodule's `.git` file points into `.git/modules`, which is not kept either.
|
|
98
|
+
if (!directory && name === '.git' && relativePath !== '.git')
|
|
99
|
+
return true;
|
|
100
|
+
if (directory && SKIP_DIRECTORY_NAMES.has(name))
|
|
101
|
+
return true;
|
|
102
|
+
if (directory && options.git === 'local' && REMOTE_GIT_DIRECTORIES.has(relativePath))
|
|
103
|
+
return true;
|
|
104
|
+
// Git's own lock files only exist while a command runs and must not come back with a restore.
|
|
105
|
+
return !directory && relativePath.startsWith('.git/') && name.endsWith('.lock');
|
|
106
|
+
};
|
|
107
|
+
await walk(root, root, io, revisions, entries, files, bodies, seen, skip, () => {
|
|
23
108
|
status = 'incomplete';
|
|
24
109
|
});
|
|
25
110
|
for (const path of [...previousPaths].sort((left, right) => left.localeCompare(right))) {
|
|
@@ -62,6 +147,12 @@ export async function materializeRemoteSnapshot(root, entries) {
|
|
|
62
147
|
if (hashSnapshotBytes(body) !== entry.contentHash)
|
|
63
148
|
throw new Error('snapshot_mismatch');
|
|
64
149
|
}
|
|
150
|
+
if (applicable.some((entry) => entry.path === '.git/HEAD')) {
|
|
151
|
+
const bundle = applicable.find((entry) => entry.path === UNPUSHED_BUNDLE && entry.kind === 'file');
|
|
152
|
+
if (bundle)
|
|
153
|
+
await addBundleObjects(rootReal, Buffer.from(bundle.contentBase64 ?? '', 'base64'));
|
|
154
|
+
await clearClonedGitMetadata(rootReal);
|
|
155
|
+
}
|
|
65
156
|
for (const entry of applicable) {
|
|
66
157
|
const target = resolve(rootReal, ...entry.path.split('/'));
|
|
67
158
|
const rel = relative(rootReal, target);
|
|
@@ -72,7 +163,7 @@ export async function materializeRemoteSnapshot(root, entries) {
|
|
|
72
163
|
await rm(target, { force: true });
|
|
73
164
|
continue;
|
|
74
165
|
}
|
|
75
|
-
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
166
|
+
await mkdir(dirname(target), { recursive: true, mode: isWorkspaceAttachmentPath(entry.path) ? 0o755 : 0o700 });
|
|
76
167
|
if (entry.kind === 'symlink') {
|
|
77
168
|
if (!entry.target)
|
|
78
169
|
throw new Error('snapshot_mismatch');
|
|
@@ -85,50 +176,100 @@ export async function materializeRemoteSnapshot(root, entries) {
|
|
|
85
176
|
if (entry.mode !== undefined)
|
|
86
177
|
await chmod(target, entry.mode);
|
|
87
178
|
}
|
|
88
|
-
let repositoryReady = false;
|
|
89
|
-
try {
|
|
90
|
-
const git = await lstat(join(rootReal, '.git'));
|
|
91
|
-
repositoryReady = git.isDirectory() && !git.isSymbolicLink();
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
repositoryReady = false;
|
|
95
|
-
}
|
|
96
179
|
return {
|
|
97
180
|
skipped,
|
|
98
181
|
directoryReady: true,
|
|
99
|
-
repositoryReady,
|
|
182
|
+
repositoryReady: await repositoryResolves(rootReal),
|
|
100
183
|
environmentReady: false,
|
|
101
184
|
};
|
|
102
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* The snapshot's `.git` replaces the one a new clone made, objects aside: a ref the clone wrote
|
|
188
|
+
* would otherwise outrank the snapshot's packed refs.
|
|
189
|
+
*/
|
|
190
|
+
async function clearClonedGitMetadata(rootReal) {
|
|
191
|
+
const gitDir = join(rootReal, '.git');
|
|
192
|
+
const info = await lstat(gitDir).catch(() => undefined);
|
|
193
|
+
if (!info?.isDirectory())
|
|
194
|
+
return;
|
|
195
|
+
for (const name of await readdir(gitDir)) {
|
|
196
|
+
if (name !== 'objects')
|
|
197
|
+
await rm(join(gitDir, name), { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Content goes to the workspace's shared objects, keyed by hash. A hash in `uploaded` was stored by
|
|
202
|
+
* this process before and is not sent again; hashes stored now are added to it. The manifest still
|
|
203
|
+
* lists every stored file, so each snapshot restores on its own. Paths not `keptInStorage` are left
|
|
204
|
+
* out of both the objects and the manifest.
|
|
205
|
+
*/
|
|
103
206
|
export async function publishFileSnapshot(input) {
|
|
104
|
-
|
|
207
|
+
const stored = new Set();
|
|
208
|
+
const entries = input.tree.entries.filter((entry) => keptInStorage(entry.path));
|
|
209
|
+
for (const entry of entries) {
|
|
105
210
|
if (entry.kind !== 'file' || !entry.contentHash)
|
|
106
211
|
continue;
|
|
212
|
+
if (input.uploaded?.has(entry.contentHash) || stored.has(entry.contentHash))
|
|
213
|
+
continue;
|
|
107
214
|
const body = input.tree.bodies.get(entry.path);
|
|
108
215
|
if (!body || hashSnapshotBytes(body) !== entry.contentHash)
|
|
109
216
|
throw new Error('snapshot_body_missing');
|
|
110
|
-
await putObjectBytes(input.baseUrl,
|
|
217
|
+
await putObjectBytes(input.baseUrl, fileSnapshotContentKey(input.workspaceId, entry.contentHash), body, 'cli', input.headers);
|
|
218
|
+
stored.add(entry.contentHash);
|
|
219
|
+
}
|
|
220
|
+
const native = [];
|
|
221
|
+
for (const transcript of input.native ?? []) {
|
|
222
|
+
const contentHash = hashSnapshotBytes(transcript.body);
|
|
223
|
+
if (input.uploadedTranscripts?.get(transcript.path) !== contentHash) {
|
|
224
|
+
await putObjectBytes(input.baseUrl, nativeTranscriptKey(input.workspaceId, transcript.path), transcript.body, 'cli', input.headers);
|
|
225
|
+
input.uploadedTranscripts?.set(transcript.path, contentHash);
|
|
226
|
+
}
|
|
227
|
+
native.push({
|
|
228
|
+
sessionId: transcript.sessionId,
|
|
229
|
+
threadId: transcript.threadId,
|
|
230
|
+
path: transcript.path,
|
|
231
|
+
contentHash,
|
|
232
|
+
size: transcript.body.byteLength,
|
|
233
|
+
capturedAt: transcript.capturedAt,
|
|
234
|
+
});
|
|
111
235
|
}
|
|
112
|
-
const digest = fileSnapshotDigest(
|
|
236
|
+
const digest = fileSnapshotDigest(entries);
|
|
113
237
|
const manifest = fileSnapshotManifestSchema.parse({
|
|
114
238
|
snapshotId: input.snapshotId,
|
|
115
239
|
workspaceId: input.workspaceId,
|
|
116
240
|
generation: input.generation,
|
|
117
241
|
status: input.tree.status,
|
|
118
|
-
entries
|
|
242
|
+
entries,
|
|
119
243
|
digest,
|
|
244
|
+
objectLayout: 'workspace',
|
|
245
|
+
...(native.length > 0 ? { native } : {}),
|
|
120
246
|
});
|
|
121
247
|
const manifestKey = fileSnapshotManifestKey(input.workspaceId, input.generation, input.snapshotId);
|
|
122
|
-
await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli');
|
|
248
|
+
await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli', input.headers);
|
|
249
|
+
for (const hash of stored)
|
|
250
|
+
input.uploaded?.add(hash);
|
|
123
251
|
return { manifestKey, checksum: digest, status: input.tree.status };
|
|
124
252
|
}
|
|
125
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Digest of what is on disk now, without the deletions recorded against the previous snapshot.
|
|
255
|
+
* Transcripts count too: a turn that only talked still changes what a restore can resume.
|
|
256
|
+
*/
|
|
257
|
+
export function liveTreeDigest(tree, native = []) {
|
|
258
|
+
const files = fileSnapshotDigest(tree.entries.filter((entry) => entry.kind !== 'deleted'));
|
|
259
|
+
if (native.length === 0)
|
|
260
|
+
return files;
|
|
261
|
+
const transcripts = native
|
|
262
|
+
.map((item) => `${item.sessionId}:${item.threadId}:${item.path}:${hashSnapshotBytes(item.body)}`)
|
|
263
|
+
.sort();
|
|
264
|
+
return hashSnapshotBytes(Buffer.from([files, ...transcripts].join('\n')));
|
|
265
|
+
}
|
|
266
|
+
async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, skip, markIncomplete) {
|
|
126
267
|
const listed = await io.readdir(current);
|
|
127
268
|
for (const entry of listed) {
|
|
128
|
-
if (entry.name === '.' || entry.name === '..' || SKIP_DIRECTORY_NAMES.has(entry.name))
|
|
129
|
-
continue;
|
|
130
269
|
const child = join(current, entry.name);
|
|
131
270
|
const relativePath = toPosixRelative(rootReal, child);
|
|
271
|
+
if (skip(relativePath, entry.name, entry.isDirectory()))
|
|
272
|
+
continue;
|
|
132
273
|
if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
|
|
133
274
|
markIncomplete();
|
|
134
275
|
continue;
|
|
@@ -145,7 +286,7 @@ async function walk(rootReal, current, io, revisions, entries, files, bodies, se
|
|
|
145
286
|
continue;
|
|
146
287
|
}
|
|
147
288
|
if (entry.isDirectory()) {
|
|
148
|
-
await walk(rootReal, child, io, revisions, entries, files, bodies, seen, markIncomplete);
|
|
289
|
+
await walk(rootReal, child, io, revisions, entries, files, bodies, seen, skip, markIncomplete);
|
|
149
290
|
continue;
|
|
150
291
|
}
|
|
151
292
|
if (!entry.isFile())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
-
import { hashWorkspaceFileContent, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { hashWorkspaceFileContent, hashSnapshotBytes, isWorkspaceAttachmentPath, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
|
|
5
5
|
export class InvalidWorkspacePathError extends Error {
|
|
6
6
|
code = 'invalid_workspace_path';
|
|
@@ -71,11 +71,11 @@ export async function snapshotWorkspaceFiles(root, snapshotDir, revisions) {
|
|
|
71
71
|
await rm(snapshotDir, { recursive: true, force: true });
|
|
72
72
|
const records = [];
|
|
73
73
|
for (const file of listed.files) {
|
|
74
|
-
const content = await
|
|
74
|
+
const content = await readFile(await resolveWorkspaceFile(root, file.path));
|
|
75
75
|
const target = join(snapshotDir, ...file.path.split('/'));
|
|
76
76
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
77
|
-
await writeFile(target, content
|
|
78
|
-
records.push({ path: file.path, contentHash:
|
|
77
|
+
await writeFile(target, content, { mode: 0o600 });
|
|
78
|
+
records.push({ path: file.path, contentHash: hashSnapshotBytes(content), revision: file.revision });
|
|
79
79
|
}
|
|
80
80
|
return records;
|
|
81
81
|
}
|
|
@@ -83,16 +83,17 @@ export async function restoreWorkspaceFiles(root, snapshotDir, files) {
|
|
|
83
83
|
const rootReal = await ensureWorkspaceRoot(root);
|
|
84
84
|
const keep = new Set(files.map((item) => item.path));
|
|
85
85
|
for (const relativePath of await walkWorkspaceFiles(rootReal, rootReal)) {
|
|
86
|
-
if (!keep.has(relativePath)) {
|
|
86
|
+
if (!keep.has(relativePath) && !isWorkspaceAttachmentPath(relativePath)) {
|
|
87
87
|
await rm(join(rootReal, ...relativePath.split('/')), { force: true });
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
const next = {};
|
|
91
91
|
for (const file of files) {
|
|
92
92
|
const source = join(snapshotDir, ...file.path.split('/'));
|
|
93
|
-
const content = await readFile(source
|
|
94
|
-
await
|
|
95
|
-
|
|
93
|
+
const content = await readFile(source);
|
|
94
|
+
const target = await resolveWorkspaceFile(root, file.path);
|
|
95
|
+
await writeFile(target, content, { mode: isWorkspaceAttachmentPath(file.path) ? 0o644 : 0o600 });
|
|
96
|
+
next[file.path] = { revision: file.revision, contentHash: hashSnapshotBytes(content) };
|
|
96
97
|
}
|
|
97
98
|
return next;
|
|
98
99
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { excludeFromGit } from './dependencies.js';
|
|
3
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
6
|
import { WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -245,6 +246,96 @@ export async function pushWorkspaceGit(root, boundBranch) {
|
|
|
245
246
|
catch { /* Preserve uncertainty; never replay a write automatically. */ }
|
|
246
247
|
throw new GitWorkspaceError('git_push_outcome_unknown', 'git_push_outcome_unknown');
|
|
247
248
|
}
|
|
249
|
+
const UNPUSHED_DIRECTORY = '.git/playcraft';
|
|
250
|
+
export const UNPUSHED_BUNDLE = `${UNPUSHED_DIRECTORY}/unpushed.bundle`;
|
|
251
|
+
const UNPUSHED_TIPS = `${UNPUSHED_DIRECTORY}/unpushed.tips`;
|
|
252
|
+
/**
|
|
253
|
+
* Keeps a bundle of the commits on local branches and the latest stash that no remote-tracking
|
|
254
|
+
* branch holds, so a backup without the packs still has them. It is rewritten only when one of
|
|
255
|
+
* those refs moves, and removed when nothing is unpushed. Without remote-tracking refs there is no
|
|
256
|
+
* baseline, and the whole history is left out rather than bundled.
|
|
257
|
+
*/
|
|
258
|
+
export async function bundleUnpushedCommits(root) {
|
|
259
|
+
const rootReal = await realpath(root);
|
|
260
|
+
const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
|
|
261
|
+
if (!marker?.isDirectory())
|
|
262
|
+
return;
|
|
263
|
+
const tips = await git(rootReal, [
|
|
264
|
+
'for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads', 'refs/remotes', 'refs/stash',
|
|
265
|
+
]);
|
|
266
|
+
const tipsPath = join(rootReal, UNPUSHED_TIPS);
|
|
267
|
+
if ((await readFile(tipsPath, 'utf8').catch(() => undefined)) === tips)
|
|
268
|
+
return;
|
|
269
|
+
const refs = tips.split('\n').map((line) => line.split(' ')[1]).filter((ref) => Boolean(ref));
|
|
270
|
+
const local = ['--branches', ...(refs.includes('refs/stash') ? ['refs/stash'] : [])];
|
|
271
|
+
const unpushed = refs.some((ref) => ref.startsWith('refs/remotes/'))
|
|
272
|
+
? Number.parseInt((await git(rootReal, ['rev-list', '--count', ...local, '--not', '--remotes'], 60000)).trim(), 10)
|
|
273
|
+
: 0;
|
|
274
|
+
const bundlePath = join(rootReal, UNPUSHED_BUNDLE);
|
|
275
|
+
await mkdir(join(rootReal, UNPUSHED_DIRECTORY), { recursive: true, mode: 0o700 });
|
|
276
|
+
if (unpushed > 0) {
|
|
277
|
+
// The `.lock` name keeps a half-written bundle out of any snapshot.
|
|
278
|
+
const temp = `${bundlePath}.lock`;
|
|
279
|
+
try {
|
|
280
|
+
await git(rootReal, ['bundle', 'create', temp, ...local, '--not', '--remotes'], 120000);
|
|
281
|
+
await rename(temp, bundlePath);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
await rm(temp, { force: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
await rm(bundlePath, { force: true });
|
|
289
|
+
}
|
|
290
|
+
await writeFile(tipsPath, tips, { mode: 0o600 });
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Adds a bundle's objects to the repository. `unbundle` walks history from the current refs, so this
|
|
294
|
+
* runs on the fresh clone before the snapshot's refs, which point at these very commits, replace its
|
|
295
|
+
* own. It fails when the remote lost the commits the bundle was built on; `repositoryResolves` then
|
|
296
|
+
* reports what is missing.
|
|
297
|
+
*/
|
|
298
|
+
export async function addBundleObjects(root, body) {
|
|
299
|
+
const rootReal = await realpath(root);
|
|
300
|
+
const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
|
|
301
|
+
if (!marker?.isDirectory())
|
|
302
|
+
return;
|
|
303
|
+
const temp = join(rootReal, '.git', 'playcraft-restore.bundle.lock');
|
|
304
|
+
try {
|
|
305
|
+
await writeFile(temp, body, { mode: 0o600 });
|
|
306
|
+
await gitAllowFail(rootReal, ['bundle', 'unbundle', temp], 120000);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* Reported by repositoryResolves. */
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
await rm(temp, { force: true });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Whether HEAD and the index resolve to objects the repository holds. */
|
|
316
|
+
export async function repositoryResolves(root) {
|
|
317
|
+
const rootReal = await realpath(root);
|
|
318
|
+
const marker = await lstat(join(rootReal, '.git')).catch(() => undefined);
|
|
319
|
+
if (!marker?.isDirectory())
|
|
320
|
+
return false;
|
|
321
|
+
try {
|
|
322
|
+
// Snapshots hold files only; Git does not recognise a repository whose refs folder is gone.
|
|
323
|
+
for (const directory of ['refs/heads', 'refs/tags', 'objects']) {
|
|
324
|
+
await mkdir(join(rootReal, '.git', directory), { recursive: true, mode: 0o755 });
|
|
325
|
+
}
|
|
326
|
+
const head = await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{tree}']);
|
|
327
|
+
if (head.code === 0) {
|
|
328
|
+
const index = await gitAllowFail(rootReal, ['diff-index', '--cached', '--quiet', 'HEAD'], 60000);
|
|
329
|
+
return index.code === 0 || index.code === 1;
|
|
330
|
+
}
|
|
331
|
+
// Before the first commit there is no branch to resolve.
|
|
332
|
+
const branches = await gitAllowFail(rootReal, ['for-each-ref', '--count=1', 'refs/heads']);
|
|
333
|
+
return branches.code === 0 && branches.stdout.trim() === '';
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
248
339
|
export async function ensureWorkspaceGit(root) {
|
|
249
340
|
const rootReal = await realpath(root);
|
|
250
341
|
if (!(await gitMarkerExists(rootReal))) {
|
|
@@ -256,6 +347,7 @@ export async function ensureWorkspaceGit(root) {
|
|
|
256
347
|
await git(rootReal, ['config', 'user.email', 'workspace@playcraft.local']);
|
|
257
348
|
}
|
|
258
349
|
await assertWorkspaceGitRoot(rootReal);
|
|
350
|
+
await excludeFromGit(rootReal, '/.tmp/user_uploads/');
|
|
259
351
|
return rootReal;
|
|
260
352
|
}
|
|
261
353
|
async function gitMarkerExists(rootReal) {
|
|
@@ -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, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { WorkspaceContextError } from './context-error.js';
|
|
7
|
+
import { scaffoldEmptyWorkspace } from './empty-template.js';
|
|
7
8
|
/** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
|
|
8
9
|
export async function prepareWorkspaceCheckout(state, binding, input, leaseFence) {
|
|
9
10
|
const preparation = workspacePreparationSchema.safeParse(input);
|
|
@@ -82,22 +83,31 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
82
83
|
try {
|
|
83
84
|
if (standalone) {
|
|
84
85
|
await runGit(['init', '--initial-branch=main', staging], env, deadline);
|
|
86
|
+
await scaffoldEmptyWorkspace(staging, join(state.config.stateDir, 'skill-artifacts'));
|
|
85
87
|
}
|
|
86
88
|
else {
|
|
89
|
+
const fork = binding.pendingFork;
|
|
90
|
+
const cloneBranch = fork?.sourceBranchName ?? binding.branchName;
|
|
87
91
|
await runGit(['check-ref-format', '--branch', binding.branchName], env, deadline);
|
|
92
|
+
await runGit(['check-ref-format', '--branch', cloneBranch], env, deadline);
|
|
88
93
|
await runGit([
|
|
89
94
|
'clone',
|
|
90
95
|
'--no-checkout',
|
|
91
96
|
'--single-branch',
|
|
92
97
|
'--branch',
|
|
93
|
-
|
|
98
|
+
cloneBranch,
|
|
94
99
|
'--',
|
|
95
100
|
binding.repositoryUrl,
|
|
96
101
|
staging,
|
|
97
102
|
], env, deadline);
|
|
98
103
|
// Checkout never receives the temporary credential and never invokes repository hooks or global filters.
|
|
99
104
|
clearCloneSecrets(env);
|
|
100
|
-
|
|
105
|
+
if (fork) {
|
|
106
|
+
await runGit(['-C', staging, 'checkout', '-b', binding.branchName, fork.sourceCommit], env, deadline);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
await runGit(['-C', staging, 'checkout', '--force', binding.branchName], env, deadline);
|
|
110
|
+
}
|
|
101
111
|
}
|
|
102
112
|
assertCurrent();
|
|
103
113
|
receipt.status = 'ready';
|
|
@@ -6,6 +6,8 @@ import { isLocalWorkspaceGitAuthMode, workspaceRegistrationSchema, } from '@play
|
|
|
6
6
|
import { prepareWorkspaceCheckout } from './prepare.js';
|
|
7
7
|
import { WorkspaceContextError } from './context-error.js';
|
|
8
8
|
import { scheduleDependencyInstall } from './dependencies.js';
|
|
9
|
+
import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
|
|
10
|
+
import { loadWorkspaceSkillArtifact } from './skill-artifact-loader.js';
|
|
9
11
|
export { WorkspaceContextError } from './context-error.js';
|
|
10
12
|
const exec = promisify(execFile);
|
|
11
13
|
export function revisionScope(state, workspaceId) {
|
|
@@ -121,7 +123,17 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
121
123
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
122
124
|
}
|
|
123
125
|
}
|
|
126
|
+
try {
|
|
127
|
+
await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new WorkspaceContextError('workspace_skills_unavailable');
|
|
131
|
+
}
|
|
124
132
|
await state.journal.putWorkspace(binding);
|
|
133
|
+
try {
|
|
134
|
+
await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
|
|
135
|
+
}
|
|
136
|
+
catch { /* Skills reads and native turns diagnose missing artifacts; files and source repair remain available. */ }
|
|
125
137
|
// Runs in the background; every registration rechecks it, so reopening retries a failed install.
|
|
126
138
|
void scheduleDependencyInstall(state, binding.workspaceId, root);
|
|
127
139
|
return binding;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { workspaceRoot } from './registry.js';
|
|
3
|
+
const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024;
|
|
4
|
+
/** Requests an exact locked artifact using control identity; no storage credentials reach Codex. */
|
|
5
|
+
export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
|
|
6
|
+
await workspaceRoot(state, workspaceId);
|
|
7
|
+
const epoch = state.runtimeEpoch;
|
|
8
|
+
if (state.journal.destroyed())
|
|
9
|
+
throw new Error('workspace_skills_unavailable');
|
|
10
|
+
const endpoint = new URL('/runtime/internal/skill-artifacts', state.config.callbackUrl);
|
|
11
|
+
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
12
|
+
throw new Error('workspace_skills_callback_insecure');
|
|
13
|
+
const response = await fetch(endpoint, {
|
|
14
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
|
|
15
|
+
headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
|
|
16
|
+
body: JSON.stringify({ runtimeId: state.config.runtimeId, generation: state.config.generation,
|
|
17
|
+
runtimeEpoch: epoch, workspaceId, entryKey: entry.entryKey, revision: entry.revision }),
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok || !response.body)
|
|
20
|
+
throw new Error('workspace_skills_artifact_unavailable');
|
|
21
|
+
const reader = response.body.getReader(), chunks = [];
|
|
22
|
+
let total = 0;
|
|
23
|
+
try {
|
|
24
|
+
while (true) {
|
|
25
|
+
const chunk = await reader.read();
|
|
26
|
+
if (chunk.done)
|
|
27
|
+
break;
|
|
28
|
+
total += chunk.value.byteLength;
|
|
29
|
+
if (total > Math.ceil(MAX_ARTIFACT_BYTES * 4 / 3) + 4096)
|
|
30
|
+
throw new Error('workspace_skills_artifact_too_large');
|
|
31
|
+
chunks.push(chunk.value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await reader.cancel().catch(() => undefined);
|
|
36
|
+
}
|
|
37
|
+
const result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
38
|
+
if (typeof result.content !== 'string' || result.artifactHash !== entry.revision.artifactHash
|
|
39
|
+
|| result.content.length > Math.ceil(MAX_ARTIFACT_BYTES * 4 / 3) || result.content.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(result.content))
|
|
40
|
+
throw new Error('workspace_skills_digest_mismatch');
|
|
41
|
+
const archive = Buffer.from(result.content, 'base64');
|
|
42
|
+
if (archive.byteLength > MAX_ARTIFACT_BYTES || archive.toString('base64') !== result.content || createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
|
|
43
|
+
throw new Error('workspace_skills_digest_mismatch');
|
|
44
|
+
if (state.journal.destroyed() || epoch !== state.runtimeEpoch)
|
|
45
|
+
throw new Error('workspace_skills_unavailable');
|
|
46
|
+
return archive;
|
|
47
|
+
}
|