@playcraft/cli 0.1.15 → 0.1.17
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/commands/login.js +79 -42
- package/dist/config.js +59 -4
- package/dist/utils/agent-api-client.js +3 -2
- package/dist/utils/environment-endpoints.js +17 -0
- package/dist/workspace-runtime/codex/app-server.js +69 -49
- package/dist/workspace-runtime/codex/config-toml.js +83 -0
- package/dist/workspace-runtime/codex/model-broker.js +79 -23
- package/dist/workspace-runtime/previews/publish.js +2 -4
- package/dist/workspace-runtime/serve.js +10 -6
- package/dist/workspace-runtime/server/connectivity.js +201 -0
- package/dist/workspace-runtime/server/dispatch.js +32 -12
- package/dist/workspace-runtime/server/endpoints.js +3 -3
- package/dist/workspace-runtime/server/http.js +15 -1
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +2 -11
- package/dist/workspace-runtime/workspaces/cli-credentials.js +2 -5
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +2 -3
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +2 -3
- package/package.json +3 -3
|
@@ -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, 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';
|
|
@@ -123,7 +124,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
123
124
|
}
|
|
124
125
|
try {
|
|
125
126
|
if (operation.kind === 'session.create') {
|
|
126
|
-
return executeCreate(state, scopeKey, current);
|
|
127
|
+
return executeCreate(state, scopeKey, current, payload);
|
|
127
128
|
}
|
|
128
129
|
if (operation.kind === 'session.turn') {
|
|
129
130
|
return executeTurn(state, scopeKey, current, payload);
|
|
@@ -171,13 +172,17 @@ async function executeSkillsUpdate(state, scopeKey, operation, payload) {
|
|
|
171
172
|
}));
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
|
-
async function executeCreate(state, scopeKey, operation) {
|
|
175
|
+
async function executeCreate(state, scopeKey, operation, payload) {
|
|
175
176
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
177
|
+
const selected = z.string().uuid().optional().safeParse(asRecord(payload).modelConfigId);
|
|
178
|
+
if (!selected.success)
|
|
179
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
|
|
176
180
|
let started;
|
|
177
181
|
try {
|
|
178
182
|
started = await native(state).startThread({
|
|
179
183
|
sessionId,
|
|
180
184
|
cwd: await workspaceRoot(state, operation.workspaceId),
|
|
185
|
+
modelConfigId: selected.data,
|
|
181
186
|
});
|
|
182
187
|
}
|
|
183
188
|
catch (error) {
|
|
@@ -195,10 +200,12 @@ async function executeCreate(state, scopeKey, operation) {
|
|
|
195
200
|
threadId: started.threadId,
|
|
196
201
|
cwd: await workspaceRoot(state, operation.workspaceId),
|
|
197
202
|
recoverability: 'native',
|
|
203
|
+
modelConfigId: started.modelConfigId,
|
|
198
204
|
});
|
|
199
205
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', {
|
|
200
206
|
nativeRequestId: `thread/start:${sessionId}`,
|
|
201
207
|
nativeThreadId: started.threadId,
|
|
208
|
+
modelConfigId: started.modelConfigId,
|
|
202
209
|
}));
|
|
203
210
|
await recordNativeEvents(state, accepted, [
|
|
204
211
|
{ type: 'session.created', payload: { sessionId, threadId: started.threadId }, occurredAt: new Date().toISOString() },
|
|
@@ -218,6 +225,9 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
218
225
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_already_active' }));
|
|
219
226
|
}
|
|
220
227
|
const body = asRecord(payload);
|
|
228
|
+
const selected = z.string().uuid().optional().safeParse(body.modelConfigId);
|
|
229
|
+
if (!selected.success)
|
|
230
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
|
|
221
231
|
let attachments;
|
|
222
232
|
try {
|
|
223
233
|
attachments = await resolveWorkspaceAttachments(await workspaceRoot(state, operation.workspaceId), body.attachmentIds);
|
|
@@ -233,15 +243,17 @@ async function executeTurn(state, scopeKey, operation, payload) {
|
|
|
233
243
|
text: String(body.text ?? '') + attachmentText,
|
|
234
244
|
images: attachments.filter((file) => file.mimeType.startsWith('image/')).map((file) => file.path),
|
|
235
245
|
clientRequestId: operation.clientRequestId,
|
|
246
|
+
modelConfigId: selected.data ?? binding.modelConfigId,
|
|
236
247
|
simulateLostReceipt: body.simulateLostReceipt === true,
|
|
237
|
-
persistReservation: async ({ nativeRequestId }) => {
|
|
248
|
+
persistReservation: async ({ nativeRequestId, modelConfigId }) => {
|
|
238
249
|
await persist(state, scopeKey, {
|
|
239
250
|
...operation,
|
|
240
251
|
nativeRequestId,
|
|
241
252
|
nativeThreadId: binding.threadId,
|
|
253
|
+
modelConfigId,
|
|
242
254
|
updatedAt: new Date().toISOString(),
|
|
243
255
|
});
|
|
244
|
-
await state.journal.putSession({ ...binding, turnStatus: 'starting' });
|
|
256
|
+
await state.journal.putSession({ ...binding, modelConfigId, turnStatus: 'starting' });
|
|
245
257
|
},
|
|
246
258
|
});
|
|
247
259
|
const reserved = state.journal.getOperation(scopeKey) ?? operation;
|
|
@@ -354,6 +366,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
354
366
|
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
355
367
|
sessionId: item.sessionId,
|
|
356
368
|
nativeThreadId: item.threadId,
|
|
369
|
+
modelConfigId: item.modelConfigId,
|
|
357
370
|
recoverability: 'history_only',
|
|
358
371
|
}));
|
|
359
372
|
const checkpoint = checkpointRecordSchema.parse({
|
|
@@ -385,7 +398,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
385
398
|
git: standalone ? 'full' : 'local',
|
|
386
399
|
});
|
|
387
400
|
// Transcripts only matter where another instance can read them back.
|
|
388
|
-
const transcripts = state.config.
|
|
401
|
+
const transcripts = state.config.checkpointObjects ? await captureTranscripts(state, operation.workspaceId) : [];
|
|
389
402
|
const digest = liveTreeDigest(tree, transcripts);
|
|
390
403
|
if (!requested && tree.status === 'complete') {
|
|
391
404
|
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
@@ -396,14 +409,14 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
396
409
|
return finishUnchanged(state, scopeKey, operation, previous);
|
|
397
410
|
}
|
|
398
411
|
}
|
|
399
|
-
if (state.config.
|
|
412
|
+
if (state.config.checkpointObjects) {
|
|
400
413
|
const stillActive = await activeManagedWrites(state, workspaceId);
|
|
401
414
|
if (stillActive.length > 0) {
|
|
402
415
|
return finishBestEffort(state, scopeKey, operation, checkpointId, workspaceId, createdAt, stillActive.map((item) => item.source));
|
|
403
416
|
}
|
|
404
417
|
try {
|
|
405
418
|
published = await publishFileSnapshot({
|
|
406
|
-
baseUrl: state.config.
|
|
419
|
+
baseUrl: platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.checkpointStore),
|
|
407
420
|
snapshotId: checkpointId,
|
|
408
421
|
workspaceId,
|
|
409
422
|
generation: state.config.generation,
|
|
@@ -411,7 +424,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
411
424
|
native: transcripts,
|
|
412
425
|
uploaded: workspaceSaves(state, workspaceId).uploaded,
|
|
413
426
|
uploadedTranscripts: workspaceSaves(state, workspaceId).transcripts,
|
|
414
|
-
headers: objectStoreHeaders(state
|
|
427
|
+
headers: objectStoreHeaders(state),
|
|
415
428
|
});
|
|
416
429
|
}
|
|
417
430
|
catch {
|
|
@@ -421,6 +434,7 @@ async function executeCheckpoint(state, scopeKey, operation, payload) {
|
|
|
421
434
|
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
422
435
|
sessionId: item.sessionId,
|
|
423
436
|
nativeThreadId: item.threadId,
|
|
437
|
+
modelConfigId: item.modelConfigId,
|
|
424
438
|
recoverability: 'history_only',
|
|
425
439
|
}));
|
|
426
440
|
const checkpoint = checkpointRecordSchema.parse({
|
|
@@ -513,13 +527,14 @@ async function executeRestore(state, scopeKey, operation, payload) {
|
|
|
513
527
|
if (!checkpoint.nativeRecoverable || threadReplaced) {
|
|
514
528
|
return {
|
|
515
529
|
...item,
|
|
530
|
+
modelConfigId: snap.modelConfigId ?? item.modelConfigId,
|
|
516
531
|
threadId,
|
|
517
532
|
recoverability: restarted ? 'history_only' : item.recoverability,
|
|
518
533
|
};
|
|
519
534
|
}
|
|
520
535
|
if (restarted)
|
|
521
|
-
return { ...item, threadId, recoverability: 'history_only' };
|
|
522
|
-
return { ...item, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
|
|
536
|
+
return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId, recoverability: 'history_only' };
|
|
537
|
+
return { ...item, modelConfigId: snap.modelConfigId ?? item.modelConfigId, threadId: snap.nativeThreadId ?? item.threadId, recoverability: 'native' };
|
|
523
538
|
});
|
|
524
539
|
for (const snap of checkpoint.sessions) {
|
|
525
540
|
if (!next.some((item) => item.sessionId === snap.sessionId)) {
|
|
@@ -527,6 +542,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
|
|
|
527
542
|
sessionId: snap.sessionId,
|
|
528
543
|
workspaceId: operation.workspaceId,
|
|
529
544
|
threadId: snap.nativeThreadId ?? `thread_${snap.sessionId}`,
|
|
545
|
+
modelConfigId: snap.modelConfigId,
|
|
530
546
|
cwd,
|
|
531
547
|
recoverability: checkpoint.nativeRecoverable && !restarted ? 'native' : 'history_only',
|
|
532
548
|
});
|
|
@@ -545,6 +561,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
|
|
|
545
561
|
threadId,
|
|
546
562
|
sessionId: snap.sessionId,
|
|
547
563
|
cwd: found.cwd,
|
|
564
|
+
modelConfigId: found.modelConfigId,
|
|
548
565
|
});
|
|
549
566
|
if (!continued || found.threadId !== threadId)
|
|
550
567
|
continue;
|
|
@@ -944,12 +961,14 @@ async function captureNativeSessions(state, workspaceId) {
|
|
|
944
961
|
? {
|
|
945
962
|
sessionId: item.sessionId,
|
|
946
963
|
nativeThreadId: item.threadId,
|
|
964
|
+
modelConfigId: item.modelConfigId,
|
|
947
965
|
nativeDurableTurnId: durable,
|
|
948
966
|
recoverability: 'native',
|
|
949
967
|
}
|
|
950
968
|
: {
|
|
951
969
|
sessionId: item.sessionId,
|
|
952
970
|
nativeThreadId: item.threadId,
|
|
971
|
+
modelConfigId: item.modelConfigId,
|
|
953
972
|
recoverability: 'history_only',
|
|
954
973
|
});
|
|
955
974
|
}
|
|
@@ -984,6 +1003,7 @@ async function finishBestEffort(state, scopeKey, operation, checkpointId, worksp
|
|
|
984
1003
|
const sessions = workspaceSessions(state, operation.workspaceId).map((item) => ({
|
|
985
1004
|
sessionId: item.sessionId,
|
|
986
1005
|
nativeThreadId: item.threadId,
|
|
1006
|
+
modelConfigId: item.modelConfigId,
|
|
987
1007
|
recoverability: 'history_only',
|
|
988
1008
|
}));
|
|
989
1009
|
const checkpoint = checkpointRecordSchema.parse({
|
|
@@ -1074,7 +1094,7 @@ function nativeIds(params) {
|
|
|
1074
1094
|
};
|
|
1075
1095
|
}
|
|
1076
1096
|
async function notifyCallback(state, events) {
|
|
1077
|
-
await fetch(state.config.
|
|
1097
|
+
await fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
|
|
1078
1098
|
method: 'POST',
|
|
1079
1099
|
headers: {
|
|
1080
1100
|
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) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
6
6
|
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
7
7
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
@@ -17,6 +17,7 @@ import { readWorkspaceSkills } from '../workspaces/skills.js';
|
|
|
17
17
|
import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { terminalHost } from './terminal-host.js';
|
|
20
|
+
import { probeRuntimeConnectivity } from './connectivity.js';
|
|
20
21
|
export async function handleRuntimeRequest(state, request, response) {
|
|
21
22
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
22
23
|
const access = authorizeRuntime(state, request);
|
|
@@ -33,6 +34,19 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
33
34
|
writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
37
|
+
if (request.method === 'GET' && url.pathname === '/runtime/connectivity') {
|
|
38
|
+
if (writeDestroyed(state, response))
|
|
39
|
+
return;
|
|
40
|
+
const parsed = runtimeConnectivityDocumentSchema.shape.workspaceId.safeParse(url.searchParams.get('workspaceId'));
|
|
41
|
+
if (!parsed.success) {
|
|
42
|
+
writeJson(response, 400, { error: 'invalid_workspace' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const workspaceId = parsed.data;
|
|
46
|
+
await workspaceRoot(state, workspaceId);
|
|
47
|
+
writeJson(response, 200, await probeRuntimeConnectivity(state, workspaceId));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
36
50
|
if (request.method === 'GET' && url.pathname === '/runtime/identity') {
|
|
37
51
|
const { runtimeId, userId, environment, generation } = state.config;
|
|
38
52
|
writeJson(response, 200, {
|
|
@@ -58,17 +58,8 @@ export async function rememberFileCheckpoint(state, workspaceId, saved) {
|
|
|
58
58
|
await rm(join(state.config.stateDir, 'file-checkpoints', checkpointId), { recursive: true, force: true }).catch(() => undefined);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
*/
|
|
64
|
-
export function objectStoreHeaders(state, storeUrl) {
|
|
65
|
-
try {
|
|
66
|
-
if (new URL(storeUrl).origin !== new URL(state.config.callbackUrl).origin)
|
|
67
|
-
return {};
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return {};
|
|
71
|
-
}
|
|
61
|
+
/** Checkpoint uploads always go to this deployment, so they always carry the control credential. */
|
|
62
|
+
export function objectStoreHeaders(state) {
|
|
72
63
|
return {
|
|
73
64
|
authorization: `Bearer ${state.config.controlToken}`,
|
|
74
65
|
'playcraft-env': state.config.environment,
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
6
|
const responseSchema = z.object({
|
|
6
7
|
token: z.string().min(1),
|
|
7
8
|
apiUrl: z.string().url(),
|
|
@@ -36,11 +37,7 @@ export async function ensureCliCredentials(state) {
|
|
|
36
37
|
async function renew(state, entry) {
|
|
37
38
|
const config = state.config;
|
|
38
39
|
const epoch = state.runtimeEpoch;
|
|
39
|
-
const endpoint =
|
|
40
|
-
if (endpoint.protocol !== 'https:' &&
|
|
41
|
-
!(endpoint.protocol === 'http:' &&
|
|
42
|
-
['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
43
|
-
throw new Error('cli_callback_insecure');
|
|
40
|
+
const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.cliCredentials);
|
|
44
41
|
try {
|
|
45
42
|
const response = await fetch(endpoint, {
|
|
46
43
|
method: 'POST',
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
10
10
|
import { WorkspaceGitDirectStore } from './git-direct-store.js';
|
|
11
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
11
12
|
const exec = promisify(execFile);
|
|
12
13
|
/** Only this control process sees the callback credential. Development processes use the local socket. */
|
|
13
14
|
export class WorkspaceGitCredentialBroker {
|
|
@@ -164,9 +165,7 @@ export class WorkspaceGitCredentialBroker {
|
|
|
164
165
|
await this.direct.sweep(true);
|
|
165
166
|
}
|
|
166
167
|
async refresh(binding, key) {
|
|
167
|
-
const endpoint =
|
|
168
|
-
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
169
|
-
throw new Error('git_broker_unavailable');
|
|
168
|
+
const endpoint = platformEndpoint(this.state.config.publicBaseUrl, PLATFORM_ROUTES.gitCredentials);
|
|
170
169
|
const epoch = this.state.runtimeEpoch;
|
|
171
170
|
const response = await fetch(endpoint, {
|
|
172
171
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { workspaceRoot } from './registry.js';
|
|
3
|
+
import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
4
|
const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024;
|
|
4
5
|
/** Requests an exact locked artifact using control identity; no storage credentials reach Codex. */
|
|
5
6
|
export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
|
|
@@ -7,9 +8,7 @@ export async function loadWorkspaceSkillArtifact(state, workspaceId, entry) {
|
|
|
7
8
|
const epoch = state.runtimeEpoch;
|
|
8
9
|
if (state.journal.destroyed())
|
|
9
10
|
throw new Error('workspace_skills_unavailable');
|
|
10
|
-
const endpoint =
|
|
11
|
-
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
|
|
12
|
-
throw new Error('workspace_skills_callback_insecure');
|
|
11
|
+
const endpoint = platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.skillArtifacts);
|
|
13
12
|
const response = await fetch(endpoint, {
|
|
14
13
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
|
|
15
14
|
headers: { authorization: `Bearer ${state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': state.config.environment },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.17",
|
|
52
|
+
"@playcraft/common": "^0.1.17",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|