@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.
@@ -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, fileSnapshotManifestSchema, fileSnapshotObjectKey, hashSnapshotBytes, isDisposableBackupPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
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
- await walk(root, root, io, revisions, entries, files, bodies, seen, () => {
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
- for (const entry of input.tree.entries) {
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, fileSnapshotObjectKey(input.workspaceId, input.generation, input.snapshotId, entry.contentHash), body, 'cli');
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(input.tree.entries);
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: input.tree.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
- async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, markIncomplete) {
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 readWorkspaceFile(root, file.path, revisions);
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.content, { mode: 0o600 });
78
- records.push({ path: file.path, contentHash: file.contentHash, revision: file.revision });
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, 'utf8');
94
- await writeWorkspaceFile(root, file.path, content);
95
- next[file.path] = { revision: file.revision, contentHash: hashWorkspaceFileContent(content) };
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 { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
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
- binding.branchName,
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
- await runGit(['-C', staging, 'checkout', '--force', binding.branchName], env, deadline);
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
+ }