@playcraft/cli 0.1.58-beta → 0.1.60-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/cli-root-help.js +1 -0
  2. package/dist/commands/references.js +55 -0
  3. package/dist/index.js +21 -3
  4. package/dist/references/client.js +69 -0
  5. package/dist/references/messages.js +57 -0
  6. package/dist/references/protocol.js +32 -0
  7. package/dist/utils/agent-generation-provenance.js +1 -1
  8. package/dist/workspace-runtime/batch/control.js +3 -3
  9. package/dist/workspace-runtime/batch/deliver.js +4 -3
  10. package/dist/workspace-runtime/batch/generate.js +4 -3
  11. package/dist/workspace-runtime/batch/install-log.js +49 -0
  12. package/dist/workspace-runtime/batch/policy.js +71 -22
  13. package/dist/workspace-runtime/batch/prepare.js +29 -13
  14. package/dist/workspace-runtime/batch/processes.js +86 -14
  15. package/dist/workspace-runtime/batch/writer-proof.js +2 -2
  16. package/dist/workspace-runtime/codex/app-server.js +305 -21
  17. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  18. package/dist/workspace-runtime/codex/launcher-client.js +34 -24
  19. package/dist/workspace-runtime/codex/loopback.js +2 -0
  20. package/dist/workspace-runtime/codex/model-broker.js +48 -8
  21. package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
  22. package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
  23. package/dist/workspace-runtime/serve.js +4 -1
  24. package/dist/workspace-runtime/server/directory-access.js +1 -1
  25. package/dist/workspace-runtime/server/dispatch.js +14 -6
  26. package/dist/workspace-runtime/server/http.js +43 -2
  27. package/dist/workspace-runtime/server/terminal-host.js +82 -1
  28. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  29. package/dist/workspace-runtime/workspaces/empty-template.js +2 -0
  30. package/dist/workspace-runtime/workspaces/registry.js +44 -3
  31. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
  32. package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
  33. package/dist/workspace-runtime/workspaces/skills.js +3 -1
  34. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  35. package/package.json +3 -3
  36. package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  37. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
  38. package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  39. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  40. package/project-templates/empty/AGENTS.md +9 -0
  41. package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  42. package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
@@ -41,6 +41,7 @@ import { workspaceArtifactContentQuerySchema } from '@playcraft/common/cloud-wor
41
41
  import { searchWorkspaceFiles } from '../workspaces/files-search.js';
42
42
  import { serveWorkspaceArchive } from '../workspaces/archive.js';
43
43
  import { handleWorkspaceTransfer } from './workspace-transfer.js';
44
+ import { handleWorkspaceStorage } from './workspace-storage.js';
44
45
  import { serveSessionImage } from '../workspaces/session-image.js';
45
46
  import { restoreNativeSession, SessionRestoreError } from './session-restore.js';
46
47
  import { handleAssetTransferRequest } from './asset-transfers.js';
@@ -51,7 +52,8 @@ export async function handleRuntimeRequest(state, request, response) {
51
52
  const workspaceId = url.searchParams.get('workspaceId');
52
53
  // Body-scoped operations, members and terminal control take their own lifetime-aware leases.
53
54
  const ownLease = url.pathname.startsWith('/runtime/operations') || url.pathname.startsWith('/runtime/terminals/') ||
54
- url.pathname.startsWith('/runtime/batch-') || /^\/runtime\/sessions\/[^/]+\/ui-results$/.test(url.pathname);
55
+ url.pathname.startsWith('/runtime/batch-') || /^\/runtime\/sessions\/[^/]+\/ui-results$/.test(url.pathname) ||
56
+ /^\/runtime\/sessions\/[^/]+\/prediction$/.test(url.pathname);
55
57
  if (authorizeRuntime(state, request) === 'control' && workspaceId &&
56
58
  (!['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET') || url.pathname === '/runtime/git-bundle') && !ownLease) {
57
59
  try {
@@ -82,6 +84,8 @@ async function dispatchRuntimeRequest(state, request, response) {
82
84
  return;
83
85
  }
84
86
  try {
87
+ if (await handleWorkspaceStorage(state, request, response, url))
88
+ return;
85
89
  const transfer = await handleAssetTransferRequest(state, request, url);
86
90
  if (transfer) {
87
91
  writeJson(response, transfer.status, transfer.body);
@@ -128,6 +132,9 @@ async function dispatchRuntimeRequest(state, request, response) {
128
132
  instanceToken: state.instanceToken,
129
133
  permissionsVersion: 1,
130
134
  composerContextVersion: 1,
135
+ scheduledTaskIdentityVersion: 1,
136
+ scheduledTaskCancellationVersion: 1,
137
+ ...(state.native?.scheduledTaskProposalSource ? { scheduledTaskProposalVersion: 1 } : {}),
131
138
  parameterAgentVersion: 1,
132
139
  directoryConcurrencyVersion: 1,
133
140
  parameterInitializationVersion: 1,
@@ -136,6 +143,7 @@ async function dispatchRuntimeRequest(state, request, response) {
136
143
  ...(state.native?.workspaceUi ? { workspaceUiVersion: 1 } : {}),
137
144
  ...(state.native?.startReview && state.native?.steer ? { turnControlVersion: 1 } : {}),
138
145
  ...(state.native?.startCompact ? { compactionVersion: 1 } : {}),
146
+ ...(state.native?.startPrediction ? { predictionVersion: 1 } : {}),
139
147
  sourceReadToken: state.journal.sourceReadToken(),
140
148
  workspaceMode: state.config.workspaceMode,
141
149
  executionGroup: {
@@ -208,6 +216,27 @@ async function dispatchRuntimeRequest(state, request, response) {
208
216
  writeJson(response, result.outcome === 'accepted' ? 200 : 409, result);
209
217
  return;
210
218
  }
219
+ const predictionRoute = /^\/runtime\/sessions\/([^/]+)\/prediction$/.exec(url.pathname);
220
+ if (request.method === 'POST' && predictionRoute) {
221
+ if (writeDestroyed(state, response))
222
+ return;
223
+ const sessionId = decodeURIComponent(predictionRoute[1]);
224
+ const session = state.journal.getSession(sessionId);
225
+ if (!session || productionSessionAttemptId(sessionId) || session.workspaceId !== url.searchParams.get('workspaceId')
226
+ || (session.directoryName ?? 'basic') !== directoryName) {
227
+ writeJson(response, 404, { error: 'session_not_found' });
228
+ return;
229
+ }
230
+ if (!state.native?.startPrediction) {
231
+ writeJson(response, 409, { error: 'prediction_unavailable' });
232
+ return;
233
+ }
234
+ // Best-effort and read-only: no instruction/journal reservation, and the ephemeral
235
+ // fork never becomes a user-visible thread.
236
+ const result = await state.native.startPrediction({ sessionId, threadId: session.threadId });
237
+ writeJson(response, result.outcome === 'ok' ? 200 : 409, result);
238
+ return;
239
+ }
211
240
  if (request.method === 'POST' && url.pathname === '/runtime/workspaces/register') {
212
241
  if (writeDestroyed(state, response))
213
242
  return;
@@ -784,6 +813,16 @@ async function dispatchRuntimeRequest(state, request, response) {
784
813
  writeJson(response, 200, operation);
785
814
  return;
786
815
  }
816
+ if (request.method === 'POST' && url.pathname === '/runtime/scheduled-task-proposals/source') {
817
+ const body = await readJson(request);
818
+ if (!body || Object.keys(body).some(key => !['bridge', 'threadId', 'turnId', 'toolCallId'].includes(key))
819
+ || !['bridge', 'threadId', 'turnId', 'toolCallId'].every(key => typeof body[key] === 'string' && Boolean(body[key]))) {
820
+ writeJson(response, 400, { valid: false });
821
+ return;
822
+ }
823
+ writeJson(response, 200, { valid: state.native?.scheduledTaskProposalSource?.(body) === true });
824
+ return;
825
+ }
787
826
  if (request.method === 'POST' && url.pathname === '/runtime/operations') {
788
827
  // Track creation before accepting it, so a new unconnected terminal is known not to have spawned.
789
828
  terminalHost(state);
@@ -949,7 +988,9 @@ async function acceptOperation(state, request, response) {
949
988
  ? { snapshot: parsed.data.remixReferenceSnapshot }
950
989
  : parsed.data.kind === 'asset.library.import'
951
990
  ? { selection: parsed.data.payload, snapshot: parsed.data.libraryAssetImports }
952
- : parsed.data.payload;
991
+ : parsed.data.kind === 'session.turn' && parsed.data.scheduledTaskProposalBridge
992
+ ? { ...asRecord(parsed.data.payload), scheduledTaskProposalBridge: parsed.data.scheduledTaskProposalBridge }
993
+ : parsed.data.payload;
953
994
  const accept = async () => {
954
995
  const result = await acceptOperationCore(state, parsed.data, {
955
996
  leaseFence: incomingFence,
@@ -1,6 +1,7 @@
1
1
  import { batchDirectoryHeld } from '../batch/occupancy.js';
2
2
  import { createRequire } from 'node:module';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { readdirSync, readFileSync } from 'node:fs';
4
5
  import { lstat } from 'node:fs/promises';
5
6
  import { StringDecoder } from 'node:string_decoder';
6
7
  import { WebSocketServer } from 'ws';
@@ -12,11 +13,19 @@ import { stopBackgroundWriter } from '../processes/managed-writes.js';
12
13
  import { controlTokenMatches, extractControlToken } from './auth.js';
13
14
  import { cancelPendingDirectoryAccess, holdDirectoryAccess } from './directory-access.js';
14
15
  const require = createRequire(import.meta.url);
16
+ /** A shell nobody views, silent this long and running nothing besides itself, is closed. */
17
+ const IDLE_RECLAIM_MS = 30 * 60_000;
18
+ const IDLE_SWEEP_MS = 120_000;
15
19
  const commands = new WeakMap();
20
+ const sessionProbes = new WeakMap();
16
21
  const hosts = new WeakMap();
17
22
  export function setTerminalCommand(state, command) {
18
23
  commands.set(state, command);
19
24
  }
25
+ /** Replaces the /proc session scan, for platforms without /proc. */
26
+ export function setTerminalSessionProbe(state, probe) {
27
+ sessionProbes.set(state, probe);
28
+ }
20
29
  export function terminalHost(state) {
21
30
  let host = hosts.get(state);
22
31
  if (!host) {
@@ -25,6 +34,20 @@ export function terminalHost(state) {
25
34
  }
26
35
  return host;
27
36
  }
37
+ /**
38
+ * The supervisor advances the runtime epoch only after the launcher confirms that every process it
39
+ * started, terminals included, has exited. A terminal from an earlier epoch is gone whatever its record says.
40
+ */
41
+ export async function retireStoppedTerminals(journal, runtimeEpoch, previousEpoch) {
42
+ if (previousEpoch === undefined || runtimeEpoch <= previousEpoch)
43
+ return;
44
+ const destroyedAt = new Date().toISOString();
45
+ for (const terminal of journal.listTerminals()) {
46
+ if (terminal.status === 'destroyed' || (terminal.runtimeEpoch ?? 0) >= runtimeEpoch)
47
+ continue;
48
+ await journal.putTerminal({ ...terminal, status: 'destroyed', destroyedAt });
49
+ }
50
+ }
28
51
  export function disposeTerminalHost(state) {
29
52
  hosts.get(state)?.dispose();
30
53
  hosts.delete(state);
@@ -41,10 +64,14 @@ export class TerminalHost {
41
64
  confirmed = new Set();
42
65
  directoryReleases = new Map();
43
66
  sockets = new WebSocketServer({ noServer: true });
67
+ idleSweep;
44
68
  constructor(state) {
45
69
  this.state = state;
70
+ this.idleSweep = setInterval(() => void this.reclaimIdle(), IDLE_SWEEP_MS);
71
+ this.idleSweep.unref?.();
46
72
  // An absent in-memory session cannot prove a restored terminal's old process group stopped.
47
- this.restored = new Set((state.journal?.listTerminals?.() ?? []).map(terminal => terminal.terminalId));
73
+ this.restored = new Set((state.journal?.listTerminals?.() ?? [])
74
+ .filter(terminal => terminal.status !== 'destroyed').map(terminal => terminal.terminalId));
48
75
  }
49
76
  async handleUpgrade(request, socket, head) {
50
77
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
@@ -152,7 +179,28 @@ export class TerminalHost {
152
179
  session.listeners.clear();
153
180
  void session.stop().then(() => this.exited(terminalId, session, null)).catch(() => undefined);
154
181
  }
182
+ /** Closes shells with no browser, no input or output for the idle window, and nothing else running. */
183
+ async reclaimIdle(now = Date.now()) {
184
+ const reclaimed = [];
185
+ for (const [terminalId, session] of [...this.sessions]) {
186
+ if (session.exited || session.listeners.size > 0 || this.closing.has(terminalId)
187
+ || now - session.lastActivity < IDLE_RECLAIM_MS)
188
+ continue;
189
+ // A background job keeps the shell; a session that cannot be inspected is kept too.
190
+ if (!session.pid || (sessionProbes.get(this.state) ?? runsOtherProcesses)(session.pid) !== false)
191
+ continue;
192
+ try {
193
+ await this.destroy(terminalId);
194
+ reclaimed.push(terminalId);
195
+ }
196
+ catch {
197
+ // The next sweep tries again.
198
+ }
199
+ }
200
+ return reclaimed;
201
+ }
155
202
  dispose() {
203
+ clearInterval(this.idleSweep);
156
204
  this.disposed = true;
157
205
  for (const terminalId of [...this.sessions.keys()])
158
206
  this.revoke(terminalId, 'runtime_destroyed');
@@ -199,6 +247,7 @@ export class TerminalHost {
199
247
  throw new Error('pty_unavailable');
200
248
  }
201
249
  const session = { terminalId: terminal.terminalId, streamId: randomUUID(), log: createTerminalOutputLog(), listeners: new Set(),
250
+ pid: child.pid ?? 0, lastActivity: Date.now(),
202
251
  stop: () => child.stop(),
203
252
  pty: { write: (data) => { child.stdin?.write(data); }, resize: child.resize,
204
253
  kill: () => { void child.stop().catch(() => undefined); } } };
@@ -233,6 +282,8 @@ export class TerminalHost {
233
282
  cwd,
234
283
  env: shellEnv(command.file, cwd),
235
284
  }),
285
+ pid: 0,
286
+ lastActivity: Date.now(),
236
287
  stop: async () => {
237
288
  if (!didExit)
238
289
  session.pty.kill();
@@ -250,6 +301,7 @@ export class TerminalHost {
250
301
  },
251
302
  };
252
303
  const local = session.pty;
304
+ session.pid = local.pid;
253
305
  local.onData((data) => publish(session, data));
254
306
  local.onExit(({ exitCode, signal }) => {
255
307
  finishExit();
@@ -297,6 +349,7 @@ export class TerminalHost {
297
349
  send(frame);
298
350
  };
299
351
  session.listeners.add(listener);
352
+ session.lastActivity = Date.now();
300
353
  ws.on('message', (raw) => {
301
354
  let body;
302
355
  try {
@@ -332,6 +385,7 @@ export class TerminalHost {
332
385
  }
333
386
  if (!resumed)
334
387
  return;
388
+ session.lastActivity = Date.now();
335
389
  if (frame.type === 'resize') {
336
390
  session.pty.resize(frame.cols, frame.rows);
337
391
  return;
@@ -341,12 +395,14 @@ export class TerminalHost {
341
395
  const detach = () => {
342
396
  open = false;
343
397
  session.listeners.delete(listener);
398
+ session.lastActivity = Date.now();
344
399
  };
345
400
  ws.on('close', detach);
346
401
  ws.on('error', detach);
347
402
  }
348
403
  }
349
404
  function publish(session, text) {
405
+ session.lastActivity = Date.now();
350
406
  const before = session.log.end;
351
407
  session.log = appendTerminalOutput(session.log, text);
352
408
  const since = terminalOutputSince(session.log, before);
@@ -361,6 +417,31 @@ function publish(session, text) {
361
417
  listener(frame);
362
418
  }
363
419
  }
420
+ /** `true` while a process other than the shell runs in its session; `undefined` when /proc cannot tell. */
421
+ function runsOtherProcesses(shellPid) {
422
+ let names;
423
+ try {
424
+ names = readdirSync('/proc');
425
+ }
426
+ catch {
427
+ return undefined;
428
+ }
429
+ for (const name of names) {
430
+ if (!/^\d+$/.test(name) || Number(name) === shellPid)
431
+ continue;
432
+ try {
433
+ const stat = readFileSync(`/proc/${name}/stat`, 'utf8');
434
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
435
+ if (Number(fields[3]) === shellPid && !['Z', 'X'].includes(fields[0] ?? ''))
436
+ return true;
437
+ }
438
+ catch (error) {
439
+ if (!['ENOENT', 'ESRCH'].includes(error.code ?? ''))
440
+ return undefined;
441
+ }
442
+ }
443
+ return false;
444
+ }
364
445
  function exitFrame(session) {
365
446
  return {
366
447
  type: 'exit',
@@ -0,0 +1,193 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';
4
+ import { join, relative, sep } from 'node:path';
5
+ import { Readable, Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { workspaceDirectoryOccupied } from '../workspaces/directory-maintenance.js';
8
+ import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
9
+ import { createStorageSnapshot, extractStorageSnapshot, STORAGE_SNAPSHOT_MAX_BYTES } from '../workspaces/storage-snapshot.js';
10
+ import { withDirectoryWrite } from './directory-access.js';
11
+ const SHA256 = /^[a-f0-9]{64}$/;
12
+ /** Private control routes. Workspace identity survives disk eviction in the runtime journal. */
13
+ export async function handleWorkspaceStorage(state, request, response, url) {
14
+ const route = /^\/runtime\/workspaces\/([^/]+)\/storage-(status|snapshot|evict|restore)$/.exec(url.pathname);
15
+ if (!route)
16
+ return false;
17
+ const [, workspaceId, action] = route;
18
+ const json = (status, body) => { response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'private, no-store' }); response.end(JSON.stringify(body)); };
19
+ const controller = new AbortController(), abort = () => controller.abort();
20
+ let download;
21
+ let result;
22
+ const closed = () => { if (!response.writableEnded)
23
+ abort(); };
24
+ request.once('aborted', abort);
25
+ response.once('close', closed);
26
+ try {
27
+ if (state.config.workspaceMode !== 'multi' || !/^[0-9a-f-]{36}$/i.test(workspaceId))
28
+ throw new Error('storage_snapshot_invalid');
29
+ if (state.journal.destroyed())
30
+ throw new Error('runtime_destroyed');
31
+ if ((action === 'status' || action === 'snapshot') ? request.method !== 'GET' : request.method !== 'POST') {
32
+ json(405, { error: 'method_not_allowed' });
33
+ return true;
34
+ }
35
+ for (const [name, expected] of [['x-playcraft-generation', state.config.generation], ['x-playcraft-runtime-epoch', state.runtimeEpoch]]) {
36
+ const value = request.headers[name];
37
+ if (value !== undefined && value !== String(expected))
38
+ throw new Error('generation_mismatch');
39
+ }
40
+ const parent = await realpath(state.config.workspaceRoot), root = join(parent, workspaceId);
41
+ const stateRoot = await realpath(state.config.stateDir);
42
+ if (stateRoot === root || stateRoot.startsWith(`${root}${sep}`))
43
+ throw new Error('storage_snapshot_invalid');
44
+ const present = async () => {
45
+ const info = await lstat(root).catch(error => { if (error.code === 'ENOENT')
46
+ return undefined; throw error; });
47
+ if (!info)
48
+ return false;
49
+ if (!info.isDirectory() || info.isSymbolicLink() || relative(parent, await realpath(root)) !== workspaceId)
50
+ throw new Error('storage_snapshot_invalid');
51
+ return (await readdir(root)).length > 0;
52
+ };
53
+ if (action === 'status') {
54
+ json(200, { present: await present() });
55
+ return true;
56
+ }
57
+ if ((action === 'snapshot' || action === 'evict') && !state.journal.getWorkspace(workspaceId))
58
+ throw new Error('workspace_not_registered');
59
+ const directories = [...new Set(['root', 'basic', ...(state.journal.getWorkspace(workspaceId)?.directories ?? []).map(item => item.directoryName)])];
60
+ const assertIdle = async () => {
61
+ controller.signal.throwIfAborted();
62
+ if (state.journal.destroyed())
63
+ throw new Error('runtime_destroyed');
64
+ for (const directory of directories)
65
+ if (await workspaceDirectoryOccupied(state, workspaceId, directory))
66
+ throw new Error('workspace_directory_occupied');
67
+ };
68
+ await withDirectoryWrite(state, { workspaceId, directoryName: 'root', mode: 'exclusive', wait: false }, async () => {
69
+ await assertIdle();
70
+ const markerDir = join(state.config.stateDir, 'workspace-storage'), marker = join(markerDir, `${workspaceId}.json`);
71
+ if (action === 'snapshot') {
72
+ if (!(await present()))
73
+ throw new Error('workspace_not_prepared');
74
+ const snapshot = await createStorageSnapshot(root, { signal: controller.signal, excludedRoots: [state.config.stateDir] });
75
+ try {
76
+ await assertIdle();
77
+ download = snapshot;
78
+ }
79
+ catch (error) {
80
+ await snapshot.cleanup();
81
+ throw error;
82
+ }
83
+ return;
84
+ }
85
+ if (action === 'evict') {
86
+ let body = '';
87
+ for await (const part of request) {
88
+ body += part.toString();
89
+ if (body.length > 1024)
90
+ throw new Error('storage_snapshot_invalid');
91
+ }
92
+ const sha256 = JSON.parse(body).sha256;
93
+ if (typeof sha256 !== 'string' || !SHA256.test(sha256))
94
+ throw new Error('storage_snapshot_invalid');
95
+ const removed = join(parent, `.storage-evicted-${workspaceId}-${sha256}`);
96
+ if (!(await present())) {
97
+ const saved = await readFile(marker, 'utf8').then(value => JSON.parse(value), () => undefined);
98
+ if (saved?.sha256 !== sha256)
99
+ throw new Error('storage_snapshot_conflict');
100
+ await rm(removed, { recursive: true, force: true });
101
+ result = { evicted: true };
102
+ return;
103
+ }
104
+ const snapshot = await createStorageSnapshot(root, { signal: controller.signal, excludedRoots: [state.config.stateDir] });
105
+ try {
106
+ if (snapshot.sha256 !== sha256)
107
+ throw new Error('storage_snapshot_conflict');
108
+ await assertIdle();
109
+ // private
110
+ await mkdir(markerDir, { recursive: true, mode: 0o700 });
111
+ const temporary = `${marker}.${randomUUID()}.tmp`;
112
+ // private
113
+ await writeFile(temporary, JSON.stringify({ sha256 }), { flag: 'wx', mode: 0o600 });
114
+ await rename(temporary, marker);
115
+ // Moving first makes a partially-deleted directory invisible to readers and safe to retry.
116
+ await rename(root, removed);
117
+ await rm(removed, { recursive: true, force: true });
118
+ result = { evicted: true };
119
+ }
120
+ finally {
121
+ await snapshot.cleanup();
122
+ }
123
+ return;
124
+ }
125
+ if (await present())
126
+ throw new Error('storage_snapshot_destination_exists');
127
+ const sha256 = request.headers['x-playcraft-snapshot-sha256'];
128
+ if (typeof sha256 !== 'string' || !SHA256.test(sha256))
129
+ throw new Error('storage_snapshot_invalid');
130
+ const rawLength = request.headers['content-length'];
131
+ if (rawLength !== undefined && (typeof rawLength !== 'string' || !/^\d+$/.test(rawLength) || !Number.isSafeInteger(Number(rawLength)) || Number(rawLength) > STORAGE_SNAPSHOT_MAX_BYTES))
132
+ throw new Error('storage_snapshot_too_large');
133
+ const temporary = await mkdtemp(join(parent, '.storage-restore-'));
134
+ try {
135
+ const incoming = join(temporary, 'incoming.gz'), staged = join(temporary, 'workspace');
136
+ const hash = createHash('sha256');
137
+ let size = 0;
138
+ await pipeline(Readable.from(request.iterator({ destroyOnReturn: false })), new Transform({ transform(chunk, _encoding, done) {
139
+ size += chunk.length;
140
+ hash.update(chunk);
141
+ done(size > STORAGE_SNAPSHOT_MAX_BYTES ? new Error('storage_snapshot_too_large') : null, chunk);
142
+ } }), createWriteStream(incoming, { flags: 'wx', mode: 0o600 }), { signal: controller.signal }); // private
143
+ if (hash.digest('hex') !== sha256 || rawLength !== undefined && size !== Number(rawLength))
144
+ throw new Error('storage_snapshot_checksum_mismatch');
145
+ // private
146
+ await mkdir(staged, { mode: 0o700 });
147
+ await extractStorageSnapshot(incoming, staged, { signal: controller.signal });
148
+ await openDirectoryToDevelopment(staged, (await lstat(parent)).gid);
149
+ await assertIdle();
150
+ if (await present())
151
+ throw new Error('storage_snapshot_destination_exists');
152
+ const evicted = await readFile(marker, 'utf8').then(value => JSON.parse(value), () => undefined);
153
+ if (typeof evicted?.sha256 === 'string' && SHA256.test(evicted.sha256))
154
+ await rm(join(parent, `.storage-evicted-${workspaceId}-${evicted.sha256}`), { recursive: true, force: true });
155
+ await rmdir(root).catch(error => { if (error.code !== 'ENOENT')
156
+ throw error; });
157
+ await rename(staged, root);
158
+ await rm(marker, { force: true });
159
+ result = { restored: true };
160
+ }
161
+ finally {
162
+ await rm(temporary, { recursive: true, force: true });
163
+ }
164
+ });
165
+ if (result)
166
+ json(200, result);
167
+ if (download) {
168
+ const snapshot = download;
169
+ try {
170
+ response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': snapshot.bytes,
171
+ 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff', 'x-playcraft-snapshot-sha256': snapshot.sha256 });
172
+ await pipeline(createReadStream(snapshot.path), response, { signal: controller.signal });
173
+ }
174
+ finally {
175
+ await snapshot.cleanup();
176
+ }
177
+ }
178
+ }
179
+ catch (error) {
180
+ if (response.headersSent)
181
+ response.destroy(error instanceof Error ? error : undefined);
182
+ else if (!controller.signal.aborted) {
183
+ const reason = error instanceof Error ? error.message : '';
184
+ const code = /^(?:storage_snapshot_[a-z_]+|workspace_(?:not_registered|not_prepared|directory_occupied)|directory_busy|runtime_destroyed|generation_mismatch)$/.test(reason) ? reason : 'storage_snapshot_failed';
185
+ json(code === 'storage_snapshot_too_large' ? 413 : code === 'storage_snapshot_invalid' ? 400 : 409, { error: code });
186
+ }
187
+ }
188
+ finally {
189
+ request.off('aborted', abort);
190
+ response.off('close', closed);
191
+ }
192
+ return true;
193
+ }
@@ -6,12 +6,14 @@ import { createHash } from 'node:crypto';
6
6
  import { canonicalProjectSkillsJson } from '@playcraft/common/project-skills';
7
7
  import { syncLocalProjectSkills } from '../../project-skills/local-store.js';
8
8
  import { selectOptionalPlatformSkills } from './optional-platform-skills.js';
9
+ import { WORKSPACE_REMIX_REFERENCES_SKILL } from './remix-reference-skill.js';
9
10
  export const EMPTY_WORKSPACE_REPOSITORY_SKILLS = [
10
11
  'playcraft-skill-recommender',
11
12
  'playcraft-image-generation',
12
13
  'playcraft-audio-generation',
13
14
  'playcraft-image-processing',
14
15
  'playcraft-workspace-assets',
16
+ WORKSPACE_REMIX_REFERENCES_SKILL,
15
17
  'playcraft-build-optimizer',
16
18
  ];
17
19
  export const EMPTY_WORKSPACE_PLATFORM_SKILLS = ['playcraft-video-understanding', 'playcraft-replica-validation'];
@@ -1,6 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { lstat, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
3
- import { join, relative } from 'node:path';
3
+ import { dirname, join, relative } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, batchSourceSnapshotSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { prepareWorkspaceCheckout } from './prepare.js';
@@ -163,7 +163,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
163
163
  await prepareWorkspaceCheckout(state, binding, preparation, leaseFence);
164
164
  root = await checkedDirectory(state, binding.workspaceId);
165
165
  }
166
- const git = async (...args) => (await exec('git', ['-C', root, ...args], {
166
+ const gitIn = (cwd) => async (...args) => (await exec('git', ['-C', cwd, ...args], {
167
167
  timeout: 5000,
168
168
  maxBuffer: 64 * 1024,
169
169
  env: {
@@ -173,8 +173,14 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
173
173
  GIT_CONFIG_GLOBAL: '/dev/null',
174
174
  },
175
175
  })).stdout.trim();
176
+ const git = gitIn(root);
177
+ // The checkout, not the journal, decides whether the remote is still missing: the state volume is
178
+ // per generation and forgets a standalone→main promotion, while the checkout outlives it.
179
+ const needsRemote = binding.targetKind === 'main' && !preservedLocalBasic
180
+ && (await git('remote').catch(() => undefined)) === '';
176
181
  // Unfinished if it failed part way: the marker outlives the journal entry written below.
177
- if (binding.targetKind === 'main' && !preservedLocalBasic && (remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
182
+ if (binding.targetKind === 'main' && !preservedLocalBasic
183
+ && (needsRemote || remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
178
184
  if (preparation?.mode !== 'clone' || !state.gitCredentials)
179
185
  throw new WorkspaceContextError('workspace_git_gateway_unavailable');
180
186
  // private
@@ -218,6 +224,10 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
218
224
  throw error;
219
225
  throw new WorkspaceContextError('workspace_not_prepared');
220
226
  }
227
+ // Variants share basic's volume but have no registration step of their own, so a swap can leave
228
+ // them checked out, on the right branch, and without the remote their saves need.
229
+ if (!readOnlyBasic && !preservedLocalBasic)
230
+ await restoreDirectoryRemotes(binding, root, gitIn);
221
231
  if (state.journal.destroyed())
222
232
  throw new WorkspaceContextError('runtime_destroyed');
223
233
  if (leaseFence < state.acceptedLeaseFence)
@@ -260,3 +270,34 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
260
270
  return binding;
261
271
  });
262
272
  }
273
+ /**
274
+ * Variant checkouts outlive the journal that registered them: a sandbox swap keeps the files and the
275
+ * branch but drops the remote. Only an already-correct branch is repaired; rebuilding another one
276
+ * would discard the variant's own commits, so that case stays a checkout mismatch.
277
+ */
278
+ async function restoreDirectoryRemotes(binding, root, gitIn) {
279
+ const repositoryUrl = binding.repositoryUrl;
280
+ if (!repositoryUrl)
281
+ return;
282
+ for (const directory of binding.directories ?? []) {
283
+ if (directory.directoryName === 'basic')
284
+ continue;
285
+ const checkout = join(dirname(root), directory.directoryName);
286
+ const stat = await lstat(checkout).catch(() => undefined);
287
+ if (!stat?.isDirectory() || stat.isSymbolicLink())
288
+ continue;
289
+ const git = gitIn(checkout);
290
+ if ((await git('symbolic-ref', '--short', 'HEAD').catch(() => undefined)) !== directory.branchName)
291
+ continue;
292
+ const origin = await git('config', '--get', 'remote.origin.url').catch(() => undefined);
293
+ if (origin === repositoryUrl)
294
+ continue;
295
+ try {
296
+ if (origin === undefined)
297
+ await git('remote', 'add', 'origin', repositoryUrl);
298
+ else
299
+ await git('remote', 'set-url', 'origin', repositoryUrl);
300
+ }
301
+ catch { /* The variant still opens; its next save reports the missing remote. */ }
302
+ }
303
+ }