@playcraft/cli 0.1.58-beta → 0.1.60-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-root-help.js +1 -0
- package/dist/commands/references.js +55 -0
- package/dist/index.js +21 -3
- package/dist/references/client.js +69 -0
- package/dist/references/messages.js +57 -0
- package/dist/references/protocol.js +32 -0
- package/dist/utils/agent-generation-provenance.js +1 -1
- package/dist/workspace-runtime/batch/control.js +3 -3
- package/dist/workspace-runtime/batch/deliver.js +4 -3
- package/dist/workspace-runtime/batch/generate.js +4 -3
- package/dist/workspace-runtime/batch/install-log.js +49 -0
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/batch/prepare.js +29 -13
- package/dist/workspace-runtime/batch/processes.js +86 -14
- package/dist/workspace-runtime/batch/writer-proof.js +2 -2
- package/dist/workspace-runtime/codex/app-server.js +305 -21
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
- package/dist/workspace-runtime/codex/launcher-client.js +34 -24
- package/dist/workspace-runtime/codex/loopback.js +2 -0
- package/dist/workspace-runtime/codex/model-broker.js +48 -8
- package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
- package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
- package/dist/workspace-runtime/serve.js +4 -1
- package/dist/workspace-runtime/server/directory-access.js +1 -1
- package/dist/workspace-runtime/server/dispatch.js +14 -6
- package/dist/workspace-runtime/server/http.js +43 -2
- package/dist/workspace-runtime/server/terminal-host.js +82 -1
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- package/dist/workspace-runtime/workspaces/empty-template.js +2 -0
- package/dist/workspace-runtime/workspaces/registry.js +44 -3
- package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
- package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
- package/dist/workspace-runtime/workspaces/skills.js +3 -1
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/package.json +3 -3
- package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
- package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-template-v2/.playcraft/skills.lock.json +7 -0
- package/project-templates/empty/AGENTS.md +9 -0
- package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
- package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
|
@@ -33,6 +33,24 @@ function request(socketPath, message) {
|
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
|
+
/** A persisted identity lets the controller stop development-user writers after a request ends. */
|
|
37
|
+
export async function stopLauncherProcess(input) {
|
|
38
|
+
if (!input.instanceId)
|
|
39
|
+
throw new Error('launcher_start_unconfirmed');
|
|
40
|
+
const { socketPath, ...identity } = input;
|
|
41
|
+
// A new launcher's empty registry cannot confirm an old group's exit.
|
|
42
|
+
for (const signal of ['SIGTERM', 'SIGKILL']) {
|
|
43
|
+
await request(socketPath, { ...identity, action: 'stop', signal });
|
|
44
|
+
const deadline = Date.now() + 3000;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
const status = await request(socketPath, { ...identity, action: 'status' });
|
|
47
|
+
if (status.running === false)
|
|
48
|
+
return;
|
|
49
|
+
await delay(50);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error('codex_group_stop_unconfirmed');
|
|
53
|
+
}
|
|
36
54
|
/** The private launcher owns the boundary; development shells never receive its identity. */
|
|
37
55
|
export async function prepareLauncherOrigin(input) {
|
|
38
56
|
try {
|
|
@@ -98,9 +116,12 @@ export async function startLauncherProcess(input) {
|
|
|
98
116
|
try {
|
|
99
117
|
const frame = JSON.parse(line);
|
|
100
118
|
if (!started) {
|
|
119
|
+
if (frame.ok === false)
|
|
120
|
+
throw Object.assign(new Error(frame.error || 'launcher_start_failed'), { launchRejected: true });
|
|
101
121
|
if (!frame.ok || !frame.pid || !frame.instanceId)
|
|
102
122
|
throw new Error(frame.error || 'launcher_start_failed');
|
|
103
123
|
identity = {
|
|
124
|
+
socketPath: input.socketPath,
|
|
104
125
|
processId: input.processId,
|
|
105
126
|
runtimeEpoch: input.runtimeEpoch,
|
|
106
127
|
instanceId: frame.instanceId,
|
|
@@ -146,6 +167,7 @@ export async function startLauncherProcess(input) {
|
|
|
146
167
|
});
|
|
147
168
|
});
|
|
148
169
|
return {
|
|
170
|
+
identity: identity,
|
|
149
171
|
pid,
|
|
150
172
|
stdin,
|
|
151
173
|
stdout,
|
|
@@ -163,31 +185,19 @@ export async function startLauncherProcess(input) {
|
|
|
163
185
|
},
|
|
164
186
|
stop() {
|
|
165
187
|
stopPromise ??= (async () => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
reject(new Error('launcher_output_drain_unconfirmed'));
|
|
179
|
-
}, 3000);
|
|
180
|
-
})]);
|
|
181
|
-
}
|
|
182
|
-
finally {
|
|
183
|
-
clearTimeout(timeout);
|
|
184
|
-
}
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
await delay(50);
|
|
188
|
-
}
|
|
188
|
+
await stopLauncherProcess(identity);
|
|
189
|
+
let timeout;
|
|
190
|
+
try {
|
|
191
|
+
await Promise.race([transportClosed, new Promise((_resolve, reject) => {
|
|
192
|
+
timeout = setTimeout(() => {
|
|
193
|
+
socket.destroy();
|
|
194
|
+
reject(new Error('launcher_output_drain_unconfirmed'));
|
|
195
|
+
}, 3000);
|
|
196
|
+
})]);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
clearTimeout(timeout);
|
|
189
200
|
}
|
|
190
|
-
throw new Error('codex_group_stop_unconfirmed');
|
|
191
201
|
})();
|
|
192
202
|
return stopPromise;
|
|
193
203
|
},
|
|
@@ -87,6 +87,8 @@ export function createLoopbackNativeAdapter() {
|
|
|
87
87
|
if (!thread || thread.sessionId !== input.sessionId)
|
|
88
88
|
return { outcome: 'uncertain' };
|
|
89
89
|
const turnId = thread.turnId;
|
|
90
|
+
if (input.expectedTurnId !== undefined && input.expectedTurnId !== turnId)
|
|
91
|
+
return { outcome: 'failed', error: 'turn_changed' };
|
|
90
92
|
queueMicrotask(() => {
|
|
91
93
|
emit(collectedEvent('notification', 'turn/completed', {
|
|
92
94
|
threadId: input.threadId,
|
|
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { workspaceContextFromRoot } from '../workspaces/registry.js';
|
|
8
8
|
import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
9
|
import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
|
|
10
|
+
import { WorkspaceRemixReferencesBroker } from './remix-references-broker.js';
|
|
10
11
|
const credentialSchema = z.object({
|
|
11
12
|
token: z.string().min(1), expiresAt: z.string().datetime(),
|
|
12
13
|
configId: z.string().uuid(), model: z.string().min(1).max(128),
|
|
@@ -27,9 +28,11 @@ export class WorkspaceModelBroker {
|
|
|
27
28
|
requests = new Set();
|
|
28
29
|
sessionRequests = new Map();
|
|
29
30
|
generation;
|
|
31
|
+
references;
|
|
30
32
|
constructor(state) {
|
|
31
33
|
this.state = state;
|
|
32
34
|
this.generation = new WorkspaceGenerationProvenanceBroker(state);
|
|
35
|
+
this.references = new WorkspaceRemixReferencesBroker(state);
|
|
33
36
|
}
|
|
34
37
|
async start() {
|
|
35
38
|
if (this.server)
|
|
@@ -52,7 +55,7 @@ export class WorkspaceModelBroker {
|
|
|
52
55
|
activate();
|
|
53
56
|
return settings;
|
|
54
57
|
}
|
|
55
|
-
async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId) {
|
|
58
|
+
async prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort, clientRequestId, originCwd) {
|
|
56
59
|
if (!this.server)
|
|
57
60
|
throw new Error('workspace_not_registered');
|
|
58
61
|
const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
|
|
@@ -61,11 +64,12 @@ export class WorkspaceModelBroker {
|
|
|
61
64
|
const routeKey = sessionId ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}` : workspaceId;
|
|
62
65
|
let route = this.routes.get(routeKey);
|
|
63
66
|
const selected = modelConfigId ?? (route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
|
|
64
|
-
const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}`;
|
|
67
|
+
const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}:${sessionId ?? ''}:${clientRequestId ?? ''}`;
|
|
65
68
|
let entry = this.entries.get(cacheKey) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected
|
|
69
|
+
&& item.sessionId === sessionId && item.clientRequestId === clientRequestId
|
|
66
70
|
&& item.reasoningEffort === reasoningEffort);
|
|
67
71
|
if (!entry || entry.epoch !== this.state.runtimeEpoch) {
|
|
68
|
-
entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort };
|
|
72
|
+
entry = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort, sessionId, clientRequestId };
|
|
69
73
|
this.entries.set(cacheKey, entry);
|
|
70
74
|
}
|
|
71
75
|
// Resolve current model metadata and permission at every native turn boundary.
|
|
@@ -73,16 +77,37 @@ export class WorkspaceModelBroker {
|
|
|
73
77
|
const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
|
|
74
78
|
if (!route || route.epoch !== this.state.runtimeEpoch) {
|
|
75
79
|
route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
|
|
76
|
-
generationCapability: randomBytes(32).toString('base64url') };
|
|
80
|
+
generationCapability: randomBytes(32).toString('base64url'), referencesCapability: randomBytes(32).toString('base64url') };
|
|
77
81
|
this.routes.set(routeKey, route);
|
|
78
82
|
}
|
|
79
83
|
// Each session pins its prepared model; shared credential renewal cannot change an active turn.
|
|
80
84
|
return {
|
|
81
|
-
activate: () => {
|
|
85
|
+
activate: (readOnly = false) => {
|
|
82
86
|
if (!this.valid(entry) || this.routes.get(routeKey) !== route)
|
|
83
87
|
throw new Error('model_access_denied');
|
|
88
|
+
const priorFeedback = route.selected?.feedback;
|
|
89
|
+
let refsBlocked = Boolean(entry.refsBlocked || priorFeedback?.refsBlocked && priorFeedback.clientRequestId === clientRequestId);
|
|
90
|
+
if (sessionId && clientRequestId) {
|
|
91
|
+
const overlaps = (this.state.journal.listOperations?.() ?? []).some(({ operation }) => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
|
|
92
|
+
&& ['dispatched', 'accepted', 'uncertain'].includes(operation.status)
|
|
93
|
+
&& operation.generation === this.state.config.generation && operation.runtimeEpoch === this.state.runtimeEpoch
|
|
94
|
+
&& (operation.sessionId !== sessionId || operation.clientRequestId !== clientRequestId));
|
|
95
|
+
if (overlaps) {
|
|
96
|
+
refsBlocked = true;
|
|
97
|
+
// A completed overlapping turn may leave shells alive. Only a later turn gets a new safe boundary.
|
|
98
|
+
for (const prepared of this.entries.values()) {
|
|
99
|
+
if (prepared.epoch === this.state.runtimeEpoch)
|
|
100
|
+
prepared.refsBlocked = true;
|
|
101
|
+
}
|
|
102
|
+
for (const existing of this.routes.values()) {
|
|
103
|
+
if (existing.epoch === this.state.runtimeEpoch && existing.selected?.feedback)
|
|
104
|
+
existing.selected.feedback.refsBlocked = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
84
108
|
route.selected = { entry, model: credential.model,
|
|
85
|
-
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId
|
|
109
|
+
...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId, readOnly, refsBlocked,
|
|
110
|
+
...(originCwd ? { originCwd } : {}) } } : {}) };
|
|
86
111
|
},
|
|
87
112
|
model: credential.model,
|
|
88
113
|
modelConfigId: credential.configId,
|
|
@@ -91,6 +116,8 @@ export class WorkspaceModelBroker {
|
|
|
91
116
|
...(sessionId ? { 'shell_environment_policy.set': {
|
|
92
117
|
PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
|
|
93
118
|
PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
|
|
119
|
+
PLAYCRAFT_REMIX_REFERENCES_ENDPOINT: `${this.baseUrl}/${routeKey}/remix-references`,
|
|
120
|
+
PLAYCRAFT_REMIX_REFERENCES_CAPABILITY: route.referencesCapability,
|
|
94
121
|
} } : {}),
|
|
95
122
|
'model_providers.playcraft_workspace': {
|
|
96
123
|
name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
|
|
@@ -125,6 +152,8 @@ export class WorkspaceModelBroker {
|
|
|
125
152
|
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
126
153
|
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
|
|
127
154
|
runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId,
|
|
155
|
+
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
|
156
|
+
...(entry.clientRequestId ? { clientRequestId: entry.clientRequestId } : {}),
|
|
128
157
|
...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
|
|
129
158
|
...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}) }),
|
|
130
159
|
});
|
|
@@ -151,13 +180,15 @@ export class WorkspaceModelBroker {
|
|
|
151
180
|
const cancel = () => abort.abort();
|
|
152
181
|
response.once('close', cancel);
|
|
153
182
|
try {
|
|
154
|
-
const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance)$/.exec(request.url ?? '');
|
|
183
|
+
const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references)$/.exec(request.url ?? '');
|
|
155
184
|
const route = match ? this.routes.get(match[1]) : undefined;
|
|
156
185
|
const selected = route?.selected;
|
|
157
186
|
const entry = selected?.entry;
|
|
158
187
|
const presented = Buffer.from(request.headers.authorization ?? '');
|
|
159
188
|
const generationRequest = match?.[2] === 'generation-provenance';
|
|
160
|
-
const
|
|
189
|
+
const referencesRequest = match?.[2] === 'remix-references';
|
|
190
|
+
const expected = Buffer.from(route ? `Bearer ${generationRequest ? route.generationCapability
|
|
191
|
+
: referencesRequest ? route.referencesCapability : route.capability}` : '');
|
|
161
192
|
if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
|
|
162
193
|
|| !timingSafeEqual(presented, expected) || !this.valid(entry)) {
|
|
163
194
|
response.writeHead(403);
|
|
@@ -173,6 +204,15 @@ export class WorkspaceModelBroker {
|
|
|
173
204
|
await this.generation.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
|
|
174
205
|
return;
|
|
175
206
|
}
|
|
207
|
+
if (referencesRequest) {
|
|
208
|
+
if (!selected?.feedback) {
|
|
209
|
+
response.writeHead(403);
|
|
210
|
+
response.end();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await this.references.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
176
216
|
activeRoute = route;
|
|
177
217
|
const active = this.sessionRequests.get(route) ?? new Set();
|
|
178
218
|
active.add(abort);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { PLATFORM_ROUTES, platformEndpoint, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES, WORKSPACE_REMIX_REFERENCES_MANIFEST, workspaceRemixReferenceCandidateSchema, workspaceRemixReferenceExportSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { withDirectoryWrite } from '../server/directory-access.js';
|
|
6
|
+
import { wakeSourceEventUploader } from '../server/source-uploader.js';
|
|
7
|
+
import { workspaceContextFromRoot } from '../workspaces/registry.js';
|
|
8
|
+
import { importWorkspaceRemixReference, listWorkspaceRemixReferences } from '../workspaces/remix-references.js';
|
|
9
|
+
import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
10
|
+
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
11
|
+
import { verifyLauncherOrigin } from './launcher-client.js';
|
|
12
|
+
import { localRemixReference, RemixReferenceToolError, remixReferenceToolRequestSchema } from '../../references/protocol.js';
|
|
13
|
+
const ACTIVE = new Set(['dispatched', 'accepted', 'uncertain']);
|
|
14
|
+
const candidatesSchema = z.object({ items: z.array(z.unknown()).max(50),
|
|
15
|
+
total: z.number().int().nonnegative(), page: z.number().int().positive(), pageSize: z.number().int().positive() }).strict();
|
|
16
|
+
const SOURCE_ERRORS = new Set(['remix_reference_unavailable', 'remix_reference_too_large', 'remix_reference_incomplete',
|
|
17
|
+
'remix_reference_unsupported', 'remix_reference_source_changed', 'workspace_access_denied', 'workspace_permission_denied']);
|
|
18
|
+
/** Executes source-reference operations only for the shell and native turn owning this route. */
|
|
19
|
+
export class WorkspaceRemixReferencesBroker {
|
|
20
|
+
state;
|
|
21
|
+
constructor(state) {
|
|
22
|
+
this.state = state;
|
|
23
|
+
}
|
|
24
|
+
operation(context, valid, write = false) {
|
|
25
|
+
const state = this.state;
|
|
26
|
+
const session = state.journal.getSession(context.sessionId);
|
|
27
|
+
const operations = state.journal.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
|
|
28
|
+
&& ACTIVE.has(operation.status) && operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch);
|
|
29
|
+
const operation = operations.length === 1 ? operations[0] : undefined;
|
|
30
|
+
if (!valid() || context.refsBlocked || state.journal.destroyed() || !operation || operation.kind !== 'session.turn' || operation.sessionId !== context.sessionId
|
|
31
|
+
|| operation.generation !== state.config.generation || operation.runtimeEpoch !== state.runtimeEpoch
|
|
32
|
+
|| operation.clientRequestId !== context.clientRequestId || !operation.workspaceId || !operation.directoryName
|
|
33
|
+
|| operation.directoryName === 'root' || !operation.nativeThreadId || !operation.nativeTurnId
|
|
34
|
+
|| !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
|
|
35
|
+
|| session.cwd !== context.cwd || session.workspaceId !== operation.workspaceId
|
|
36
|
+
|| (session.directoryName ?? 'basic') !== operation.directoryName || session.turnBlock === 'uncertain'
|
|
37
|
+
|| !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
|
|
38
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
39
|
+
if (write) {
|
|
40
|
+
if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence)
|
|
41
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
42
|
+
if (context.readOnly || operation.workMode === 'plan' || operation.permissionMode === 'read-only' || session.permissionMode === 'read-only')
|
|
43
|
+
throw new RemixReferenceToolError('remix_reference_readonly');
|
|
44
|
+
if (state.journal.listSessions().some(other => other.sessionId !== context.sessionId
|
|
45
|
+
&& other.workspaceId === operation.workspaceId
|
|
46
|
+
&& ((other.directoryName ?? 'basic') === operation.directoryName || other.directoryName === 'root')
|
|
47
|
+
&& (other.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(other.turnStatus ?? ''))))
|
|
48
|
+
throw new RemixReferenceToolError('turn_already_active');
|
|
49
|
+
}
|
|
50
|
+
return operation;
|
|
51
|
+
}
|
|
52
|
+
async current(context, valid, write = false) {
|
|
53
|
+
const operation = this.operation(context, valid, write);
|
|
54
|
+
const scope = await workspaceContextFromRoot(this.state, context.cwd);
|
|
55
|
+
if (scope.workspaceId !== operation.workspaceId || scope.directoryName !== operation.directoryName)
|
|
56
|
+
throw new RemixReferenceToolError('remix_reference_scope_mismatch');
|
|
57
|
+
if (write && await batchDirectoryHeld(this.state, scope.workspaceId, scope.directoryName))
|
|
58
|
+
throw new RemixReferenceToolError('batch_workspace_readonly');
|
|
59
|
+
this.operation(context, valid, write);
|
|
60
|
+
return { ...scope, operation };
|
|
61
|
+
}
|
|
62
|
+
async forward(request, response, context, valid) {
|
|
63
|
+
const reply = (status, value) => {
|
|
64
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
65
|
+
response.end(JSON.stringify(value));
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
if (request.socket.remoteAddress !== '127.0.0.1')
|
|
69
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
70
|
+
let text = '';
|
|
71
|
+
for await (const chunk of request) {
|
|
72
|
+
text += chunk.toString();
|
|
73
|
+
if (Buffer.byteLength(text) > 16 * 1024)
|
|
74
|
+
throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
|
|
75
|
+
}
|
|
76
|
+
const parsed = remixReferenceToolRequestSchema.safeParse(JSON.parse(text));
|
|
77
|
+
if (!parsed.success)
|
|
78
|
+
throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
|
|
79
|
+
const input = parsed.data;
|
|
80
|
+
const write = input.action === 'import';
|
|
81
|
+
const scope = await this.current(context, valid, write);
|
|
82
|
+
const callerCwd = await realpath(input.cwd);
|
|
83
|
+
const childPath = relative(context.cwd, callerCwd);
|
|
84
|
+
if (isAbsolute(childPath) || childPath === '..' || childPath.startsWith('../') || input.workspaceId !== undefined && input.workspaceId !== scope.workspaceId
|
|
85
|
+
|| input.directoryName !== undefined && input.directoryName !== scope.directoryName)
|
|
86
|
+
throw new RemixReferenceToolError('remix_reference_scope_mismatch');
|
|
87
|
+
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
88
|
+
if (!socketPath || !context.boundaryId || !request.socket.localPort || !request.socket.remotePort
|
|
89
|
+
|| !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId, runtimeEpoch: this.state.runtimeEpoch,
|
|
90
|
+
serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? callerCwd }))
|
|
91
|
+
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
92
|
+
await this.current(context, valid, write);
|
|
93
|
+
if (input.action === 'list') {
|
|
94
|
+
const items = await this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, () => listWorkspaceRemixReferences(context.cwd, scope.directoryName));
|
|
95
|
+
this.operation(context, valid);
|
|
96
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
97
|
+
items: items.map(reference => localRemixReference(context.cwd, reference)) });
|
|
98
|
+
}
|
|
99
|
+
if (input.action === 'search') {
|
|
100
|
+
const result = candidatesSchema.parse(await this.upstream(scope, { action: 'search', source: input.source,
|
|
101
|
+
...(input.tagCategory === undefined ? {} : { tagCategory: input.tagCategory, tagValues: input.tagValues }),
|
|
102
|
+
...(input.q === undefined ? {} : { q: input.q }), page: input.page, limit: input.limit }, 2 * 1024 * 1024));
|
|
103
|
+
await this.current(context, valid);
|
|
104
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...result,
|
|
105
|
+
items: result.items.map(item => workspaceRemixReferenceCandidateSchema.parse(item)) });
|
|
106
|
+
}
|
|
107
|
+
const result = await withDirectoryWrite(this.state, { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
108
|
+
sessionId: context.sessionId, wait: false }, async () => {
|
|
109
|
+
await this.current(context, valid, true);
|
|
110
|
+
const raw = await this.upstream(scope, { action: 'export', projectUuid: input.projectUuid, branchName: input.branchName }, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES);
|
|
111
|
+
await this.current(context, valid, true);
|
|
112
|
+
const snapshot = workspaceRemixReferenceExportSchema.parse(raw);
|
|
113
|
+
if (snapshot.source.environment !== this.state.config.environment || snapshot.source.projectUuid !== input.projectUuid
|
|
114
|
+
|| snapshot.source.branchName !== input.branchName)
|
|
115
|
+
throw new RemixReferenceToolError('remix_reference_unavailable');
|
|
116
|
+
return this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, async () => {
|
|
117
|
+
await this.current(context, valid, true);
|
|
118
|
+
await shareWorkspaceGit(this.state, context.cwd);
|
|
119
|
+
await this.current(context, valid, true);
|
|
120
|
+
const reference = await importWorkspaceRemixReference(context.cwd, scope.directoryName, snapshot, () => {
|
|
121
|
+
this.operation(context, valid, true);
|
|
122
|
+
});
|
|
123
|
+
this.operation(context, valid, true);
|
|
124
|
+
await this.state.journal.allocateSourceEvent({ runtimeId: this.state.config.runtimeId,
|
|
125
|
+
generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
126
|
+
type: 'workspace.references.imported', occurredAt: new Date().toISOString(),
|
|
127
|
+
sessionId: context.sessionId, workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
|
|
128
|
+
payload: { sessionId: context.sessionId, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
129
|
+
referenceId: reference.referenceId, path: reference.path, contentHash: reference.contentHash } });
|
|
130
|
+
await this.state.journal.allocateSourceEvent({ runtimeId: this.state.config.runtimeId,
|
|
131
|
+
generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
132
|
+
type: 'files.changed', occurredAt: new Date().toISOString(), sessionId: context.sessionId,
|
|
133
|
+
workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
|
|
134
|
+
payload: { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
135
|
+
paths: [reference.path, WORKSPACE_REMIX_REFERENCES_MANIFEST], source: 'workspace.references.import',
|
|
136
|
+
operationId: scope.operation.operationId } });
|
|
137
|
+
wakeSourceEventUploader(this.state);
|
|
138
|
+
return reference;
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
142
|
+
reference: localRemixReference(context.cwd, result) });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (response.headersSent) {
|
|
146
|
+
response.destroy();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const code = error instanceof SyntaxError ? 'invalid_remix_reference_tool_request' : error instanceof RemixReferenceToolError ? error.code
|
|
150
|
+
: error instanceof Error && /^(?:remix_reference_[a-z_]+|directory_busy)$/.test(error.message)
|
|
151
|
+
? error.message : 'remix_reference_tool_failed';
|
|
152
|
+
reply(code === 'invalid_remix_reference_tool_request' ? 400
|
|
153
|
+
: ['turn_already_active', 'batch_workspace_readonly', 'directory_busy'].includes(code) ? 409 : 403, { ok: false, error: { code } });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async upstream(scope, input, limit) {
|
|
157
|
+
const config = this.state.config;
|
|
158
|
+
const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.remixReferenceTools), {
|
|
159
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(120_000),
|
|
160
|
+
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
161
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
162
|
+
workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...input }),
|
|
163
|
+
});
|
|
164
|
+
const chunks = [];
|
|
165
|
+
let bytes = 0;
|
|
166
|
+
if (!response.body)
|
|
167
|
+
throw new RemixReferenceToolError('remix_reference_unavailable');
|
|
168
|
+
for await (const chunk of response.body) {
|
|
169
|
+
bytes += chunk.byteLength;
|
|
170
|
+
if (bytes > (response.ok ? limit : 8192)) {
|
|
171
|
+
await response.body.cancel().catch(() => undefined);
|
|
172
|
+
throw new RemixReferenceToolError('remix_reference_too_large');
|
|
173
|
+
}
|
|
174
|
+
chunks.push(Buffer.from(chunk));
|
|
175
|
+
}
|
|
176
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const code = [value?.reason, value?.code, value?.message, value?.error?.reason, value?.error?.code]
|
|
179
|
+
.find(code => typeof code === 'string' && SOURCE_ERRORS.has(code));
|
|
180
|
+
throw new RemixReferenceToolError(code ?? 'remix_reference_unavailable');
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { scheduledTaskProposalDraftSchema, scheduledTaskProposalDraftJsonSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { rejectTool, toolContent } from './tool-response.js';
|
|
4
|
+
export const WORKSPACE_SCHEDULED_TASK_TOOLS = [
|
|
5
|
+
{ type: 'function', name: 'playcraft_get_scheduled_task_context',
|
|
6
|
+
description: 'Read server time, scheduling defaults, and tasks actually related to this conversation. The returned timeZone describes the server clock, not the user’s local timezone; clarify the intended timezone when it is unknown. When several tasks match, ask the user which task to change. This tool never changes a task.',
|
|
7
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
8
|
+
{ type: 'function', name: 'playcraft_propose_scheduled_task',
|
|
9
|
+
description: 'Prepare a scheduled-task creation or change for the user to review in a persistent confirmation card. Read scheduling context first. This does not create or change a task: only the user can confirm the card. A pending_confirmation result must never be reported as an applied task.',
|
|
10
|
+
inputSchema: { type: 'object', properties: {
|
|
11
|
+
draft: scheduledTaskProposalDraftJsonSchema, supersedesProposalId: { type: 'string', format: 'uuid' },
|
|
12
|
+
}, required: ['draft'], additionalProperties: false } },
|
|
13
|
+
];
|
|
14
|
+
export const SCHEDULED_TASK_TOOL_ERROR_CODES = new Set([
|
|
15
|
+
'unavailable', 'source_unavailable', 'source_pending', 'invalid_input', 'request_conflict', 'request_pending',
|
|
16
|
+
'feature_disabled', 'limit', 'version_conflict', 'schedule_expired', 'invalid_time_zone', 'interval_too_short',
|
|
17
|
+
'target_read_only', 'target_unavailable', 'target_access_denied', 'target_binding_changed', 'session_unavailable',
|
|
18
|
+
'account_unavailable', 'model_unavailable', 'reasoning_effort_unavailable', 'revoked',
|
|
19
|
+
].map(code => `scheduled_task_proposal_${code}`));
|
|
20
|
+
const prepareArguments = z.object({ draft: z.unknown(), supersedesProposalId: z.string().uuid().optional() }).strict();
|
|
21
|
+
const sourceKey = (source) => JSON.stringify([source.threadId, source.turnId, source.toolCallId]);
|
|
22
|
+
/** Only a live root-native call can attest the source of a prepare-only bridge request. */
|
|
23
|
+
export class WorkspaceScheduledTaskTools {
|
|
24
|
+
handler;
|
|
25
|
+
changed;
|
|
26
|
+
active = new Map();
|
|
27
|
+
settled = new Map();
|
|
28
|
+
constructor(handler, changed) {
|
|
29
|
+
this.handler = handler;
|
|
30
|
+
this.changed = changed;
|
|
31
|
+
}
|
|
32
|
+
isActive(source) {
|
|
33
|
+
const call = this.active.get(sourceKey(source));
|
|
34
|
+
return Boolean(call && call.source.bridge === source.bridge && call.current());
|
|
35
|
+
}
|
|
36
|
+
async request(requestId, payload, client, context) {
|
|
37
|
+
if (!context?.bridge || !context.current() || typeof payload.callId !== 'string' || !payload.callId.length
|
|
38
|
+
|| payload.namespace)
|
|
39
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_source_unavailable');
|
|
40
|
+
const action = payload.tool === 'playcraft_get_scheduled_task_context' ? 'context'
|
|
41
|
+
: payload.tool === 'playcraft_propose_scheduled_task' ? 'prepare' : undefined;
|
|
42
|
+
const parsed = action === 'context' ? z.object({}).strict().safeParse(payload.arguments)
|
|
43
|
+
: prepareArguments.safeParse(payload.arguments);
|
|
44
|
+
if (!action || !parsed.success || (action === 'prepare'
|
|
45
|
+
&& !scheduledTaskProposalDraftSchema.safeParse(parsed.data.draft).success))
|
|
46
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_invalid_input');
|
|
47
|
+
const source = { bridge: context.bridge, threadId: context.threadId, turnId: context.turnId, toolCallId: payload.callId };
|
|
48
|
+
const key = sourceKey(source), fingerprint = JSON.stringify([action, parsed.data]);
|
|
49
|
+
const prior = this.settled.get(key);
|
|
50
|
+
if (prior) {
|
|
51
|
+
if (prior.fingerprint !== fingerprint)
|
|
52
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_request_conflict');
|
|
53
|
+
await client.respond(requestId, { success: true, contentItems: toolContent(prior.result) });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (this.active.has(key))
|
|
57
|
+
return rejectTool(client, requestId, 'scheduled_task_proposal_request_pending');
|
|
58
|
+
this.active.set(key, { source, current: context.current });
|
|
59
|
+
try {
|
|
60
|
+
const result = await this.handler(action, source, parsed.data);
|
|
61
|
+
this.settled.set(key, { fingerprint, result });
|
|
62
|
+
if (this.settled.size > 256)
|
|
63
|
+
this.settled.delete(this.settled.keys().next().value);
|
|
64
|
+
if (action === 'prepare' && result && typeof result === 'object'
|
|
65
|
+
&& 'proposalId' in result && typeof result.proposalId === 'string')
|
|
66
|
+
this.changed(result.proposalId, source);
|
|
67
|
+
await client.respond(requestId, { success: true, contentItems: toolContent(result) });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
await rejectTool(client, requestId, error instanceof Error && SCHEDULED_TASK_TOOL_ERROR_CODES.has(error.message)
|
|
71
|
+
? error.message : 'scheduled_task_proposal_unavailable');
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
this.active.delete(key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function isWorkspaceScheduledTaskTool(value) {
|
|
79
|
+
return WORKSPACE_SCHEDULED_TASK_TOOLS.some(tool => tool.name === value);
|
|
80
|
+
}
|
|
@@ -12,7 +12,7 @@ import { attachNativeCollector, executeOperation, recoverInterruptedParameterVer
|
|
|
12
12
|
import { restoreDeferredDirectoryOperations } from './server/directory-access.js';
|
|
13
13
|
import { handleRuntimeRequest } from './server/http.js';
|
|
14
14
|
import { desktopHost, disposeDesktopHost } from './server/desktop-host.js';
|
|
15
|
-
import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
|
|
15
|
+
import { disposeTerminalHost, retireStoppedTerminals, terminalHost } from './server/terminal-host.js';
|
|
16
16
|
import { handleExecutionGroupCrash } from './server/execution-group.js';
|
|
17
17
|
import { RuntimeProcessLock } from './server/lock.js';
|
|
18
18
|
import { probeGitBinary } from './workspaces/git.js';
|
|
@@ -33,6 +33,7 @@ import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
|
|
|
33
33
|
import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
|
|
34
34
|
import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
|
|
35
35
|
import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
|
|
36
|
+
import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
|
|
36
37
|
export async function startWorkspaceRuntime(config, options = {}) {
|
|
37
38
|
process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
|
|
38
39
|
const negotiated = negotiateProtocol(config);
|
|
@@ -58,6 +59,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
58
59
|
await recoverInterruptedPreviews(journal);
|
|
59
60
|
await recoverInterruptedParameterVerifications(journal);
|
|
60
61
|
await recoverInterruptedDeliveries(config.stateDir);
|
|
62
|
+
await retireStoppedTerminals(journal, config.runtimeEpoch, previousEpoch);
|
|
61
63
|
// shared: in multi-workspace mode this is /workspaces, the container both identities traverse.
|
|
62
64
|
await mkdir(config.workspaceRoot, { recursive: true, mode: 0o2770 });
|
|
63
65
|
const state = runtimeState = {
|
|
@@ -411,6 +413,7 @@ function defaultNativeAdapter(state) {
|
|
|
411
413
|
config,
|
|
412
414
|
workspaceUi: true,
|
|
413
415
|
deliveryTools: deliveryToolHandlers(state),
|
|
416
|
+
scheduledTaskProposals: scheduledTaskProposalHandler(state),
|
|
414
417
|
...(multi ? { modelBroker: new WorkspaceModelBroker(state) } : {}),
|
|
415
418
|
...(multi ? { loadMcpConfig: async (cwd) => {
|
|
416
419
|
const { workspaceId } = await workspaceContextFromRoot(state, cwd);
|
|
@@ -8,7 +8,7 @@ const instances = new WeakMap();
|
|
|
8
8
|
const executing = new WeakMap();
|
|
9
9
|
const freezing = new WeakMap();
|
|
10
10
|
const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
|
|
11
|
-
const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', '
|
|
11
|
+
const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', 'directory.create',
|
|
12
12
|
'directory.rename', 'directory.delete', 'workspace.references.import', 'skills.update', 'parameter.verify',
|
|
13
13
|
'checkpoint.create', 'checkpoint.restore']);
|
|
14
14
|
export function managedDirectoryScope(state, value) {
|
|
@@ -33,6 +33,7 @@ import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPrevi
|
|
|
33
33
|
import { startManagedBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
|
|
34
34
|
import { wakeSourceEventUploader } from './source-uploader.js';
|
|
35
35
|
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
36
|
+
import { trustedMemberPolicy } from '../batch/policy.js';
|
|
36
37
|
import { BinaryWorkspaceWriteError, observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from '../workspaces/binary-write.js';
|
|
37
38
|
import { importWorkspaceLibraryAssets } from '../workspaces/asset-library-import.js';
|
|
38
39
|
import { createWorkspaceDirectory, WorkspaceDirectoryCreateError } from '../workspaces/directory-create.js';
|
|
@@ -217,7 +218,7 @@ async function executeScopedOperation(state, scopeKey, operation, payload, recov
|
|
|
217
218
|
if (operation.kind === 'session.steer')
|
|
218
219
|
return executeSteer(state, scopeKey, current, payload);
|
|
219
220
|
if (operation.kind === 'session.interrupt') {
|
|
220
|
-
return executeInterrupt(state, scopeKey, current);
|
|
221
|
+
return executeInterrupt(state, scopeKey, current, payload);
|
|
221
222
|
}
|
|
222
223
|
if (operation.kind === 'session.approval.respond') {
|
|
223
224
|
return executeApproval(state, scopeKey, current, payload);
|
|
@@ -455,7 +456,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
455
456
|
...(typeof body.teamId === 'string' ? { teamId: body.teamId } : {}) })}\n\n`
|
|
456
457
|
: '';
|
|
457
458
|
const engineeringBatchContext = state.config.workspaceMode === 'multi' && operation.workspaceId
|
|
458
|
-
&& operation.directoryName !== 'root' && !productionSessionAttemptId(sessionId)
|
|
459
|
+
&& operation.directoryName !== 'root' && !productionSessionAttemptId(sessionId) && !trustedMemberPolicy(state, sessionId)
|
|
459
460
|
? `PlayCraft batch request context (trusted runtime identity): ${JSON.stringify({ workspaceId: operation.workspaceId,
|
|
460
461
|
clientRequestId: operation.clientRequestId, sourceDirectoryName: binding.directoryName ?? 'basic',
|
|
461
462
|
...(typeof body.teamId === 'string' ? { teamId: body.teamId } : {}) })}`
|
|
@@ -496,6 +497,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
|
|
|
496
497
|
workMode: selection.data.workMode ?? 'execute',
|
|
497
498
|
skillsLockHash: composer.skillsLockHash,
|
|
498
499
|
uiSnapshot: uiSnapshot.data,
|
|
500
|
+
scheduledTaskProposalBridge: typeof body.scheduledTaskProposalBridge === 'string' ? body.scheduledTaskProposalBridge : undefined,
|
|
499
501
|
simulateLostReceipt: body.simulateLostReceipt === true,
|
|
500
502
|
persistReservation: async ({ nativeRequestId, modelConfigId, reasoningEffort, permissionMode }) => {
|
|
501
503
|
const selectedEffort = reasoningEffort ?? requestedEffort;
|
|
@@ -849,13 +851,18 @@ async function executeSteer(state, scopeKey, operation, payload) {
|
|
|
849
851
|
} }]);
|
|
850
852
|
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
851
853
|
}
|
|
852
|
-
async function executeInterrupt(state, scopeKey, operation) {
|
|
854
|
+
async function executeInterrupt(state, scopeKey, operation, payload) {
|
|
853
855
|
const sessionId = required(operation.sessionId, 'sessionId');
|
|
854
856
|
const binding = state.journal.getSession(sessionId);
|
|
855
857
|
if (!binding) {
|
|
856
858
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
|
|
857
859
|
}
|
|
858
|
-
const
|
|
860
|
+
const expectedTurnId = asRecord(payload).expectedTurnId;
|
|
861
|
+
if (expectedTurnId !== undefined && (typeof expectedTurnId !== 'string' || !expectedTurnId
|
|
862
|
+
|| binding.activeTurnId !== expectedTurnId))
|
|
863
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'turn_changed' }));
|
|
864
|
+
const result = await native(state).interrupt({ sessionId, threadId: binding.threadId,
|
|
865
|
+
...(typeof expectedTurnId === 'string' ? { expectedTurnId } : {}) });
|
|
859
866
|
if (result.outcome === 'failed') {
|
|
860
867
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: result.error ?? 'turn_not_running' }));
|
|
861
868
|
}
|
|
@@ -1273,11 +1280,12 @@ async function executeWorkspaceSave(state, scopeKey, operation, payload) {
|
|
|
1273
1280
|
}
|
|
1274
1281
|
const binding = operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId) : undefined;
|
|
1275
1282
|
try {
|
|
1276
|
-
|
|
1283
|
+
// Saves share the directory, and a deferred one runs outside the workspace queue; commit one at a time.
|
|
1284
|
+
const saved = await state.journal.exclusive(`workspace-save:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, async () => saveWorkspaceGit(await gitWorkspaceRoot(state, operation), {
|
|
1277
1285
|
message: parsed.data.trigger === 'auto' ? 'Autosave workspace' : 'Save workspace',
|
|
1278
1286
|
push: savePushesBranch(binding?.targetKind, operation.directoryName),
|
|
1279
1287
|
boundBranch: boundBranch(state, operation),
|
|
1280
|
-
});
|
|
1288
|
+
}));
|
|
1281
1289
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
1282
1290
|
await recordNativeEvents(state, accepted, [
|
|
1283
1291
|
{
|