@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.
- package/dist/utils/environment-endpoints.js +0 -9
- package/dist/workspace-runtime/codex/app-server.js +69 -65
- package/dist/workspace-runtime/codex/config-toml.js +83 -0
- package/dist/workspace-runtime/codex/model-broker.js +79 -24
- package/dist/workspace-runtime/previews/publish.js +2 -5
- package/dist/workspace-runtime/serve.js +10 -6
- package/dist/workspace-runtime/server/connectivity.js +201 -0
- package/dist/workspace-runtime/server/dispatch.js +83 -78
- package/dist/workspace-runtime/server/endpoints.js +3 -3
- package/dist/workspace-runtime/server/http.js +15 -1
- package/dist/workspace-runtime/workspaces/cli-credentials.js +2 -6
- package/dist/workspace-runtime/workspaces/file-snapshot.js +20 -161
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +2 -4
- package/dist/workspace-runtime/workspaces/git.js +67 -81
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -4
- package/package.json +3 -3
- package/dist/workspace-runtime/codex/transcripts.js +0 -94
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +0 -79
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
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 =
|
|
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 === '
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
7
|
-
const result = { changed,
|
|
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) {
|