@playcraft/cli 0.1.16 → 0.1.18

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,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
- import { runtimeCallbackEndpoint } from '../../utils/environment-endpoints.js';
3
+ import { platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
4
4
  import { constants } from 'node:fs';
5
5
  import { lstat, open, readdir, rm } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
@@ -223,10 +223,7 @@ function git(cwd, args) {
223
223
  });
224
224
  }
225
225
  async function postControl(state, path, body) {
226
- const endpoint = runtimeCallbackEndpoint(state.config.callbackUrl, path);
227
- const local = endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname);
228
- if (endpoint.protocol !== 'https:' && !local)
229
- throw new PreviewFailure('preview_upload_failed');
226
+ const endpoint = platformEndpoint(state.config.publicBaseUrl, path);
230
227
  const response = await fetch(endpoint, {
231
228
  method: 'POST',
232
229
  redirect: 'error',
@@ -96,7 +96,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
96
96
  }
97
97
  catch (error) {
98
98
  const code = error instanceof Error ? error.message : '';
99
- state.nativeError = code === 'model_proxy_token_missing' ? code : 'codex_start_failed';
99
+ state.nativeError = code === 'codex_model_catalog_unavailable' ? code : 'codex_start_failed';
100
100
  console.error(`[workspace-runtime] ${state.nativeError}`);
101
101
  }
102
102
  const server = createServer((request, response) => {
@@ -180,11 +180,15 @@ function defaultNativeAdapter(state) {
180
180
  const config = state.config;
181
181
  if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'loopback')
182
182
  return createLoopbackNativeAdapter();
183
- const managed = config.workspaceMode === 'multi' && Boolean(process.env.PLAYCRAFT_LAUNCHER_SOCKET);
184
- if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex'
185
- || (!process.env.PLAYCRAFT_NATIVE_ADAPTER && managed)) {
186
- return createCodexAppServerAdapter({ config,
187
- ...(managed ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
183
+ const hasLauncher = Boolean(process.env.PLAYCRAFT_LAUNCHER_SOCKET);
184
+ const multi = config.workspaceMode === 'multi';
185
+ if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex' && !multi && !hasLauncher) {
186
+ throw new Error('codex_requires_managed_launcher');
187
+ }
188
+ if (process.env.PLAYCRAFT_NATIVE_ADAPTER === 'codex' || (multi && hasLauncher)) {
189
+ return createCodexAppServerAdapter({
190
+ config,
191
+ ...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
188
192
  loadSkillArtifact: async (cwd, entry) => {
189
193
  const id = basename(cwd);
190
194
  if (await workspaceRoot(state, id) !== cwd)
@@ -0,0 +1,201 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { PLATFORM_ROUTES, modelConnectivityPath, platformEndpoint, } from '@playcraft/common/cloud-workspace/runtime';
4
+ const PROBE_TIMEOUT_MS = 4_000;
5
+ const CACHE_MS = 15_000;
6
+ const CREDENTIAL_RENEW_BEFORE_MS = 30_000;
7
+ const MAX_RESPONSE_BYTES = 16 * 1024;
8
+ const credentialSchema = z.object({
9
+ token: z.string().min(1), expiresAt: z.string().datetime(),
10
+ configId: z.string().uuid(), model: z.string().min(1).max(128),
11
+ claims: z.object({
12
+ environment: z.string(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
13
+ generation: z.number().int(), runtimeEpoch: z.number().int(),
14
+ }),
15
+ });
16
+ const caches = new WeakMap();
17
+ const ready = () => ({ status: 'ready' });
18
+ const unavailable = (reason) => ({ status: 'unavailable', reason });
19
+ function snapshot(state, workspaceId) {
20
+ let nativeReady = false;
21
+ try {
22
+ nativeReady = Boolean(state.native?.ready());
23
+ }
24
+ catch { /* A failed adapter stays unavailable. */ }
25
+ const { publicBaseUrl, controlToken, environment, userId, runtimeId, generation } = state.config;
26
+ return { publicBaseUrl, controlToken, environment, userId, runtimeId, generation,
27
+ runtimeEpoch: state.runtimeEpoch, workspaceId, nativeReady, destroyed: state.journal.destroyed(),
28
+ registered: state.config.workspaceMode !== 'multi' || Boolean(state.journal.getWorkspace(workspaceId)) };
29
+ }
30
+ function safeEndpoint(value) {
31
+ const url = new URL(value);
32
+ if (url.username || url.password || url.search || url.hash || /[%\\]/.test(url.pathname)
33
+ || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
34
+ throw new Error('insecure_endpoint');
35
+ }
36
+ return url;
37
+ }
38
+ async function responseJson(response) {
39
+ const reader = response.body?.getReader();
40
+ if (!reader)
41
+ throw new Error('invalid_probe_response');
42
+ const chunks = [];
43
+ let size = 0;
44
+ try {
45
+ while (true) {
46
+ const item = await reader.read();
47
+ if (item.done)
48
+ break;
49
+ size += item.value.byteLength;
50
+ if (size > MAX_RESPONSE_BYTES)
51
+ throw new Error('invalid_probe_response');
52
+ chunks.push(item.value);
53
+ }
54
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
55
+ }
56
+ finally {
57
+ await reader.cancel().catch(() => undefined);
58
+ reader.releaseLock();
59
+ }
60
+ }
61
+ async function requestProbe(target, init, credentials = false) {
62
+ const signal = AbortSignal.timeout(PROBE_TIMEOUT_MS);
63
+ try {
64
+ const response = await fetch(target, { ...init, redirect: 'error', signal });
65
+ if (!response.ok) {
66
+ const offline = response.headers.has('ngrok-error-code');
67
+ const rejected = response.status === 401 || response.status === 403;
68
+ let reason = rejected ? 'endpoint_rejected' : offline ? 'endpoint_offline' : 'endpoint_unreachable';
69
+ if (credentials && !offline && !rejected) {
70
+ const body = await responseJson(response).catch(() => undefined);
71
+ if (signal.aborted)
72
+ return { state: unavailable('endpoint_timeout') };
73
+ if (body && typeof body === 'object' && 'reason' in body && body.reason === 'model_config_required')
74
+ reason = 'model_config_required';
75
+ }
76
+ else
77
+ await response.body?.cancel().catch(() => undefined);
78
+ return { state: unavailable(reason) };
79
+ }
80
+ let body;
81
+ try {
82
+ body = await responseJson(response);
83
+ }
84
+ catch {
85
+ return { state: unavailable(signal.aborted ? 'endpoint_timeout' : 'invalid_probe_response') };
86
+ }
87
+ return { state: ready(), body };
88
+ }
89
+ catch {
90
+ return { state: unavailable(signal.aborted ? 'endpoint_timeout' : 'endpoint_unreachable') };
91
+ }
92
+ }
93
+ function echoMatches(body, expected) {
94
+ return Boolean(body && typeof body === 'object' && !Array.isArray(body)
95
+ && Object.entries(expected).every(([key, value]) => body[key] === value));
96
+ }
97
+ function scope(config) {
98
+ return { runtimeId: config.runtimeId, environment: config.environment, workspaceId: config.workspaceId,
99
+ generation: config.generation, runtimeEpoch: config.runtimeEpoch };
100
+ }
101
+ function entrance(config, route) {
102
+ return safeEndpoint(platformEndpoint(config.publicBaseUrl, route));
103
+ }
104
+ async function callbackProbe(config) {
105
+ if (!config.publicBaseUrl)
106
+ return unavailable('endpoint_not_configured');
107
+ let endpoint;
108
+ try {
109
+ endpoint = entrance(config, PLATFORM_ROUTES.eventsProbe);
110
+ }
111
+ catch {
112
+ return unavailable('insecure_endpoint');
113
+ }
114
+ const nonce = randomUUID();
115
+ const response = await requestProbe(endpoint, {
116
+ method: 'POST', headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
117
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
118
+ runtimeEpoch: config.runtimeEpoch, workspaceId: config.workspaceId, nonce }),
119
+ });
120
+ if (response.state.status !== 'ready')
121
+ return response.state;
122
+ return echoMatches(response.body, { service: 'playcraft-runtime', ...scope(config), nonce }) ? ready() : unavailable('invalid_probe_response');
123
+ }
124
+ async function modelProxyProbe(config, entry) {
125
+ if (!config.publicBaseUrl)
126
+ return unavailable('endpoint_not_configured');
127
+ let credentialEndpoint;
128
+ try {
129
+ credentialEndpoint = entrance(config, PLATFORM_ROUTES.modelCredentials);
130
+ entrance(config, PLATFORM_ROUTES.modelProxy);
131
+ }
132
+ catch {
133
+ return unavailable('insecure_endpoint');
134
+ }
135
+ if (entry.credential && Date.parse(entry.credential.expiresAt) <= Date.now() + CREDENTIAL_RENEW_BEFORE_MS)
136
+ entry.credential = undefined;
137
+ if (!entry.credential) {
138
+ const response = await requestProbe(credentialEndpoint, {
139
+ method: 'POST', headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
140
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: config.runtimeEpoch, workspaceId: config.workspaceId }),
141
+ }, true);
142
+ if (response.state.status !== 'ready')
143
+ return response.state;
144
+ const parsed = credentialSchema.safeParse(response.body);
145
+ if (!parsed.success || !echoMatches(parsed.data.claims, { ...scope(config), userId: config.userId })
146
+ || Date.parse(parsed.data.expiresAt) <= Date.now() + 1_000)
147
+ return unavailable('invalid_probe_response');
148
+ entry.credential = parsed.data;
149
+ }
150
+ const credential = entry.credential;
151
+ const nonce = randomUUID();
152
+ const target = new URL(platformEndpoint(config.publicBaseUrl, modelConnectivityPath(credential.configId)));
153
+ target.searchParams.set('nonce', nonce);
154
+ const result = await requestProbe(target, { method: 'GET', headers: { authorization: `Bearer ${credential.token}`, 'playcraft-env': config.environment } });
155
+ if (result.state.reason === 'endpoint_rejected')
156
+ entry.credential = undefined;
157
+ if (result.state.status !== 'ready')
158
+ return result.state;
159
+ return echoMatches(result.body, { service: 'playcraft-model-proxy', ...scope(config), configId: credential.configId, nonce })
160
+ ? ready() : unavailable('invalid_probe_response');
161
+ }
162
+ async function probe(config, entry) {
163
+ if (config.destroyed || !config.registered) {
164
+ const state = unavailable(config.destroyed ? 'runtime_destroyed' : 'workspace_not_registered');
165
+ return { ...scope(config), checkedAt: new Date().toISOString(), callback: state, modelProxy: state, conversation: state };
166
+ }
167
+ const [callback, modelProxy] = await Promise.all([callbackProbe(config), modelProxyProbe(config, entry)]);
168
+ const conversation = !config.nativeReady ? unavailable('adapter_not_started')
169
+ : callback.status !== 'ready' ? unavailable('callback_unavailable')
170
+ : modelProxy.status !== 'ready' ? unavailable('model_proxy_unavailable') : ready();
171
+ return { ...scope(config), checkedAt: new Date().toISOString(), callback, modelProxy, conversation };
172
+ }
173
+ /** Check the configured services without starting a thread or generating a model response. */
174
+ export function probeRuntimeConnectivity(state, workspaceId) {
175
+ const config = snapshot(state, workspaceId);
176
+ const key = JSON.stringify(config);
177
+ let cache = caches.get(state);
178
+ if (!cache) {
179
+ cache = new Map();
180
+ caches.set(state, cache);
181
+ }
182
+ const previous = cache.get(workspaceId);
183
+ if (previous?.key === key) {
184
+ if (previous.pending)
185
+ return previous.pending;
186
+ if (previous.value && previous.expiresAt > Date.now())
187
+ return Promise.resolve(previous.value);
188
+ }
189
+ const entry = previous?.key === key ? previous : { key, expiresAt: 0 };
190
+ cache.set(workspaceId, entry);
191
+ if (cache.size > 256)
192
+ cache.delete(cache.keys().next().value);
193
+ entry.pending = probe(config, entry).then((value) => {
194
+ if (JSON.stringify(snapshot(state, workspaceId)) !== key)
195
+ return probeRuntimeConnectivity(state, workspaceId);
196
+ entry.value = value;
197
+ entry.expiresAt = Date.now() + CACHE_MS;
198
+ return value;
199
+ }).finally(() => { entry.pending = undefined; });
200
+ return entry.pending;
201
+ }
@@ -2,7 +2,8 @@ import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { z } from 'zod';
6
+ import { PLATFORM_ROUTES, platformEndpoint, checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
7
  import { closeCliCredentials } from '../workspaces/cli-credentials.js';
7
8
  import { resolveWorkspaceAttachments } from '../workspaces/attachments.js';
8
9
  import { readWorkspaceSkillSnapshot, readWorkspaceSkills } from '../workspaces/skills.js';
@@ -11,10 +12,9 @@ import { enableLocalProjectSkill, syncLocalProjectSkills } from '../../project-s
11
12
  import { NativePreparationError } from '../codex/native-adapter.js';
12
13
  import { disposeTerminalHost } from './terminal-host.js';
13
14
  import { disposeDesktopHost } from './desktop-host.js';
14
- import { captureWorkspaceTree, liveTreeDigest, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
15
- import { objectStoreHeaders, oneSaveAtATime, rememberFileCheckpoint, unchangedCheckpoint, workspaceSaves, } from '../workspaces/checkpoint-saves.js';
15
+ import { captureWorkspaceTree, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
16
16
  import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
17
- import { GitWorkspaceError, bundleUnpushedCommits, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
17
+ import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, saveWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
18
18
  import { ParameterEditError } from '../workspaces/json5-edit.js';
19
19
  import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
20
20
  import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
@@ -37,6 +37,7 @@ const LOCAL_KINDS = new Set([
37
37
  'git.stage',
38
38
  'git.unstage',
39
39
  'git.discard',
40
+ 'workspace.save',
40
41
  'terminal.create',
41
42
  'parameter.patch',
42
43
  'preview.publish',
@@ -88,8 +89,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
88
89
  return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'workspace_not_ready' }));
89
90
  }
90
91
  if (operation.kind === 'checkpoint.create') {
91
- const workspaceId = current.workspaceId ?? String(asRecord(payload).workspaceId ?? 'workspace');
92
- return oneSaveAtATime(state, workspaceId, () => executeCheckpoint(state, scopeKey, current, payload));
92
+ return executeCheckpoint(state, scopeKey, current, payload);
93
93
  }
94
94
  if (operation.kind === 'checkpoint.restore') {
95
95
  return executeRestore(state, scopeKey, current, payload);
@@ -109,6 +109,9 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
109
109
  if (operation.kind === 'git.stage' || operation.kind === 'git.unstage' || operation.kind === 'git.discard') {
110
110
  return executeGitIndex(state, scopeKey, current, payload);
111
111
  }
112
+ if (operation.kind === 'workspace.save') {
113
+ return executeWorkspaceSave(state, scopeKey, current, payload);
114
+ }
112
115
  if (operation.kind === 'terminal.create') {
113
116
  return executeTerminalCreate(state, scopeKey, current, payload);
114
117
  }
@@ -123,7 +126,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
123
126
  }
124
127
  try {
125
128
  if (operation.kind === 'session.create') {
126
- return executeCreate(state, scopeKey, current);
129
+ return executeCreate(state, scopeKey, current, payload);
127
130
  }
128
131
  if (operation.kind === 'session.turn') {
129
132
  return executeTurn(state, scopeKey, current, payload);
@@ -171,13 +174,17 @@ async function executeSkillsUpdate(state, scopeKey, operation, payload) {
171
174
  }));
172
175
  }
173
176
  }
174
- async function executeCreate(state, scopeKey, operation) {
177
+ async function executeCreate(state, scopeKey, operation, payload) {
175
178
  const sessionId = required(operation.sessionId, 'sessionId');
179
+ const selected = z.string().uuid().optional().safeParse(asRecord(payload).modelConfigId);
180
+ if (!selected.success)
181
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
176
182
  let started;
177
183
  try {
178
184
  started = await native(state).startThread({
179
185
  sessionId,
180
186
  cwd: await workspaceRoot(state, operation.workspaceId),
187
+ modelConfigId: selected.data,
181
188
  });
182
189
  }
183
190
  catch (error) {
@@ -195,10 +202,12 @@ async function executeCreate(state, scopeKey, operation) {
195
202
  threadId: started.threadId,
196
203
  cwd: await workspaceRoot(state, operation.workspaceId),
197
204
  recoverability: 'native',
205
+ modelConfigId: started.modelConfigId,
198
206
  });
199
207
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', {
200
208
  nativeRequestId: `thread/start:${sessionId}`,
201
209
  nativeThreadId: started.threadId,
210
+ modelConfigId: started.modelConfigId,
202
211
  }));
203
212
  await recordNativeEvents(state, accepted, [
204
213
  { type: 'session.created', payload: { sessionId, threadId: started.threadId }, occurredAt: new Date().toISOString() },
@@ -218,6 +227,9 @@ async function executeTurn(state, scopeKey, operation, payload) {
218
227
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
219
228
  }
220
229
  const body = asRecord(payload);
230
+ const selected = z.string().uuid().optional().safeParse(body.modelConfigId);
231
+ if (!selected.success)
232
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
221
233
  let attachments;
222
234
  try {
223
235
  attachments = await resolveWorkspaceAttachments(await workspaceRoot(state, operation.workspaceId), body.attachmentIds);
@@ -233,15 +245,17 @@ async function executeTurn(state, scopeKey, operation, payload) {
233
245
  text: String(body.text ?? '') + attachmentText,
234
246
  images: attachments.filter((file) => file.mimeType.startsWith('image/')).map((file) => file.path),
235
247
  clientRequestId: operation.clientRequestId,
248
+ modelConfigId: selected.data ?? binding.modelConfigId,
236
249
  simulateLostReceipt: body.simulateLostReceipt === true,
237
- persistReservation: async ({ nativeRequestId }) => {
250
+ persistReservation: async ({ nativeRequestId, modelConfigId }) => {
238
251
  await persist(state, scopeKey, {
239
252
  ...operation,
240
253
  nativeRequestId,
241
254
  nativeThreadId: binding.threadId,
255
+ modelConfigId,
242
256
  updatedAt: new Date().toISOString(),
243
257
  });
244
- await state.journal.putSession({ ...binding, turnStatus: 'starting' });
258
+ await state.journal.putSession({ ...binding, modelConfigId, turnStatus: 'starting' });
245
259
  },
246
260
  });
247
261
  const reserved = state.journal.getOperation(scopeKey) ?? operation;
@@ -341,9 +355,9 @@ async function executeApproval(state, scopeKey, operation, payload) {
341
355
  }
342
356
  async function executeCheckpoint(state, scopeKey, operation, payload) {
343
357
  const body = asRecord(payload);
344
- // Only a save without a requested identity may answer with an earlier, unchanged checkpoint.
345
- const requested = typeof body.checkpointId === 'string' && body.checkpointId.trim() !== '';
346
- const checkpointId = requested ? String(body.checkpointId).trim() : `ckpt_${randomUUID()}`;
358
+ const checkpointId = typeof body.checkpointId === 'string' && body.checkpointId.trim()
359
+ ? body.checkpointId.trim()
360
+ : `ckpt_${randomUUID()}`;
347
361
  if (!/^[a-zA-Z0-9_-]{1,128}$/.test(checkpointId) || state.journal.getCheckpoint(checkpointId)) {
348
362
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'checkpoint_id_conflict' }));
349
363
  }
@@ -354,6 +368,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
354
368
  const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
355
369
  sessionId: item.sessionId,
356
370
  nativeThreadId: item.threadId,
371
+ modelConfigId: item.modelConfigId,
357
372
  recoverability: 'history_only',
358
373
  }));
359
374
  const checkpoint = checkpointRecordSchema.parse({
@@ -374,44 +389,20 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
374
389
  const revisions = state.journal.fileRevisions(revisionScope(state, operation.workspaceId));
375
390
  let files;
376
391
  let published;
377
- // A standalone workspace has no remote, so all of its `.git` is kept. A project workspace's packs
378
- // come back with a new clone; what the remote lacks goes into the unpushed bundle.
379
- const standalone = state.journal.getWorkspace(workspaceId)?.targetKind === 'standalone';
380
- if (!standalone) {
381
- // Without a fresh bundle the loose objects still hold commits made since the last repack.
382
- await bundleUnpushedCommits(root).catch(() => undefined);
383
- }
384
- const tree = await captureWorkspaceTree(root, previousFilePaths(state), revisions, undefined, {
385
- git: standalone ? 'full' : 'local',
386
- });
387
- // Transcripts only matter where another instance can read them back.
388
- const transcripts = state.config.fileObjectStoreUrl ? await captureTranscripts(state, operation.workspaceId) : [];
389
- const digest = liveTreeDigest(tree, transcripts);
390
- if (!requested && tree.status === 'complete') {
391
- const stillActive = await activeManagedWrites(state, workspaceId);
392
- if (stillActive.length === 0) {
393
- const captured = await captureNativeSessions(state, operation.workspaceId);
394
- const previous = unchangedCheckpoint(state, workspaceId, digest, captured.sessions);
395
- if (previous)
396
- return finishUnchanged(state, scopeKey, operation, previous);
397
- }
398
- }
399
- if (state.config.fileObjectStoreUrl) {
392
+ if (state.config.checkpointObjects) {
393
+ const tree = await captureWorkspaceTree(root, previousFilePaths(state), revisions);
400
394
  const stillActive = await activeManagedWrites(state, workspaceId);
401
395
  if (stillActive.length > 0) {
402
396
  return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
403
397
  }
404
398
  try {
405
399
  published = await publishFileSnapshot({
406
- baseUrl: state.config.fileObjectStoreUrl,
400
+ baseUrl: platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.checkpointStore),
407
401
  snapshotId: checkpointId,
408
402
  workspaceId,
409
403
  generation: state.config.generation,
410
404
  tree,
411
- native: transcripts,
412
- uploaded: workspaceSaves(state, workspaceId).uploaded,
413
- uploadedTranscripts: workspaceSaves(state, workspaceId).transcripts,
414
- headers: objectStoreHeaders(state, state.config.fileObjectStoreUrl),
405
+ headers: checkpointStoreHeaders(state),
415
406
  });
416
407
  }
417
408
  catch {
@@ -421,6 +412,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
421
412
  const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
422
413
  sessionId: item.sessionId,
423
414
  nativeThreadId: item.threadId,
415
+ modelConfigId: item.modelConfigId,
424
416
  recoverability: 'history_only',
425
417
  }));
426
418
  const checkpoint = checkpointRecordSchema.parse({
@@ -471,26 +463,8 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
471
463
  await state.journal.setFileCheckpointId(checkpointId);
472
464
  if (nativeReady)
473
465
  await state.journal.setConsistentCheckpointId(checkpointId);
474
- if (tree.status === 'complete') {
475
- await rememberFileCheckpoint(state, workspaceId, { checkpointId, digest, sessions: captured.sessions });
476
- }
477
466
  return finishCheckpoint(state, scopeKey, operation, checkpoint, []);
478
467
  }
479
- /**
480
- * Nothing changed since the last file checkpoint: the save reports that checkpoint again and
481
- * writes no new one, so periodic saves of an idle workspace cost one read of the tree.
482
- */
483
- async function finishUnchanged(state, scopeKey, operation, checkpoint) {
484
- const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { checkpointId: checkpoint.checkpointId }));
485
- return persist(state, scopeKey, transitionOperation(accepted, 'completed', {
486
- checkpointId: checkpoint.checkpointId,
487
- checkpointKind: checkpoint.kind,
488
- checkpointConsistent: checkpoint.consistent === true,
489
- ...(checkpoint.manifestKey ? { snapshotManifestKey: checkpoint.manifestKey } : {}),
490
- ...(checkpoint.checksum ? { snapshotChecksum: checkpoint.checksum } : {}),
491
- ...(checkpoint.snapshotStatus ? { snapshotStatus: checkpoint.snapshotStatus } : {}),
492
- }));
493
- }
494
468
  async function executeRestore(state, scopeKey, operation, payload) {
495
469
  const body = asRecord(payload);
496
470
  const checkpointId = String(body.checkpointId ?? operation.checkpointId ?? '');
@@ -513,13 +487,14 @@ async function executeRestore(state, scopeKey, operation, payload) {
513
487
  if (!checkpoint.nativeRecoverable || threadReplaced) {
514
488
  return {
515
489
  ...item,
490
+ modelConfigId: snap.modelConfigId ?? item.modelConfigId,
516
491
  threadId,
517
492
  recoverability: restarted ? 'history_only' : item.recoverability,
518
493
  };
519
494
  }
520
495
  if (restarted)
521
- return { ...item, threadId, recoverability: 'history_only' };
522
- return { ...item, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
496
+ return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId, recoverability: 'history_only' };
497
+ return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
523
498
  });
524
499
  for (const snap of checkpoint.sessions) {
525
500
  if (!next.some((item) => item.sessionId === snap.sessionId)) {
@@ -527,6 +502,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
527
502
  sessionId: snap.sessionId,
528
503
  workspaceId: operation.workspaceId,
529
504
  threadId: snap.nativeThreadId ?? `thread_${snap.sessionId}`,
505
+ modelConfigId: snap.modelConfigId,
530
506
  cwd,
531
507
  recoverability: checkpoint.nativeRecoverable && !restarted ? 'native' : 'history_only',
532
508
  });
@@ -545,6 +521,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
545
521
  threadId,
546
522
  sessionId: snap.sessionId,
547
523
  cwd: found.cwd,
524
+ modelConfigId: found.modelConfigId,
548
525
  });
549
526
  if (!continued || found.threadId !== threadId)
550
527
  continue;
@@ -713,6 +690,37 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
713
690
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
714
691
  }
715
692
  }
693
+ /**
694
+ * Saves the workspace to Git: commit everything that changed, then push a Remix branch. A
695
+ * standalone workspace has no remote, and the Git gateway never lets a workspace write its
696
+ * project's main branch, so those two only commit.
697
+ */
698
+ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
699
+ const parsed = workspaceSavePayloadSchema.safeParse(payload ?? {});
700
+ if (!parsed.success) {
701
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_workspace_save' }));
702
+ }
703
+ const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
704
+ try {
705
+ const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId), {
706
+ message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
707
+ push: binding?.targetKind === 'remix',
708
+ boundBranch: boundBranch(state, operation),
709
+ });
710
+ const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
711
+ await recordNativeEvents(state, accepted, [
712
+ {
713
+ type: 'workspace.saved',
714
+ payload: { ...(saved.commit ? { sha: saved.commit } : {}), pushed: saved.pushed, withheld: saved.withheld },
715
+ occurredAt: new Date().toISOString(),
716
+ },
717
+ ]);
718
+ return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
719
+ }
720
+ catch (error) {
721
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
722
+ }
723
+ }
716
724
  async function executeGitPush(state, scopeKey, operation, payload) {
717
725
  const parsed = gitPushPayloadSchema.safeParse(payload ?? {});
718
726
  if (!parsed.success) {
@@ -910,22 +918,6 @@ async function finishCheckpoint(state, scopeKey, operation, checkpoint, activeWr
910
918
  ...(checkpoint.snapshotStatus ? { snapshotStatus: checkpoint.snapshotStatus } : {}),
911
919
  }));
912
920
  }
913
- /** Transcripts of the workspace's resumable threads. A thread whose file cannot be read is left out. */
914
- async function captureTranscripts(state, workspaceId) {
915
- const native = state.native;
916
- if (!native?.exportThread)
917
- return [];
918
- const captured = [];
919
- for (const session of workspaceSessions(state, workspaceId)) {
920
- if (session.recoverability !== 'native')
921
- continue;
922
- const capturedAt = new Date().toISOString();
923
- const file = await native.exportThread({ threadId: session.threadId }).catch(() => undefined);
924
- if (file)
925
- captured.push({ sessionId: session.sessionId, threadId: session.threadId, capturedAt, ...file });
926
- }
927
- return captured;
928
- }
929
921
  async function captureNativeSessions(state, workspaceId) {
930
922
  const live = workspaceSessions(state, workspaceId);
931
923
  const sessions = [];
@@ -944,12 +936,14 @@ async function captureNativeSessions(state, workspaceId) {
944
936
  ? {
945
937
  sessionId: item.sessionId,
946
938
  nativeThreadId: item.threadId,
939
+ modelConfigId: item.modelConfigId,
947
940
  nativeDurableTurnId: durable,
948
941
  recoverability: 'native',
949
942
  }
950
943
  : {
951
944
  sessionId: item.sessionId,
952
945
  nativeThreadId: item.threadId,
946
+ modelConfigId: item.modelConfigId,
953
947
  recoverability: 'history_only',
954
948
  });
955
949
  }
@@ -984,6 +978,7 @@ async function finishBestEffort(state, scopeKey, operation, checkpointId, worksp
984
978
  const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
985
979
  sessionId: item.sessionId,
986
980
  nativeThreadId: item.threadId,
981
+ modelConfigId: item.modelConfigId,
987
982
  recoverability: 'history_only',
988
983
  }));
989
984
  const checkpoint = checkpointRecordSchema.parse({
@@ -1073,8 +1068,18 @@ function nativeIds(params) {
1073
1068
  itemId: typeof record.itemId === 'string' ? record.itemId : typeof record.approvalId === 'string' ? record.approvalId : undefined,
1074
1069
  };
1075
1070
  }
1071
+ /** The checkpoint route is on this deployment, so uploads carry the control credential. */
1072
+ function checkpointStoreHeaders(state) {
1073
+ return {
1074
+ authorization: `Bearer ${state.config.controlToken}`,
1075
+ 'playcraft-env': state.config.environment,
1076
+ 'x-playcraft-runtime-id': state.config.runtimeId,
1077
+ 'x-playcraft-generation': String(state.config.generation),
1078
+ 'x-playcraft-runtime-epoch': String(state.runtimeEpoch),
1079
+ };
1080
+ }
1076
1081
  async function notifyCallback(state, events) {
1077
- await fetch(state.config.callbackUrl, {
1082
+ await fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
1078
1083
  method: 'POST',
1079
1084
  headers: {
1080
1085
  authorization: `Bearer ${state.config.controlToken}`,
@@ -1,10 +1,10 @@
1
1
  import { chmod, rename, writeFile } from 'node:fs/promises';
2
2
  import { applySandboxEndpointPatch } from '@playcraft/common/cloud-workspace/runtime';
3
- /** Persist the new addresses, then publish them to the running process. A restart is not required. */
3
+ /** Persist the new public entrance, then publish it to the running process. A restart is not required. */
4
4
  export async function applyRuntimeEndpointPatch(state, patch) {
5
5
  const next = applySandboxEndpointPatch(state.config, patch);
6
- const changed = next.callbackUrl !== state.config.callbackUrl || next.modelProxyUrl !== state.config.modelProxyUrl;
7
- const result = { changed, callbackUrl: next.callbackUrl, modelProxyUrl: next.modelProxyUrl };
6
+ const changed = next.publicBaseUrl !== state.config.publicBaseUrl;
7
+ const result = { changed, publicBaseUrl: next.publicBaseUrl };
8
8
  if (!changed)
9
9
  return result;
10
10
  if (!state.configPath) {