@playcraft/cli 0.1.25 → 0.1.27
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/workspace-runtime/codex/app-server.js +27 -18
- package/dist/workspace-runtime/codex/config-toml.js +0 -2
- package/dist/workspace-runtime/codex/model-broker.js +27 -8
- package/dist/workspace-runtime/server/dispatch.js +34 -7
- package/dist/workspace-runtime/server/http.js +8 -0
- package/dist/workspace-runtime/workspaces/attachment-content.js +111 -0
- package/dist/workspace-runtime/workspaces/git.js +47 -1
- package/dist/workspace-runtime/workspaces/registry.js +28 -2
- package/package.json +3 -3
|
@@ -10,6 +10,9 @@ import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
|
|
|
10
10
|
import { collectedEvent, NativePreparationError } from './native-adapter.js';
|
|
11
11
|
import { readProjectSkillsLockFile } from '@playcraft/common/project-skills';
|
|
12
12
|
import { freezeWorkspaceSkills } from '../workspaces/skills.js';
|
|
13
|
+
function codexReasoningEffort(effort) {
|
|
14
|
+
return effort === 'max' ? 'xhigh' : effort;
|
|
15
|
+
}
|
|
13
16
|
function asRecord(value) {
|
|
14
17
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
15
18
|
? value
|
|
@@ -85,24 +88,25 @@ export function createCodexAppServerAdapter(options) {
|
|
|
85
88
|
startPromise ??= start();
|
|
86
89
|
await startPromise;
|
|
87
90
|
}
|
|
88
|
-
async function threadConfig(cwd, modelConfigId, sessionId) {
|
|
91
|
+
async function threadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
|
|
89
92
|
try {
|
|
90
|
-
return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId)
|
|
93
|
+
return await options.modelBroker?.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort)
|
|
91
94
|
?? { ...options.fixedThreadConfig, modelConfigId: undefined, activate: () => { } };
|
|
92
95
|
}
|
|
93
96
|
catch (error) {
|
|
94
97
|
const reason = error instanceof Error ? error.message : '';
|
|
95
98
|
throw new NativePreparationError(['model_config_required',
|
|
96
|
-
'model_credential_scope_mismatch', 'model_config_denied', 'workspace_not_registered'].includes(reason)
|
|
99
|
+
'model_credential_scope_mismatch', 'model_config_denied', 'reasoning_effort_denied', 'reasoning_effort_unsupported', 'workspace_not_registered'].includes(reason)
|
|
97
100
|
? reason : 'model_credential_unavailable');
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
|
-
async function lockedThreadConfig(cwd, selectedConfigId, sessionId) {
|
|
101
|
-
const { modelConfigId, activate, ...providedModelConfig } = await threadConfig(cwd, selectedConfigId, sessionId);
|
|
103
|
+
async function lockedThreadConfig(cwd, selectedConfigId, sessionId, reasoningEffort) {
|
|
104
|
+
const { modelConfigId, activate, ...providedModelConfig } = await threadConfig(cwd, selectedConfigId, sessionId, reasoningEffort);
|
|
102
105
|
// Both local and managed threads must use the verified portable catalog.
|
|
103
106
|
const modelConfig = { ...providedModelConfig, config: {
|
|
104
107
|
...('config' in providedModelConfig ? asRecord(providedModelConfig.config) : {}),
|
|
105
108
|
...portableCodexConfig(catalogDirectory ? join(catalogDirectory, 'portable-models.json') : undefined),
|
|
109
|
+
...(reasoningEffort ? { model_reasoning_effort: codexReasoningEffort(reasoningEffort) } : {}),
|
|
106
110
|
} };
|
|
107
111
|
const scope = relative(options.config.workspaceRoot, cwd);
|
|
108
112
|
const orchestration = options.config.workspaceMode === 'multi' && /^[0-9a-f-]{36}$/i.test(scope);
|
|
@@ -175,7 +179,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
175
179
|
throw new NativePreparationError('workspace_skills_unavailable');
|
|
176
180
|
}
|
|
177
181
|
}
|
|
178
|
-
async function restartForSkills(threadId, selectedConfigId) {
|
|
182
|
+
async function restartForSkills(threadId, selectedConfigId, reasoningEffort) {
|
|
179
183
|
if ([...threads.values()].some(thread => thread.turnActive) || approvals.size)
|
|
180
184
|
throw new NativePreparationError('skills_reload_pending');
|
|
181
185
|
const previous = live;
|
|
@@ -206,7 +210,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
206
210
|
// Other workspaces resume lazily, so a damaged unrelated lock cannot block this workspace.
|
|
207
211
|
const thread = threads.get(threadId);
|
|
208
212
|
if (thread?.hasNativeHistory && thread.cwd)
|
|
209
|
-
await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd, selectedConfigId ?? thread.modelConfigId, thread.sessionId));
|
|
213
|
+
await resumePreparedThread(threadId, thread, await lockedThreadConfig(thread.cwd, selectedConfigId ?? thread.modelConfigId, thread.sessionId, reasoningEffort));
|
|
210
214
|
}
|
|
211
215
|
catch {
|
|
212
216
|
readyFlag = false;
|
|
@@ -452,7 +456,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
452
456
|
await ensureStarted();
|
|
453
457
|
if (!rpc)
|
|
454
458
|
throw new Error('codex_not_started');
|
|
455
|
-
const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId);
|
|
459
|
+
const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId, input.reasoningEffort);
|
|
456
460
|
const result = await rpc.request('thread/start', {
|
|
457
461
|
cwd: input.cwd,
|
|
458
462
|
model,
|
|
@@ -463,8 +467,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
463
467
|
throw new Error('native_receipt_lost');
|
|
464
468
|
prepared.activate();
|
|
465
469
|
threads.set(threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
|
|
466
|
-
discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: false, loaded: true });
|
|
467
|
-
return { threadId, modelConfigId: prepared.modelConfigId };
|
|
470
|
+
discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, hasNativeHistory: false, loaded: true });
|
|
471
|
+
return { threadId, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort };
|
|
468
472
|
});
|
|
469
473
|
},
|
|
470
474
|
async resumeThread(input) {
|
|
@@ -472,7 +476,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
472
476
|
await ensureStarted();
|
|
473
477
|
if (!rpc)
|
|
474
478
|
throw new Error('codex_not_started');
|
|
475
|
-
const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId);
|
|
479
|
+
const prepared = await lockedThreadConfig(input.cwd, input.modelConfigId, input.sessionId, input.reasoningEffort);
|
|
476
480
|
let resumedId;
|
|
477
481
|
try {
|
|
478
482
|
const resumed = await rpc.request('thread/resume', {
|
|
@@ -487,8 +491,8 @@ export function createCodexAppServerAdapter(options) {
|
|
|
487
491
|
return undefined;
|
|
488
492
|
prepared.activate();
|
|
489
493
|
threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
|
|
490
|
-
discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, hasNativeHistory: true, loaded: true });
|
|
491
|
-
return { modelConfigId: prepared.modelConfigId };
|
|
494
|
+
discoveryHash: prepared.discoveryHash, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, hasNativeHistory: true, loaded: true });
|
|
495
|
+
return { modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort };
|
|
492
496
|
});
|
|
493
497
|
},
|
|
494
498
|
async startTurn(input) {
|
|
@@ -506,11 +510,13 @@ export function createCodexAppServerAdapter(options) {
|
|
|
506
510
|
return { outcome: 'failed', error: 'workspace_skills_changed' };
|
|
507
511
|
let selectedModel;
|
|
508
512
|
let modelConfigId = thread.modelConfigId;
|
|
513
|
+
const modelChanged = input.modelConfigId !== undefined && input.modelConfigId !== thread.modelConfigId;
|
|
514
|
+
const reasoningEffort = input.reasoningEffort ?? (modelChanged ? undefined : thread.reasoningEffort);
|
|
509
515
|
let activateModel = () => { };
|
|
510
516
|
let execution;
|
|
511
517
|
if (thread.cwd) {
|
|
512
518
|
try {
|
|
513
|
-
const prepared = await lockedThreadConfig(thread.cwd, input.modelConfigId ?? thread.modelConfigId, input.sessionId);
|
|
519
|
+
const prepared = await lockedThreadConfig(thread.cwd, input.modelConfigId ?? thread.modelConfigId, input.sessionId, reasoningEffort);
|
|
514
520
|
selectedModel = 'model' in prepared.params ? prepared.params.model : undefined;
|
|
515
521
|
modelConfigId = prepared.modelConfigId;
|
|
516
522
|
activateModel = prepared.activate;
|
|
@@ -521,7 +527,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
521
527
|
return { outcome: 'failed', error: 'workspace_skills_changed' };
|
|
522
528
|
}
|
|
523
529
|
else if (thread.skillsHash && (prepared.skillsHash !== thread.skillsHash || prepared.discoveryHash !== thread.discoveryHash)) {
|
|
524
|
-
await restartForSkills(input.threadId, prepared.modelConfigId);
|
|
530
|
+
await restartForSkills(input.threadId, prepared.modelConfigId, reasoningEffort);
|
|
525
531
|
}
|
|
526
532
|
if (!thread.loaded)
|
|
527
533
|
await resumePreparedThread(input.threadId, thread, prepared);
|
|
@@ -533,9 +539,10 @@ export function createCodexAppServerAdapter(options) {
|
|
|
533
539
|
}
|
|
534
540
|
}
|
|
535
541
|
const requestId = rpc.reserveRequest();
|
|
536
|
-
await input.persistReservation({ nativeRequestId: String(requestId), modelConfigId });
|
|
542
|
+
await input.persistReservation({ nativeRequestId: String(requestId), modelConfigId, reasoningEffort });
|
|
537
543
|
activateModel();
|
|
538
544
|
thread.modelConfigId = modelConfigId;
|
|
545
|
+
thread.reasoningEffort = reasoningEffort;
|
|
539
546
|
if (input.simulateLostReceipt)
|
|
540
547
|
return { outcome: 'uncertain', threadId: input.threadId };
|
|
541
548
|
try {
|
|
@@ -543,7 +550,9 @@ export function createCodexAppServerAdapter(options) {
|
|
|
543
550
|
const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
|
|
544
551
|
{ type: 'text', text: input.text },
|
|
545
552
|
...(input.images ?? []).map((path) => ({ type: 'localImage', path })),
|
|
546
|
-
], ...(selectedModel ? { model: selectedModel } : {}),
|
|
553
|
+
], ...(selectedModel ? { model: selectedModel } : {}),
|
|
554
|
+
...(reasoningEffort ? { effort: codexReasoningEffort(reasoningEffort) } : modelChanged ? { effort: null } : {}),
|
|
555
|
+
...execution }, turnTimeoutMs);
|
|
547
556
|
const turnId = started.turn?.id;
|
|
548
557
|
if (!turnId)
|
|
549
558
|
return { outcome: 'uncertain', threadId: input.threadId };
|
|
@@ -554,7 +563,7 @@ export function createCodexAppServerAdapter(options) {
|
|
|
554
563
|
acceptedThread.modelConfigId = modelConfigId;
|
|
555
564
|
acceptedThread.turnActive = acceptedThread.lastCompletedTurnId !== turnId;
|
|
556
565
|
}
|
|
557
|
-
return { outcome: 'accepted', threadId: input.threadId, turnId, modelConfigId };
|
|
566
|
+
return { outcome: 'accepted', threadId: input.threadId, turnId, modelConfigId, reasoningEffort };
|
|
558
567
|
}
|
|
559
568
|
catch {
|
|
560
569
|
return { outcome: 'uncertain', threadId: input.threadId };
|
|
@@ -9,6 +9,7 @@ import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraf
|
|
|
9
9
|
const credentialSchema = z.object({
|
|
10
10
|
token: z.string().min(1), expiresAt: z.string().datetime(),
|
|
11
11
|
configId: z.string().uuid(), model: z.string().min(1).max(128),
|
|
12
|
+
supportsReasoning: z.boolean().optional(),
|
|
12
13
|
claims: z.object({
|
|
13
14
|
environment: z.string(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
|
|
14
15
|
generation: z.number().int(), runtimeEpoch: z.number().int(),
|
|
@@ -43,12 +44,12 @@ export class WorkspaceModelBroker {
|
|
|
43
44
|
throw new Error('model_broker_unavailable');
|
|
44
45
|
this.baseUrl = `http://127.0.0.1:${address.port}`;
|
|
45
46
|
}
|
|
46
|
-
async threadConfig(cwd, modelConfigId, sessionId) {
|
|
47
|
-
const { activate, ...settings } = await this.prepareThreadConfig(cwd, modelConfigId, sessionId);
|
|
47
|
+
async threadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
|
|
48
|
+
const { activate, ...settings } = await this.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort);
|
|
48
49
|
activate();
|
|
49
50
|
return settings;
|
|
50
51
|
}
|
|
51
|
-
async prepareThreadConfig(cwd, modelConfigId, sessionId) {
|
|
52
|
+
async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
|
|
52
53
|
if (!this.server)
|
|
53
54
|
throw new Error('workspace_not_registered');
|
|
54
55
|
const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
|
|
@@ -57,10 +58,11 @@ export class WorkspaceModelBroker {
|
|
|
57
58
|
const routeKey = sessionId ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}` : workspaceId;
|
|
58
59
|
let route = this.routes.get(routeKey);
|
|
59
60
|
const selected = modelConfigId ?? (route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
|
|
60
|
-
const cacheKey = `${workspaceId}:${selected ?? 'default'}`;
|
|
61
|
-
let entry = this.entries.get(cacheKey) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected
|
|
61
|
+
const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}`;
|
|
62
|
+
let entry = this.entries.get(cacheKey) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected
|
|
63
|
+
&& item.reasoningEffort === reasoningEffort);
|
|
62
64
|
if (!entry || entry.epoch !== this.state.runtimeEpoch) {
|
|
63
|
-
entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected };
|
|
65
|
+
entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort };
|
|
64
66
|
this.entries.set(cacheKey, entry);
|
|
65
67
|
}
|
|
66
68
|
// Resolve current model metadata and permission at every native turn boundary.
|
|
@@ -113,11 +115,12 @@ export class WorkspaceModelBroker {
|
|
|
113
115
|
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
114
116
|
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
|
|
115
117
|
runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId,
|
|
116
|
-
...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {})
|
|
118
|
+
...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
|
|
119
|
+
...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}) }),
|
|
117
120
|
});
|
|
118
121
|
if (!response.ok) {
|
|
119
122
|
const error = await response.json().catch(() => ({}));
|
|
120
|
-
throw new Error(['model_config_required', 'model_config_denied'].includes(error.reason ?? '') ? error.reason : 'model_credential_unavailable');
|
|
123
|
+
throw new Error(['model_config_required', 'model_config_denied', 'reasoning_effort_denied', 'reasoning_effort_unsupported'].includes(error.reason ?? '') ? error.reason : 'model_credential_unavailable');
|
|
121
124
|
}
|
|
122
125
|
const credential = credentialSchema.parse(await response.json());
|
|
123
126
|
const claims = credential.claims;
|
|
@@ -176,6 +179,22 @@ export class WorkspaceModelBroker {
|
|
|
176
179
|
}
|
|
177
180
|
// The browser selects a configuration, never an arbitrary billable upstream model.
|
|
178
181
|
body.model = selected.model;
|
|
182
|
+
if (credential.supportsReasoning === false) {
|
|
183
|
+
const reasoning = body.reasoning;
|
|
184
|
+
if (reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning)) {
|
|
185
|
+
delete reasoning.effort;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (entry.reasoningEffort) {
|
|
189
|
+
const reasoning = body.reasoning;
|
|
190
|
+
const modelName = selected.model.split('/').pop() ?? selected.model;
|
|
191
|
+
const effort = entry.reasoningEffort === 'max' && /^o\d|gpt-[5-9]/i.test(modelName)
|
|
192
|
+
? 'xhigh' : entry.reasoningEffort;
|
|
193
|
+
body.reasoning = {
|
|
194
|
+
...(reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning) ? reasoning : {}),
|
|
195
|
+
effort,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
179
198
|
const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
|
|
180
199
|
try {
|
|
181
200
|
const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
|
|
@@ -2,7 +2,7 @@ import { withObservedFile } from '../workspaces/revisions.js';
|
|
|
2
2
|
import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, reasoningEffortSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { closeCliCredentials } from '../workspaces/cli-credentials.js';
|
|
7
7
|
import { artifactFormat } from '../workspaces/artifact-files.js';
|
|
8
8
|
import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
|
|
@@ -163,12 +163,16 @@ async function executeCreate(state, scopeKey, operation, payload) {
|
|
|
163
163
|
const selected = z.string().uuid().optional().safeParse(asRecord(payload).modelConfigId);
|
|
164
164
|
if (!selected.success)
|
|
165
165
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
|
|
166
|
+
const effort = reasoningEffortSchema.optional().safeParse(asRecord(payload).reasoningEffort);
|
|
167
|
+
if (!effort.success)
|
|
168
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'reasoning_effort_denied' }));
|
|
166
169
|
let started;
|
|
167
170
|
try {
|
|
168
171
|
started = await native(state).startThread({
|
|
169
172
|
sessionId,
|
|
170
173
|
cwd: await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName),
|
|
171
174
|
modelConfigId: selected.data,
|
|
175
|
+
reasoningEffort: effort.data,
|
|
172
176
|
});
|
|
173
177
|
}
|
|
174
178
|
catch (error) {
|
|
@@ -188,11 +192,13 @@ async function executeCreate(state, scopeKey, operation, payload) {
|
|
|
188
192
|
cwd: await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName),
|
|
189
193
|
recoverability: 'native',
|
|
190
194
|
modelConfigId: started.modelConfigId,
|
|
195
|
+
reasoningEffort: started.reasoningEffort ?? effort.data,
|
|
191
196
|
});
|
|
192
197
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', {
|
|
193
198
|
nativeRequestId: `thread/start:${sessionId}`,
|
|
194
199
|
nativeThreadId: started.threadId,
|
|
195
200
|
modelConfigId: started.modelConfigId,
|
|
201
|
+
reasoningEffort: started.reasoningEffort ?? effort.data,
|
|
196
202
|
}));
|
|
197
203
|
await recordNativeEvents(state, accepted, [
|
|
198
204
|
{ type: 'session.created', payload: { sessionId, threadId: started.threadId }, occurredAt: new Date().toISOString() },
|
|
@@ -205,9 +211,14 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
205
211
|
const selected = z.string().uuid().optional().safeParse(body.modelConfigId);
|
|
206
212
|
if (!selected.success)
|
|
207
213
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'model_config_denied' }));
|
|
214
|
+
const effort = reasoningEffortSchema.optional().safeParse(body.reasoningEffort);
|
|
215
|
+
if (!effort.success)
|
|
216
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'reasoning_effort_denied' }));
|
|
208
217
|
let found = state.journal.getSession(sessionId);
|
|
218
|
+
const modelChanged = selected.data !== undefined && selected.data !== (found?.modelConfigId ?? recovery?.modelConfigId);
|
|
219
|
+
const requestedEffort = effort.data ?? (modelChanged ? undefined : recovery?.reasoningEffort ?? found?.reasoningEffort);
|
|
209
220
|
if (recovery && found?.recoverability !== 'native') {
|
|
210
|
-
const rebound = await rebindSession(state, operation, found, selected.data ?? recovery.modelConfigId);
|
|
221
|
+
const rebound = await rebindSession(state, operation, found, selected.data ?? recovery.modelConfigId, requestedEffort);
|
|
211
222
|
if (typeof rebound === 'string') {
|
|
212
223
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: rebound }));
|
|
213
224
|
}
|
|
@@ -246,16 +257,19 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
246
257
|
images: attachments.filter((file) => file.mimeType.startsWith('image/')).map((file) => file.path),
|
|
247
258
|
clientRequestId: operation.clientRequestId,
|
|
248
259
|
modelConfigId: selected.data ?? binding.modelConfigId,
|
|
260
|
+
reasoningEffort: requestedEffort,
|
|
249
261
|
simulateLostReceipt: body.simulateLostReceipt === true,
|
|
250
|
-
persistReservation: async ({ nativeRequestId, modelConfigId }) => {
|
|
262
|
+
persistReservation: async ({ nativeRequestId, modelConfigId, reasoningEffort }) => {
|
|
263
|
+
const selectedEffort = reasoningEffort ?? requestedEffort;
|
|
251
264
|
await persist(state, scopeKey, {
|
|
252
265
|
...operation,
|
|
253
266
|
nativeRequestId,
|
|
254
267
|
nativeThreadId: binding.threadId,
|
|
255
268
|
modelConfigId,
|
|
269
|
+
reasoningEffort: selectedEffort,
|
|
256
270
|
updatedAt: new Date().toISOString(),
|
|
257
271
|
});
|
|
258
|
-
await state.journal.putSession({ ...binding, modelConfigId, turnStatus: 'starting' });
|
|
272
|
+
await state.journal.putSession({ ...binding, modelConfigId, reasoningEffort: selectedEffort, turnStatus: 'starting' });
|
|
259
273
|
},
|
|
260
274
|
});
|
|
261
275
|
const reserved = state.journal.getOperation(scopeKey) ?? operation;
|
|
@@ -323,7 +337,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
323
337
|
* carry the earlier conversation until a turn starts on it.
|
|
324
338
|
* Returns an error code when the thread cannot be prepared.
|
|
325
339
|
*/
|
|
326
|
-
async function rebindSession(state, operation, binding, modelConfigId) {
|
|
340
|
+
async function rebindSession(state, operation, binding, modelConfigId, reasoningEffort) {
|
|
327
341
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
328
342
|
const adapter = native(state);
|
|
329
343
|
const cwd = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
@@ -335,6 +349,7 @@ async function rebindSession(state, operation, binding, modelConfigId) {
|
|
|
335
349
|
threadId: binding.threadId,
|
|
336
350
|
cwd,
|
|
337
351
|
modelConfigId: modelConfigId ?? binding.modelConfigId,
|
|
352
|
+
reasoningEffort,
|
|
338
353
|
});
|
|
339
354
|
if (resumed) {
|
|
340
355
|
const next = {
|
|
@@ -342,6 +357,7 @@ async function rebindSession(state, operation, binding, modelConfigId) {
|
|
|
342
357
|
cwd,
|
|
343
358
|
recoverability: 'native',
|
|
344
359
|
modelConfigId: resumed.modelConfigId ?? binding.modelConfigId,
|
|
360
|
+
reasoningEffort: resumed.reasoningEffort ?? reasoningEffort,
|
|
345
361
|
turnStatus: undefined,
|
|
346
362
|
turnBlock: undefined,
|
|
347
363
|
activeTurnId: undefined,
|
|
@@ -351,7 +367,8 @@ async function rebindSession(state, operation, binding, modelConfigId) {
|
|
|
351
367
|
return next;
|
|
352
368
|
}
|
|
353
369
|
}
|
|
354
|
-
const started = await adapter.startThread({ sessionId, cwd, modelConfigId: modelConfigId ?? binding?.modelConfigId
|
|
370
|
+
const started = await adapter.startThread({ sessionId, cwd, modelConfigId: modelConfigId ?? binding?.modelConfigId,
|
|
371
|
+
reasoningEffort });
|
|
355
372
|
const next = {
|
|
356
373
|
sessionId,
|
|
357
374
|
workspaceId: operation.workspaceId,
|
|
@@ -360,6 +377,7 @@ async function rebindSession(state, operation, binding, modelConfigId) {
|
|
|
360
377
|
cwd,
|
|
361
378
|
recoverability: 'native',
|
|
362
379
|
modelConfigId: started.modelConfigId,
|
|
380
|
+
reasoningEffort: started.reasoningEffort ?? reasoningEffort,
|
|
363
381
|
recoveryContextPending: true,
|
|
364
382
|
};
|
|
365
383
|
await state.journal.putSession(next);
|
|
@@ -563,6 +581,15 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
|
|
|
563
581
|
* and the Git gateway never lets a workspace write its project's main branch, so those two only
|
|
564
582
|
* commit.
|
|
565
583
|
*/
|
|
584
|
+
/**
|
|
585
|
+
* Whether a save pushes after it commits. A workspace bound to a project (its main branch or a
|
|
586
|
+
* Remix branch) pushes the way the Git panel does; the credential decides whether the remote
|
|
587
|
+
* accepts it. A standalone workspace has no remote, so its saves stay local; its project
|
|
588
|
+
* directories have their own branch.
|
|
589
|
+
*/
|
|
590
|
+
export function savePushesBranch(targetKind, directoryName) {
|
|
591
|
+
return targetKind === 'main' || targetKind === 'remix' || Boolean(directoryName && directoryName !== 'basic');
|
|
592
|
+
}
|
|
566
593
|
async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
567
594
|
const parsed = workspaceSavePayloadSchema.safeParse(payload ?? {});
|
|
568
595
|
if (!parsed.success) {
|
|
@@ -572,7 +599,7 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
|
572
599
|
try {
|
|
573
600
|
const saved = await saveWorkspaceGit(await workspaceRoot(state, operation.workspaceId, operation.directoryName), {
|
|
574
601
|
message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
|
|
575
|
-
push: binding?.targetKind
|
|
602
|
+
push: savePushesBranch(binding?.targetKind, operation.directoryName),
|
|
576
603
|
boundBranch: boundBranch(state, operation),
|
|
577
604
|
});
|
|
578
605
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
@@ -12,6 +12,7 @@ import { readDependencies } from '../workspaces/dependencies.js';
|
|
|
12
12
|
import { desktopHost, desktopRoute } from './desktop-host.js';
|
|
13
13
|
import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
|
|
14
14
|
import { readAttachmentUpload, uploadWorkspaceAttachment, uploadWorkspaceVideoAttachment } from '../workspaces/attachments.js';
|
|
15
|
+
import { serveWorkspaceAttachment } from '../workspaces/attachment-content.js';
|
|
15
16
|
import { readWorkspaceSkills } from '../workspaces/skills.js';
|
|
16
17
|
import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
|
|
17
18
|
import { join } from 'node:path';
|
|
@@ -190,6 +191,13 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
190
191
|
}
|
|
191
192
|
return;
|
|
192
193
|
}
|
|
194
|
+
if (['GET', 'HEAD'].includes(request.method ?? '') && url.pathname === '/runtime/attachment-content') {
|
|
195
|
+
if (writeDestroyed(state, response))
|
|
196
|
+
return;
|
|
197
|
+
const root = await workspaceRoot(state, url.searchParams.get('workspaceId') ?? '', directoryName);
|
|
198
|
+
await serveWorkspaceAttachment(root, url.searchParams.get('attachmentId') ?? '', request, response);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
193
201
|
if (request.method === 'GET' && url.pathname === '/runtime/artifacts') {
|
|
194
202
|
if (writeDestroyed(state, response))
|
|
195
203
|
return;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { extname } from 'node:path';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, WORKSPACE_USER_UPLOADS_DIRECTORY, WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { artifactMime, artifactPath } from './artifact-files.js';
|
|
7
|
+
const additionalMediaTypes = {
|
|
8
|
+
'.aac': 'audio/aac', '.opus': 'audio/ogg', '.oga': 'audio/ogg', '.aif': 'audio/aiff', '.aiff': 'audio/aiff',
|
|
9
|
+
'.bmp': 'image/bmp', '.ico': 'image/vnd.microsoft.icon', '.heic': 'image/heic', '.heif': 'image/heif',
|
|
10
|
+
'.tif': 'image/tiff', '.tiff': 'image/tiff',
|
|
11
|
+
};
|
|
12
|
+
const detectedImageTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
13
|
+
function attachmentRange(header, size) {
|
|
14
|
+
if (!header)
|
|
15
|
+
return undefined;
|
|
16
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
17
|
+
if (!match || (!match[1] && !match[2]) || !size)
|
|
18
|
+
return null;
|
|
19
|
+
const first = match[1] ? Number(match[1]) : undefined;
|
|
20
|
+
const last = match[2] ? Number(match[2]) : undefined;
|
|
21
|
+
if ((first !== undefined && !Number.isSafeInteger(first)) || (last !== undefined && !Number.isSafeInteger(last)))
|
|
22
|
+
return null;
|
|
23
|
+
const start = first ?? Math.max(0, size - (last ?? 0));
|
|
24
|
+
const end = first === undefined ? size - 1 : Math.min(last ?? size - 1, size - 1);
|
|
25
|
+
return start >= size || start > end ? null : { start, end };
|
|
26
|
+
}
|
|
27
|
+
async function openAttachment(root, id) {
|
|
28
|
+
const metadataPath = await artifactPath(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata/${id}.json`);
|
|
29
|
+
const metadataFile = await open(metadataPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
30
|
+
let metadata;
|
|
31
|
+
try {
|
|
32
|
+
const stat = await metadataFile.stat();
|
|
33
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 8192)
|
|
34
|
+
throw new Error('attachment_unavailable');
|
|
35
|
+
metadata = JSON.parse(await metadataFile.readFile('utf8'));
|
|
36
|
+
if (metadata.id !== id || !attachmentUploadSchema.shape.name.safeParse(metadata.name).success)
|
|
37
|
+
throw new Error('attachment_unavailable');
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await metadataFile.close();
|
|
41
|
+
}
|
|
42
|
+
const path = await artifactPath(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}/${metadata.name}`);
|
|
43
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
44
|
+
try {
|
|
45
|
+
const stat = await handle.stat();
|
|
46
|
+
const mimeType = detectedImageTypes.has(metadata.mimeType) ? metadata.mimeType
|
|
47
|
+
: additionalMediaTypes[extname(metadata.name).toLowerCase()] ?? artifactMime(metadata.name);
|
|
48
|
+
const limit = mimeType.startsWith('video/') ? WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES : WORKSPACE_ATTACHMENT_MAX_BYTES;
|
|
49
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size !== metadata.size || stat.size > limit)
|
|
50
|
+
throw new Error('attachment_unavailable');
|
|
51
|
+
return { handle, name: metadata.name, mimeType, size: stat.size };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
await handle.close();
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Serve only registered references; no caller-supplied file paths reach the filesystem. */
|
|
59
|
+
export async function serveWorkspaceAttachment(root, attachmentId, request, response) {
|
|
60
|
+
if (!attachmentIdsSchema.safeParse([attachmentId]).success) {
|
|
61
|
+
response.writeHead(400, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
62
|
+
response.end(JSON.stringify({ error: 'invalid_attachment' }));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let content;
|
|
66
|
+
try {
|
|
67
|
+
content = await openAttachment(root, attachmentId);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
response.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
71
|
+
response.end(JSON.stringify({ error: 'attachment_not_found' }));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const range = attachmentRange(request.headers.range, content.size);
|
|
76
|
+
const headers = {
|
|
77
|
+
'content-type': content.mimeType,
|
|
78
|
+
'cache-control': 'private, no-store',
|
|
79
|
+
'x-content-type-options': 'nosniff',
|
|
80
|
+
'content-security-policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'",
|
|
81
|
+
'referrer-policy': 'no-referrer',
|
|
82
|
+
'accept-ranges': 'bytes',
|
|
83
|
+
};
|
|
84
|
+
if (!/^(?:image|video|audio|model)\//.test(content.mimeType) || content.mimeType === 'image/svg+xml')
|
|
85
|
+
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(content.name).replace(/'/g, '%27')}`;
|
|
86
|
+
if (range === null) {
|
|
87
|
+
response.writeHead(416, { ...headers, 'content-range': `bytes */${content.size}`, 'content-length': '0' });
|
|
88
|
+
response.end();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (range)
|
|
92
|
+
headers['content-range'] = `bytes ${range.start}-${range.end}/${content.size}`;
|
|
93
|
+
headers['content-length'] = String(range ? range.end - range.start + 1 : content.size);
|
|
94
|
+
response.writeHead(range ? 206 : 200, headers);
|
|
95
|
+
if (request.method === 'HEAD' || !content.size) {
|
|
96
|
+
response.end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
await pipeline(content.handle.createReadStream({
|
|
100
|
+
start: range?.start ?? 0,
|
|
101
|
+
end: range?.end ?? content.size - 1,
|
|
102
|
+
autoClose: false,
|
|
103
|
+
}), response);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
response.destroy(error instanceof Error ? error : undefined);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await content.handle.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -189,6 +189,16 @@ async function assertBoundBranch(rootReal, boundBranch) {
|
|
|
189
189
|
* A push the remote refused for this ref is a definite outcome, unlike a lost response. Porcelain
|
|
190
190
|
* output marks the ref with "!" and says whether the remote has commits this checkout lacks.
|
|
191
191
|
*/
|
|
192
|
+
/**
|
|
193
|
+
* The gateway or the host refused the push over HTTP before any ref changed: no write access, the
|
|
194
|
+
* gateway's writes switched off, or no such repository. Like a protected-branch refusal, nothing
|
|
195
|
+
* was pushed and the commit is kept.
|
|
196
|
+
*/
|
|
197
|
+
function httpRefusal(stderr) {
|
|
198
|
+
return /returned error: (?:401|403|404)\b|Authentication failed|Access denied|could not read Username|repository '[^']*' not found/i.test(stderr)
|
|
199
|
+
? 'git_push_rejected'
|
|
200
|
+
: undefined;
|
|
201
|
+
}
|
|
192
202
|
function pushRejection(stdout) {
|
|
193
203
|
const line = stdout.split('\n').find((item) => item.startsWith('!\t'));
|
|
194
204
|
if (!line)
|
|
@@ -235,7 +245,7 @@ export async function pushWorkspaceGit(root, boundBranch) {
|
|
|
235
245
|
catch { /* A lost response does not prove the remote rejected the update. */ }
|
|
236
246
|
if (pushed?.code === 0)
|
|
237
247
|
return;
|
|
238
|
-
const rejected = pushed ? pushRejection(pushed.stdout) : undefined;
|
|
248
|
+
const rejected = pushed ? pushRejection(pushed.stdout) ?? httpRefusal(pushed.stderr) : undefined;
|
|
239
249
|
if (rejected)
|
|
240
250
|
throw new GitWorkspaceError(rejected, rejected);
|
|
241
251
|
try {
|
|
@@ -282,6 +292,42 @@ export async function saveWorkspaceGit(root, input) {
|
|
|
282
292
|
await recordPushedHead(rootReal);
|
|
283
293
|
return { ...(commit ? { commit } : {}), pushed: true, withheld };
|
|
284
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Connects a standalone checkout to the repository it was just given, keeping all its history and
|
|
297
|
+
* uncommitted work. The new repository holds one empty commit on `branch`; it becomes the base of an
|
|
298
|
+
* unborn checkout, or is merged into existing history, so the next push is a plain fast-forward.
|
|
299
|
+
* Credentials come from the workspace's configured helper and never enter the remote URL.
|
|
300
|
+
* Safe to run again after a failure part way.
|
|
301
|
+
*/
|
|
302
|
+
export async function attachWorkspaceRemote(root, repositoryUrl, branch) {
|
|
303
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
304
|
+
const origin = await gitAllowFail(rootReal, ['remote', 'get-url', 'origin']);
|
|
305
|
+
if (origin.code !== 0)
|
|
306
|
+
await git(rootReal, ['remote', 'add', 'origin', repositoryUrl]);
|
|
307
|
+
else if (origin.stdout.trim() !== repositoryUrl)
|
|
308
|
+
throw new GitWorkspaceError('remote_not_configured', 'origin_mismatch');
|
|
309
|
+
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
310
|
+
const fetched = await gitAllowFail(rootReal, ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${remoteRef}`], 90000);
|
|
311
|
+
if (fetched.code !== 0)
|
|
312
|
+
throw new GitWorkspaceError(httpRefusal(fetched.stderr) ?? 'git_command_failed', 'git_fetch_failed');
|
|
313
|
+
const current = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
|
|
314
|
+
const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}'])).code === 0;
|
|
315
|
+
if (current !== branch) {
|
|
316
|
+
await git(rootReal, born ? ['branch', '-M', branch] : ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]);
|
|
317
|
+
}
|
|
318
|
+
if (!born) {
|
|
319
|
+
// Nothing committed yet: start the branch at the remote commit; the files stay as changes.
|
|
320
|
+
await git(rootReal, ['update-ref', `refs/heads/${branch}`, remoteRef]);
|
|
321
|
+
}
|
|
322
|
+
else if ((await gitAllowFail(rootReal, ['merge-base', '--is-ancestor', remoteRef, 'HEAD'])).code !== 0) {
|
|
323
|
+
const merged = await gitAllowFail(rootReal, [...(await commitIdentity(rootReal)), 'merge', '--allow-unrelated-histories', '--no-edit', remoteRef], 60000);
|
|
324
|
+
if (merged.code !== 0) {
|
|
325
|
+
await gitAllowFail(rootReal, ['merge', '--abort']);
|
|
326
|
+
throw new GitWorkspaceError('git_command_failed', merged.stderr.trim() || 'git_merge_failed');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
await git(rootReal, ['branch', `--set-upstream-to=origin/${branch}`, branch]);
|
|
330
|
+
}
|
|
285
331
|
async function unstage(rootReal, paths) {
|
|
286
332
|
if (paths.length === 0)
|
|
287
333
|
return;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { lstat, mkdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { lstat, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
@@ -9,6 +9,7 @@ import { scheduleDependencyInstall } from './dependencies.js';
|
|
|
9
9
|
import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
|
|
10
10
|
import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skill-artifact-loader.js';
|
|
11
11
|
import { writeWorkspaceCliContext } from './cli-context.js';
|
|
12
|
+
import { attachWorkspaceRemote } from './git.js';
|
|
12
13
|
export { WorkspaceContextError } from './context-error.js';
|
|
13
14
|
const exec = promisify(execFile);
|
|
14
15
|
export function revisionScope(state, workspaceId, directoryName = 'basic') {
|
|
@@ -106,8 +107,11 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
106
107
|
const existing = state.journal.getWorkspace(binding.workspaceId);
|
|
107
108
|
const preservedLocalBasic = binding.targetKind !== 'standalone' && binding.preserveLocalBasic === true;
|
|
108
109
|
const localUpgrade = preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
|
|
109
|
-
|
|
110
|
+
// A standalone workspace promoted to a project's main keeps its checkout and gains that remote.
|
|
111
|
+
const remoteUpgrade = !preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
|
|
112
|
+
if (existing && !sameWorkspaceRegistration(existing, binding) && !localUpgrade && !remoteUpgrade)
|
|
110
113
|
throw new WorkspaceContextError('workspace_binding_conflict');
|
|
114
|
+
const attachMarker = join(state.config.stateDir, 'workspace-remote-attach', `${binding.workspaceId}.json`);
|
|
111
115
|
const directories = binding.directories ?? existing?.directories;
|
|
112
116
|
if (directories?.some(directory => directory.directoryName === 'basic' && directory.status === 'reclaimed')) {
|
|
113
117
|
// Registration publishes trusted identity. Only an explicit basic ensure restores the checkout.
|
|
@@ -142,6 +146,28 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
142
146
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
143
147
|
},
|
|
144
148
|
})).stdout.trim();
|
|
149
|
+
// Unfinished if it failed part way: the marker outlives the journal entry written below.
|
|
150
|
+
if (binding.targetKind === 'main' && !preservedLocalBasic && (remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
|
|
151
|
+
if (preparation?.mode !== 'clone' || !state.gitCredentials)
|
|
152
|
+
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
153
|
+
await mkdir(join(state.config.stateDir, 'workspace-remote-attach'), { recursive: true, mode: 0o700 });
|
|
154
|
+
await writeFile(attachMarker, JSON.stringify({ repositoryUrl: binding.repositoryUrl, branchName: binding.branchName }), { mode: 0o600 });
|
|
155
|
+
// The credential helper only serves a workspace the journal knows to be bound.
|
|
156
|
+
await state.journal.putWorkspace({ ...binding, directories });
|
|
157
|
+
try {
|
|
158
|
+
await state.gitCredentials.configure(binding, preparation);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await attachWorkspaceRemote(root, binding.repositoryUrl, binding.branchName);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new WorkspaceContextError('workspace_remote_attach_failed');
|
|
168
|
+
}
|
|
169
|
+
await rm(attachMarker, { force: true });
|
|
170
|
+
}
|
|
145
171
|
try {
|
|
146
172
|
let checkoutMatches;
|
|
147
173
|
if (binding.targetKind === 'standalone' || preservedLocalBasic) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"@gltf-transform/core": "^4.3.0",
|
|
51
51
|
"@gltf-transform/extensions": "^4.3.0",
|
|
52
52
|
"@gltf-transform/functions": "^4.3.0",
|
|
53
|
-
"@playcraft/build": "^0.1.
|
|
54
|
-
"@playcraft/common": "^0.1.
|
|
53
|
+
"@playcraft/build": "^0.1.27",
|
|
54
|
+
"@playcraft/common": "^0.1.27",
|
|
55
55
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
56
56
|
"chokidar": "^4.0.3",
|
|
57
57
|
"commander": "^13.1.0",
|