@xenosystem/agent-interface-host 0.1.7 → 0.1.8
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/acp/AcpAgentTurnExecutionAdapter.d.ts.map +1 -1
- package/dist/acp/AcpAgentTurnExecutionAdapter.js +11 -0
- package/dist/acp/AcpAgentTurnExecutionAdapter.js.map +1 -1
- package/dist/acp/AcpNativeProviderLauncherResolver.d.ts +26 -0
- package/dist/acp/AcpNativeProviderLauncherResolver.d.ts.map +1 -0
- package/dist/acp/AcpNativeProviderLauncherResolver.js +145 -0
- package/dist/acp/AcpNativeProviderLauncherResolver.js.map +1 -0
- package/dist/acp/AcpProcessSupervisor.d.ts.map +1 -1
- package/dist/acp/AcpProcessSupervisor.js +4 -1
- package/dist/acp/AcpProcessSupervisor.js.map +1 -1
- package/dist/acp/AcpProviderModelService.d.ts +1 -0
- package/dist/acp/AcpProviderModelService.d.ts.map +1 -1
- package/dist/acp/AcpProviderModelService.js +2 -2
- package/dist/acp/AcpProviderModelService.js.map +1 -1
- package/dist/acp/AcpSessionService.d.ts.map +1 -1
- package/dist/acp/AcpSessionService.js +40 -2
- package/dist/acp/AcpSessionService.js.map +1 -1
- package/dist/acp/acpTypes.d.ts +3 -0
- package/dist/acp/acpTypes.d.ts.map +1 -1
- package/dist/cloud/XenoCloudAgentTurnExecutionAdapter.d.ts.map +1 -1
- package/dist/cloud/XenoCloudAgentTurnExecutionAdapter.js +4 -0
- package/dist/cloud/XenoCloudAgentTurnExecutionAdapter.js.map +1 -1
- package/dist/index.d.ts +41 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +138 -10
- package/dist/index.js.map +1 -1
- package/dist/lease/ProcessLivenessProber.d.ts +30 -0
- package/dist/lease/ProcessLivenessProber.d.ts.map +1 -0
- package/dist/lease/ProcessLivenessProber.js +95 -0
- package/dist/lease/ProcessLivenessProber.js.map +1 -0
- package/dist/lease/ProviderWriterLeaseService.d.ts +43 -0
- package/dist/lease/ProviderWriterLeaseService.d.ts.map +1 -0
- package/dist/lease/ProviderWriterLeaseService.js +148 -0
- package/dist/lease/ProviderWriterLeaseService.js.map +1 -0
- package/dist/node.d.ts +8 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +8 -0
- package/dist/node.js.map +1 -1
- package/dist/provider/ProviderLaneService.d.ts +71 -0
- package/dist/provider/ProviderLaneService.d.ts.map +1 -0
- package/dist/provider/ProviderLaneService.js +892 -0
- package/dist/provider/ProviderLaneService.js.map +1 -0
- package/dist/provider/ProviderTransitionOutboxDispatcher.d.ts +22 -0
- package/dist/provider/ProviderTransitionOutboxDispatcher.d.ts.map +1 -0
- package/dist/provider/ProviderTransitionOutboxDispatcher.js +60 -0
- package/dist/provider/ProviderTransitionOutboxDispatcher.js.map +1 -0
- package/dist/sdk/SdkNativeAgentTurnExecutionAdapter.d.ts +4 -1
- package/dist/sdk/SdkNativeAgentTurnExecutionAdapter.d.ts.map +1 -1
- package/dist/sdk/SdkNativeAgentTurnExecutionAdapter.js +37 -1
- package/dist/sdk/SdkNativeAgentTurnExecutionAdapter.js.map +1 -1
- package/dist/state/ProviderLaneSqliteStore.d.ts +64 -0
- package/dist/state/ProviderLaneSqliteStore.d.ts.map +1 -0
- package/dist/state/ProviderLaneSqliteStore.js +728 -0
- package/dist/state/ProviderLaneSqliteStore.js.map +1 -0
- package/dist/state/SqliteAgentStateRepository.d.ts +9 -0
- package/dist/state/SqliteAgentStateRepository.d.ts.map +1 -1
- package/dist/state/SqliteAgentStateRepository.js +171 -17
- package/dist/state/SqliteAgentStateRepository.js.map +1 -1
- package/dist/state/createAgentStateRepository.d.ts.map +1 -1
- package/dist/state/createAgentStateRepository.js +4 -2
- package/dist/state/createAgentStateRepository.js.map +1 -1
- package/dist/state/providerLaneSchema.d.ts +13 -0
- package/dist/state/providerLaneSchema.d.ts.map +1 -0
- package/dist/state/providerLaneSchema.js +129 -0
- package/dist/state/providerLaneSchema.js.map +1 -0
- package/dist/state/providerLaneTypes.d.ts +122 -0
- package/dist/state/providerLaneTypes.d.ts.map +1 -0
- package/dist/state/providerLaneTypes.js +28 -0
- package/dist/state/providerLaneTypes.js.map +1 -0
- package/dist/state/sdkProtectedStateCipher.d.ts +30 -0
- package/dist/state/sdkProtectedStateCipher.d.ts.map +1 -0
- package/dist/state/sdkProtectedStateCipher.js +50 -0
- package/dist/state/sdkProtectedStateCipher.js.map +1 -0
- package/dist/workspace/WorkspaceHostService.d.ts +31 -1
- package/dist/workspace/WorkspaceHostService.d.ts.map +1 -1
- package/dist/workspace/WorkspaceHostService.js +89 -12
- package/dist/workspace/WorkspaceHostService.js.map +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { ProviderWriterLeaseService } from '../lease/ProviderWriterLeaseService.js';
|
|
5
|
+
import { ProviderTransitionOutboxDispatcher, } from './ProviderTransitionOutboxDispatcher.js';
|
|
6
|
+
const HOST_STATE_KEY = '__xenoHost';
|
|
7
|
+
const BLOCKING_HANDOFF_STATUSES = new Set(['externally_advanced', 'needs_reconciliation']);
|
|
8
|
+
/**
|
|
9
|
+
* Host authority for conversation workspace epochs and redacted provider lanes.
|
|
10
|
+
* Provider continuation locators never leave the repository's protected path.
|
|
11
|
+
*/
|
|
12
|
+
export class ProviderLaneService {
|
|
13
|
+
options;
|
|
14
|
+
now;
|
|
15
|
+
createId;
|
|
16
|
+
writerLeases;
|
|
17
|
+
eventSink;
|
|
18
|
+
nativeTerminals = new Map();
|
|
19
|
+
nativeTerminalByLane = new Map();
|
|
20
|
+
outboxDispatcher;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
if (!options.hostStorageIdentity.trim())
|
|
24
|
+
throw new Error('hostStorageIdentity is required.');
|
|
25
|
+
this.now = options.now ?? (() => new Date());
|
|
26
|
+
this.createId = options.createId ?? randomUUID;
|
|
27
|
+
this.writerLeases = options.writerLeaseService ?? (options.hostInstanceId
|
|
28
|
+
? new ProviderWriterLeaseService({
|
|
29
|
+
store: options.repository.providerLanes,
|
|
30
|
+
hostInstanceId: options.hostInstanceId,
|
|
31
|
+
now: this.now,
|
|
32
|
+
})
|
|
33
|
+
: undefined);
|
|
34
|
+
this.outboxDispatcher = options.runtimeEventRepository
|
|
35
|
+
? new ProviderTransitionOutboxDispatcher({
|
|
36
|
+
store: options.repository.providerLanes,
|
|
37
|
+
runtimeEvents: options.runtimeEventRepository,
|
|
38
|
+
onCommitted: (event) => this.eventSink?.('runtime.event.appended', {
|
|
39
|
+
requestId: event.requestId,
|
|
40
|
+
event,
|
|
41
|
+
}),
|
|
42
|
+
})
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
getHostBinding(conversationId) {
|
|
46
|
+
this.requireHostState(conversationId);
|
|
47
|
+
return {
|
|
48
|
+
schemaVersion: 1,
|
|
49
|
+
conversationId,
|
|
50
|
+
hostStorageIdentity: this.options.hostStorageIdentity,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
setEventSink(sink) {
|
|
54
|
+
this.eventSink = sink;
|
|
55
|
+
}
|
|
56
|
+
start() {
|
|
57
|
+
void this.outboxDispatcher?.dispatchPending();
|
|
58
|
+
}
|
|
59
|
+
async openConversation(input) {
|
|
60
|
+
try {
|
|
61
|
+
const conversationId = boundedId(input.conversationId ?? this.createId(), 'conversationId');
|
|
62
|
+
const mode = input.mode ?? 'chat';
|
|
63
|
+
const existing = this.options.repository.loadConversationForHost(conversationId);
|
|
64
|
+
const workspace = this.resolveWorkspace(conversationId, mode, input.workingDirectory);
|
|
65
|
+
const previousHostState = readHostState(existing?.[HOST_STATE_KEY]);
|
|
66
|
+
const workspaceEpoch = previousHostState
|
|
67
|
+
&& previousHostState.mode === mode
|
|
68
|
+
&& previousHostState.workspaceEpoch.fingerprint === workspace.fingerprint
|
|
69
|
+
? previousHostState.workspaceEpoch
|
|
70
|
+
: this.createWorkspaceEpoch(mode, workspace.directory, workspace.fingerprint);
|
|
71
|
+
const hostState = {
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
hostStorageIdentity: this.options.hostStorageIdentity,
|
|
74
|
+
mode,
|
|
75
|
+
workspaceEpoch,
|
|
76
|
+
workspaceDirectory: workspace.directory,
|
|
77
|
+
...(input.workspaceId ? { workspaceId: boundedId(input.workspaceId, 'workspaceId') } : {}),
|
|
78
|
+
};
|
|
79
|
+
const conversation = {
|
|
80
|
+
...(existing ?? {}),
|
|
81
|
+
id: conversationId,
|
|
82
|
+
workspaceMode: mode === 'chat' ? 'chat-only' : 'workspace',
|
|
83
|
+
...(mode === 'development'
|
|
84
|
+
? { rootDirectory: workspace.directory, grantedDirectories: [workspace.directory] }
|
|
85
|
+
: { rootDirectory: undefined, grantedDirectories: [] }),
|
|
86
|
+
[HOST_STATE_KEY]: hostState,
|
|
87
|
+
};
|
|
88
|
+
const saved = this.options.repository.saveConversationForHost(conversation);
|
|
89
|
+
if (!saved.success)
|
|
90
|
+
return failure('CONVERSATION_NOT_FOUND', saved.error || 'Conversation could not be persisted.');
|
|
91
|
+
return this.conversationResult(conversationId, hostState);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return failureFromError(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async resumeConversation(input) {
|
|
98
|
+
try {
|
|
99
|
+
const conversationId = boundedId(input.conversationId, 'conversationId');
|
|
100
|
+
const conversation = this.options.repository.loadConversationForHost(conversationId);
|
|
101
|
+
const hostState = readHostState(conversation?.[HOST_STATE_KEY]);
|
|
102
|
+
if (!conversation || !hostState || hostState.hostStorageIdentity !== this.options.hostStorageIdentity) {
|
|
103
|
+
return failure('CONVERSATION_NOT_FOUND', 'Conversation is not bound to this host storage authority.');
|
|
104
|
+
}
|
|
105
|
+
if (input.expectedWorkspaceEpochId && input.expectedWorkspaceEpochId !== hostState.workspaceEpoch.id) {
|
|
106
|
+
return failure('WORKSPACE_EPOCH_CONFLICT', 'Conversation workspace changed before resume.');
|
|
107
|
+
}
|
|
108
|
+
return this.conversationResult(conversationId, hostState);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
return failureFromError(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async listLanes(input) {
|
|
115
|
+
try {
|
|
116
|
+
const conversationId = boundedId(input.conversationId, 'conversationId');
|
|
117
|
+
const hostState = this.requireHostState(conversationId);
|
|
118
|
+
const lanes = this.options.repository.providerLanes.listLaneProjections(conversationId, input.includeHistorical !== false);
|
|
119
|
+
const activeLaneId = lanes.find((lane) => lane.workspaceEpoch.id === hostState.workspaceEpoch.id
|
|
120
|
+
&& this.options.repository.providerLanes.getLane(lane.laneId)?.isActive)?.laneId;
|
|
121
|
+
return { success: true, conversationId, ...(activeLaneId ? { activeLaneId } : {}), lanes };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return failureFromError(error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async getLane(input) {
|
|
128
|
+
try {
|
|
129
|
+
const conversationId = boundedId(input.conversationId, 'conversationId');
|
|
130
|
+
this.requireHostState(conversationId);
|
|
131
|
+
const lane = this.options.repository.providerLanes.getLaneProjection(conversationId, boundedId(input.laneId, 'laneId'));
|
|
132
|
+
return lane
|
|
133
|
+
? { success: true, conversationId, lane }
|
|
134
|
+
: failure('CONVERSATION_NOT_FOUND', 'Provider lane was not found.');
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
return failureFromError(error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async selectLane(input) {
|
|
141
|
+
try {
|
|
142
|
+
const conversationId = boundedId(input.conversationId, 'conversationId');
|
|
143
|
+
const requestHash = hashCanonical({
|
|
144
|
+
scope: 'provider.lanes.select',
|
|
145
|
+
conversationId,
|
|
146
|
+
providerId: input.providerId,
|
|
147
|
+
catalogRevision: input.catalogRevision,
|
|
148
|
+
modelId: input.modelId ?? null,
|
|
149
|
+
laneId: input.laneId ?? null,
|
|
150
|
+
expectedLaneRevision: input.expectedLaneRevision ?? null,
|
|
151
|
+
expectedWorkspaceEpochId: input.expectedWorkspaceEpochId ?? null,
|
|
152
|
+
});
|
|
153
|
+
const replay = this.options.repository.providerLanes.replayIdempotency(input.command.idempotencyKey, requestHash);
|
|
154
|
+
if (replay.found) {
|
|
155
|
+
return replay.conflict
|
|
156
|
+
? failure('LANE_REVISION_CONFLICT', 'Idempotency key was already used for a different provider selection.')
|
|
157
|
+
: replay.response;
|
|
158
|
+
}
|
|
159
|
+
const hostState = this.requireHostState(conversationId);
|
|
160
|
+
if (input.expectedWorkspaceEpochId && input.expectedWorkspaceEpochId !== hostState.workspaceEpoch.id) {
|
|
161
|
+
return failure('WORKSPACE_EPOCH_CONFLICT', 'Conversation workspace changed before provider selection.');
|
|
162
|
+
}
|
|
163
|
+
const providers = await this.options.getProviders();
|
|
164
|
+
const provider = providers.find((candidate) => candidate.id === input.providerId);
|
|
165
|
+
if (!provider)
|
|
166
|
+
return failure('PROVIDER_UNAVAILABLE', 'Selected provider is not in the current catalog.');
|
|
167
|
+
if (provider.catalogRevision !== input.catalogRevision) {
|
|
168
|
+
return failure('CATALOG_REVISION_CONFLICT', 'Provider catalog changed before selection.');
|
|
169
|
+
}
|
|
170
|
+
if (provider.availability !== 'available' && provider.availability !== 'degraded') {
|
|
171
|
+
return failure('PROVIDER_UNAVAILABLE', provider.safeUnavailableReason || 'Selected provider is unavailable.');
|
|
172
|
+
}
|
|
173
|
+
const model = resolveModel(provider, input.modelId);
|
|
174
|
+
if ('code' in model)
|
|
175
|
+
return failure(model.code, model.error);
|
|
176
|
+
const compatibilityKey = resumeCompatibilityKey(provider, model.value?.id);
|
|
177
|
+
const lanes = this.options.repository.providerLanes.listLaneProjections(conversationId, true);
|
|
178
|
+
const requestedLane = input.laneId
|
|
179
|
+
? lanes.find((lane) => lane.laneId === input.laneId)
|
|
180
|
+
: lanes.find((lane) => isReusableLane(lane, provider.id, model.value?.id, compatibilityKey, hostState.workspaceEpoch.id));
|
|
181
|
+
if (input.laneId && !requestedLane) {
|
|
182
|
+
return failure('CONVERSATION_NOT_FOUND', 'Requested provider lane was not found.');
|
|
183
|
+
}
|
|
184
|
+
if (requestedLane && !isReusableLane(requestedLane, provider.id, model.value?.id, compatibilityKey, hostState.workspaceEpoch.id)) {
|
|
185
|
+
return failure('MODEL_RESUME_INCOMPATIBLE', 'Requested lane is not compatible with this provider, model, or workspace epoch.');
|
|
186
|
+
}
|
|
187
|
+
const active = this.options.repository.providerLanes.getActiveLane(conversationId);
|
|
188
|
+
if (active && active.laneId !== requestedLane?.laneId && BLOCKING_HANDOFF_STATUSES.has(active.status)) {
|
|
189
|
+
return failure('RECONCILIATION_REQUIRED', 'The active provider lane must be reconciled before switching providers.');
|
|
190
|
+
}
|
|
191
|
+
if (requestedLane && input.laneId && input.expectedLaneRevision === undefined) {
|
|
192
|
+
return failure('LANE_REVISION_CONFLICT', 'expectedLaneRevision is required when selecting an existing lane.');
|
|
193
|
+
}
|
|
194
|
+
if (requestedLane && input.expectedLaneRevision !== undefined && input.expectedLaneRevision !== requestedLane.revision) {
|
|
195
|
+
return failure('LANE_REVISION_CONFLICT', 'Provider lane revision changed before selection.');
|
|
196
|
+
}
|
|
197
|
+
if (!requestedLane && input.expectedLaneRevision !== undefined && input.expectedLaneRevision !== 0) {
|
|
198
|
+
return failure('LANE_REVISION_CONFLICT', 'A new provider lane requires expectedLaneRevision 0 or no revision.');
|
|
199
|
+
}
|
|
200
|
+
const timestamp = this.now().toISOString();
|
|
201
|
+
const laneId = requestedLane?.laneId ?? `lane-${this.createId()}`;
|
|
202
|
+
const nextRevision = requestedLane ? requestedLane.revision + 1 : 1;
|
|
203
|
+
const existingRecord = requestedLane
|
|
204
|
+
? this.options.repository.providerLanes.getLane(requestedLane.laneId, true)
|
|
205
|
+
: null;
|
|
206
|
+
const lane = {
|
|
207
|
+
...(existingRecord ?? {}),
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
laneId,
|
|
210
|
+
conversationId,
|
|
211
|
+
runtimeProviderId: provider.id,
|
|
212
|
+
providerKind: provider.kind,
|
|
213
|
+
providerDisplayName: provider.displayName,
|
|
214
|
+
...(model.value ? { modelId: model.value.id, modelDisplayName: model.value.displayName } : {}),
|
|
215
|
+
resumeCompatibilityKey: compatibilityKey,
|
|
216
|
+
workspaceEpoch: hostState.workspaceEpoch,
|
|
217
|
+
status: existingRecord?.status ?? requestedLane?.status ?? 'new',
|
|
218
|
+
revision: requestedLane?.revision ?? 0,
|
|
219
|
+
isActive: true,
|
|
220
|
+
...(provider.providerSession ? { capabilities: provider.providerSession } : {}),
|
|
221
|
+
lastRequestId: input.command.commandId,
|
|
222
|
+
lastUsedAt: timestamp,
|
|
223
|
+
createdAt: existingRecord?.createdAt ?? timestamp,
|
|
224
|
+
updatedAt: timestamp,
|
|
225
|
+
};
|
|
226
|
+
const projection = projectionForRevision(lane, nextRevision);
|
|
227
|
+
const response = { success: true, conversationId, lane: projection };
|
|
228
|
+
const result = this.options.repository.providerLanes.commitLaneTransition({
|
|
229
|
+
lane,
|
|
230
|
+
expectedRevision: requestedLane?.revision ?? 0,
|
|
231
|
+
outbox: {
|
|
232
|
+
outboxId: `outbox-${this.createId()}`,
|
|
233
|
+
conversationId,
|
|
234
|
+
laneId,
|
|
235
|
+
laneRevision: nextRevision,
|
|
236
|
+
transitionType: 'provider.lane.selected',
|
|
237
|
+
eventId: `provider-lane-selected-${input.command.commandId}`,
|
|
238
|
+
idempotencyKey: input.command.idempotencyKey,
|
|
239
|
+
payload: {
|
|
240
|
+
id: `provider-lane-selected-${input.command.commandId}`,
|
|
241
|
+
type: 'provider.lane.selected',
|
|
242
|
+
timestamp,
|
|
243
|
+
lane: projection,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
idempotency: {
|
|
247
|
+
idempotencyKey: input.command.idempotencyKey,
|
|
248
|
+
scope: 'provider.lanes.select',
|
|
249
|
+
requestId: input.command.commandId,
|
|
250
|
+
requestHash,
|
|
251
|
+
laneId,
|
|
252
|
+
conversationId,
|
|
253
|
+
response: response,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
if (!result.success)
|
|
257
|
+
return failure(result.code ?? 'LANE_REVISION_CONFLICT', result.error || 'Provider lane selection failed.');
|
|
258
|
+
void this.outboxDispatcher?.dispatchPending();
|
|
259
|
+
return result.replayed && result.response
|
|
260
|
+
? result.response
|
|
261
|
+
: response;
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
return failureFromError(error);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async attachNative(input) {
|
|
268
|
+
try {
|
|
269
|
+
const writerLeases = this.requireWriterLeases();
|
|
270
|
+
const conversationId = boundedId(input.conversationId, 'conversationId');
|
|
271
|
+
const laneId = boundedId(input.laneId, 'laneId');
|
|
272
|
+
const hostState = this.requireHostState(conversationId);
|
|
273
|
+
const lane = this.options.repository.providerLanes.getLane(laneId, true);
|
|
274
|
+
if (!lane || lane.conversationId !== conversationId || lane.workspaceEpoch.id !== hostState.workspaceEpoch.id) {
|
|
275
|
+
return failure('CONVERSATION_NOT_FOUND', 'Provider lane was not found for this workspace epoch.');
|
|
276
|
+
}
|
|
277
|
+
if (lane.revision !== input.expectedLaneRevision) {
|
|
278
|
+
return failure('LANE_REVISION_CONFLICT', 'Provider lane revision changed before native attach.');
|
|
279
|
+
}
|
|
280
|
+
if (lane.status === 'active_structured' || lane.status === 'active_native') {
|
|
281
|
+
return failure('LANE_WRITER_ACTIVE', 'Provider lane already has an active writer.');
|
|
282
|
+
}
|
|
283
|
+
if (lane.status === 'externally_advanced' || lane.status === 'needs_reconciliation') {
|
|
284
|
+
return failure('RECONCILIATION_REQUIRED', 'Provider lane must be reconciled before native attach.');
|
|
285
|
+
}
|
|
286
|
+
const native = readNativeContinuation(lane.continuation);
|
|
287
|
+
if (!native)
|
|
288
|
+
return failure('NATIVE_TUI_UNSUPPORTED', 'Provider lane does not contain a native resumable session.');
|
|
289
|
+
if (!this.options.resolveNativeLauncher) {
|
|
290
|
+
return failure('NATIVE_TUI_UNSUPPORTED', 'Provider-owned native launcher resolution is unavailable.');
|
|
291
|
+
}
|
|
292
|
+
const timestamp = this.now().toISOString();
|
|
293
|
+
const lease = createWriterLease({
|
|
294
|
+
leaseId: `lease-${this.createId()}`,
|
|
295
|
+
lane,
|
|
296
|
+
nativeProviderId: native.providerId,
|
|
297
|
+
nativeSessionHash: hashText(native.nativeSessionId),
|
|
298
|
+
hostInstanceId: this.options.hostInstanceId ?? 'injected-writer-lease-service',
|
|
299
|
+
ownerSurface: input.command.client.surface,
|
|
300
|
+
workspaceEpochId: hostState.workspaceEpoch.id,
|
|
301
|
+
authoritySnapshotHash: hashCanonical({
|
|
302
|
+
workspaceEpochId: hostState.workspaceEpoch.id,
|
|
303
|
+
client: input.command.client,
|
|
304
|
+
laneRevision: lane.revision,
|
|
305
|
+
}),
|
|
306
|
+
timestamp,
|
|
307
|
+
});
|
|
308
|
+
const acquired = writerLeases.acquireLease(lease);
|
|
309
|
+
if (!acquired.success || !acquired.lease) {
|
|
310
|
+
return failure(acquired.code ?? 'LANE_WRITER_UNKNOWN', acquired.error || 'Provider writer lease could not be acquired.');
|
|
311
|
+
}
|
|
312
|
+
const laneResult = this.options.repository.providerLanes.saveLane({
|
|
313
|
+
...lane,
|
|
314
|
+
status: 'active_native',
|
|
315
|
+
nativeProviderId: native.providerId,
|
|
316
|
+
redactedNativeSessionHash: hashText(native.nativeSessionId),
|
|
317
|
+
lastRequestId: input.command.commandId,
|
|
318
|
+
lastUsedAt: timestamp,
|
|
319
|
+
updatedAt: timestamp,
|
|
320
|
+
}, lane.revision, true);
|
|
321
|
+
if (!laneResult.success || !laneResult.lane) {
|
|
322
|
+
releaseWriterLease(writerLeases, acquired.lease);
|
|
323
|
+
return failure(laneResult.code ?? 'LANE_REVISION_CONFLICT', laneResult.error || 'Provider lane changed before native attach.');
|
|
324
|
+
}
|
|
325
|
+
const started = await this.options.workspaceService.startPty({
|
|
326
|
+
client: input.command.client,
|
|
327
|
+
conversationId,
|
|
328
|
+
cwd: hostState.workspaceDirectory,
|
|
329
|
+
allowedDirectories: [hostState.workspaceDirectory],
|
|
330
|
+
...(input.cols === undefined ? {} : { cols: input.cols }),
|
|
331
|
+
...(input.rows === undefined ? {} : { rows: input.rows }),
|
|
332
|
+
profile: {
|
|
333
|
+
kind: 'provider-session',
|
|
334
|
+
laneId,
|
|
335
|
+
expectedLaneRevision: laneResult.lane.revision,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
if (!started.success || !started.terminalId) {
|
|
339
|
+
releaseWriterLease(writerLeases, acquired.lease);
|
|
340
|
+
this.restoreLaneAfterFailedNativeStart(laneResult.lane);
|
|
341
|
+
return failure('NATIVE_TUI_UNSUPPORTED', started.error || 'Native provider TUI could not be started.');
|
|
342
|
+
}
|
|
343
|
+
const ownership = {
|
|
344
|
+
terminalId: started.terminalId,
|
|
345
|
+
conversationId,
|
|
346
|
+
laneId,
|
|
347
|
+
lease: acquired.lease,
|
|
348
|
+
ownerInstanceId: input.command.client.instanceId,
|
|
349
|
+
};
|
|
350
|
+
this.nativeTerminals.set(started.terminalId, ownership);
|
|
351
|
+
this.nativeTerminalByLane.set(laneId, started.terminalId);
|
|
352
|
+
this.startLeaseHeartbeat(ownership);
|
|
353
|
+
const result = {
|
|
354
|
+
success: true,
|
|
355
|
+
conversationId,
|
|
356
|
+
laneId,
|
|
357
|
+
terminalId: started.terminalId,
|
|
358
|
+
laneRevision: laneResult.lane.revision,
|
|
359
|
+
};
|
|
360
|
+
this.eventSink?.('provider.native_tui.started', {
|
|
361
|
+
conversationId,
|
|
362
|
+
laneId,
|
|
363
|
+
terminalId: started.terminalId,
|
|
364
|
+
laneRevision: laneResult.lane.revision,
|
|
365
|
+
});
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
return failureFromError(error);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
detachNative(input) {
|
|
373
|
+
try {
|
|
374
|
+
const conversationId = boundedId(input.conversationId, 'conversationId');
|
|
375
|
+
const laneId = boundedId(input.laneId, 'laneId');
|
|
376
|
+
const terminalId = input.terminalId || this.nativeTerminalByLane.get(laneId);
|
|
377
|
+
const ownership = terminalId ? this.nativeTerminals.get(terminalId) : undefined;
|
|
378
|
+
if (!ownership || ownership.conversationId !== conversationId || ownership.laneId !== laneId) {
|
|
379
|
+
return failure('CONVERSATION_NOT_FOUND', 'Active native provider terminal was not found.');
|
|
380
|
+
}
|
|
381
|
+
this.eventSink?.('provider.native_tui.detached', { conversationId, laneId, terminalId: ownership.terminalId });
|
|
382
|
+
return { success: true, conversationId, laneId };
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
return failureFromError(error);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
reattachNative(input) {
|
|
389
|
+
try {
|
|
390
|
+
const conversationId = boundedId(input.conversationId, 'conversationId');
|
|
391
|
+
const laneId = boundedId(input.laneId, 'laneId');
|
|
392
|
+
const terminalId = boundedId(input.terminalId, 'terminalId');
|
|
393
|
+
const ownership = this.nativeTerminals.get(terminalId);
|
|
394
|
+
if (!ownership || ownership.conversationId !== conversationId || ownership.laneId !== laneId) {
|
|
395
|
+
return failure('CONVERSATION_NOT_FOUND', 'Active native provider terminal was not found.');
|
|
396
|
+
}
|
|
397
|
+
if (!this.options.workspaceService.transferPtyOwnership(terminalId, input.command.client.instanceId)) {
|
|
398
|
+
return failure('LANE_WRITER_UNKNOWN', 'Native provider terminal ownership could not be transferred.');
|
|
399
|
+
}
|
|
400
|
+
ownership.ownerInstanceId = input.command.client.instanceId;
|
|
401
|
+
return { success: true, conversationId, laneId, terminalId };
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
return failureFromError(error);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
stopNative(input) {
|
|
408
|
+
try {
|
|
409
|
+
const conversationId = boundedId(input.conversationId, 'conversationId');
|
|
410
|
+
const laneId = boundedId(input.laneId, 'laneId');
|
|
411
|
+
const terminalId = input.terminalId || this.nativeTerminalByLane.get(laneId);
|
|
412
|
+
const ownership = terminalId ? this.nativeTerminals.get(terminalId) : undefined;
|
|
413
|
+
if (!ownership || ownership.conversationId !== conversationId || ownership.laneId !== laneId) {
|
|
414
|
+
return failure('CONVERSATION_NOT_FOUND', 'Active native provider terminal was not found.');
|
|
415
|
+
}
|
|
416
|
+
this.options.workspaceService.transferPtyOwnership(ownership.terminalId, input.command.client.instanceId);
|
|
417
|
+
const stopped = this.options.workspaceService.stopPty({
|
|
418
|
+
client: input.command.client,
|
|
419
|
+
terminalId: ownership.terminalId,
|
|
420
|
+
allowedDirectories: [],
|
|
421
|
+
});
|
|
422
|
+
return stopped.success
|
|
423
|
+
? { success: true, conversationId, laneId, exited: false }
|
|
424
|
+
: failure('LANE_WRITER_UNKNOWN', stopped.error || 'Native provider terminal could not be stopped.');
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
return failureFromError(error);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
reconcile(input) {
|
|
431
|
+
try {
|
|
432
|
+
const conversationId = boundedId(input.conversationId, 'conversationId');
|
|
433
|
+
const laneId = boundedId(input.laneId, 'laneId');
|
|
434
|
+
const lane = this.options.repository.providerLanes.getLane(laneId, true);
|
|
435
|
+
if (!lane || lane.conversationId !== conversationId) {
|
|
436
|
+
return failure('CONVERSATION_NOT_FOUND', 'Provider lane was not found.');
|
|
437
|
+
}
|
|
438
|
+
if (lane.revision !== input.expectedLaneRevision) {
|
|
439
|
+
return failure('LANE_REVISION_CONFLICT', 'Provider lane revision changed before reconciliation.');
|
|
440
|
+
}
|
|
441
|
+
if (lane.status !== 'externally_advanced' && lane.status !== 'needs_reconciliation') {
|
|
442
|
+
return failure('RECONCILIATION_FAILED', 'Provider lane does not require reconciliation.');
|
|
443
|
+
}
|
|
444
|
+
if (!input.acknowledgePartialContext) {
|
|
445
|
+
return failure('RECONCILIATION_REQUIRED', 'Native provider history import is partial and must be acknowledged.');
|
|
446
|
+
}
|
|
447
|
+
const timestamp = this.now().toISOString();
|
|
448
|
+
const capsuleId = `handoff-${this.createId()}`;
|
|
449
|
+
const capsule = {
|
|
450
|
+
schemaVersion: 1,
|
|
451
|
+
id: capsuleId,
|
|
452
|
+
sourceRuntimeProviderId: lane.runtimeProviderId,
|
|
453
|
+
...(lane.redactedNativeSessionHash ? { sourceNativeSessionHash: lane.redactedNativeSessionHash } : {}),
|
|
454
|
+
workspaceEpochId: lane.workspaceEpoch.id,
|
|
455
|
+
createdAt: timestamp,
|
|
456
|
+
summary: 'Native provider TUI exited. The exact provider session will resume; unstructured TUI-only context was not imported.',
|
|
457
|
+
decisions: [],
|
|
458
|
+
changedFiles: [],
|
|
459
|
+
commandsAndTests: [],
|
|
460
|
+
unresolvedIssues: ['TUI-only conversational context was not structurally imported.'],
|
|
461
|
+
nextActions: ['Resume the provider lane in chat and verify the workspace state.'],
|
|
462
|
+
completeness: 'partial',
|
|
463
|
+
};
|
|
464
|
+
const { invalidationReason: _invalidationReason, ...laneWithoutInvalidation } = lane;
|
|
465
|
+
const saved = this.options.repository.providerLanes.saveLane({
|
|
466
|
+
...laneWithoutInvalidation,
|
|
467
|
+
status: 'clean',
|
|
468
|
+
lastHandoffCapsuleId: capsuleId,
|
|
469
|
+
lastRequestId: input.command.commandId,
|
|
470
|
+
lastUsedAt: timestamp,
|
|
471
|
+
updatedAt: timestamp,
|
|
472
|
+
}, lane.revision, true);
|
|
473
|
+
return saved.success && saved.projection
|
|
474
|
+
? { success: true, conversationId, laneId, lane: saved.projection, capsule, acknowledgedPartial: true }
|
|
475
|
+
: failure(saved.code ?? 'RECONCILIATION_FAILED', saved.error || 'Provider lane reconciliation failed.');
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
return failureFromError(error);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
resolveProviderSessionPty = async (profile, cwd) => {
|
|
482
|
+
const lane = this.options.repository.providerLanes.getLane(profile.laneId, true);
|
|
483
|
+
if (!lane || lane.revision !== profile.expectedLaneRevision || lane.status !== 'active_native') {
|
|
484
|
+
return { code: 'LANE_REVISION_CONFLICT', error: 'Provider lane changed before native TUI launch.' };
|
|
485
|
+
}
|
|
486
|
+
const native = readNativeContinuation(lane.continuation);
|
|
487
|
+
if (!native || !this.options.resolveNativeLauncher) {
|
|
488
|
+
return { code: 'NATIVE_TUI_UNSUPPORTED', error: 'Provider lane has no provider-owned native launcher.' };
|
|
489
|
+
}
|
|
490
|
+
return this.options.resolveNativeLauncher({
|
|
491
|
+
runtimeProviderId: lane.runtimeProviderId,
|
|
492
|
+
nativeProviderId: native.providerId,
|
|
493
|
+
nativeSessionId: native.nativeSessionId,
|
|
494
|
+
cwd,
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
handleWorkspacePtyExit(payload) {
|
|
498
|
+
const terminalId = typeof payload.terminalId === 'string' ? payload.terminalId : '';
|
|
499
|
+
const ownership = this.nativeTerminals.get(terminalId);
|
|
500
|
+
if (!ownership)
|
|
501
|
+
return;
|
|
502
|
+
this.nativeTerminals.delete(terminalId);
|
|
503
|
+
this.nativeTerminalByLane.delete(ownership.laneId);
|
|
504
|
+
if (ownership.heartbeat)
|
|
505
|
+
clearInterval(ownership.heartbeat);
|
|
506
|
+
releaseWriterLease(this.requireWriterLeases(), ownership.lease);
|
|
507
|
+
const lane = this.options.repository.providerLanes.getLane(ownership.laneId, true);
|
|
508
|
+
if (lane?.status === 'active_native') {
|
|
509
|
+
const timestamp = this.now().toISOString();
|
|
510
|
+
this.options.repository.providerLanes.saveLane({
|
|
511
|
+
...lane,
|
|
512
|
+
status: 'externally_advanced',
|
|
513
|
+
invalidationReason: 'Native provider TUI exited; reconcile before structured execution.',
|
|
514
|
+
lastUsedAt: timestamp,
|
|
515
|
+
updatedAt: timestamp,
|
|
516
|
+
}, lane.revision, true);
|
|
517
|
+
}
|
|
518
|
+
this.eventSink?.('provider.native_tui.exited', {
|
|
519
|
+
conversationId: ownership.conversationId,
|
|
520
|
+
laneId: ownership.laneId,
|
|
521
|
+
terminalId,
|
|
522
|
+
...(typeof payload.exitCode === 'number' ? { exitCode: payload.exitCode } : {}),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
close() {
|
|
526
|
+
for (const ownership of this.nativeTerminals.values()) {
|
|
527
|
+
if (ownership.heartbeat)
|
|
528
|
+
clearInterval(ownership.heartbeat);
|
|
529
|
+
}
|
|
530
|
+
this.nativeTerminals.clear();
|
|
531
|
+
this.nativeTerminalByLane.clear();
|
|
532
|
+
}
|
|
533
|
+
getWorkspaceDirectory(conversationId) {
|
|
534
|
+
return this.requireHostState(conversationId).workspaceDirectory;
|
|
535
|
+
}
|
|
536
|
+
requireWriterLeases() {
|
|
537
|
+
if (!this.writerLeases)
|
|
538
|
+
throw cockpitError('LANE_WRITER_UNKNOWN', 'Provider writer lease authority is unavailable.');
|
|
539
|
+
return this.writerLeases;
|
|
540
|
+
}
|
|
541
|
+
startLeaseHeartbeat(ownership) {
|
|
542
|
+
ownership.heartbeat = setInterval(() => {
|
|
543
|
+
const nextExpiry = new Date(this.now().valueOf() + 45_000).toISOString();
|
|
544
|
+
const renewed = this.requireWriterLeases().renewLease(ownership.lease.leaseId, ownership.laneId, ownership.lease.leaseRevision, nextExpiry);
|
|
545
|
+
if (renewed.success && renewed.lease)
|
|
546
|
+
ownership.lease = renewed.lease;
|
|
547
|
+
}, 15_000);
|
|
548
|
+
ownership.heartbeat.unref?.();
|
|
549
|
+
}
|
|
550
|
+
restoreLaneAfterFailedNativeStart(lane) {
|
|
551
|
+
const current = this.options.repository.providerLanes.getLane(lane.laneId, true);
|
|
552
|
+
if (!current || current.status !== 'active_native')
|
|
553
|
+
return;
|
|
554
|
+
const timestamp = this.now().toISOString();
|
|
555
|
+
this.options.repository.providerLanes.saveLane({
|
|
556
|
+
...current,
|
|
557
|
+
status: 'clean',
|
|
558
|
+
updatedAt: timestamp,
|
|
559
|
+
}, current.revision, true);
|
|
560
|
+
}
|
|
561
|
+
prepareTurn(request) {
|
|
562
|
+
const hostState = this.requireHostState(request.conversationId);
|
|
563
|
+
if (request.mode !== hostState.mode) {
|
|
564
|
+
throw cockpitError('WORKSPACE_EPOCH_CONFLICT', 'Turn mode does not match the active conversation workspace epoch.');
|
|
565
|
+
}
|
|
566
|
+
const lane = this.options.repository.providerLanes.getActiveLane(request.conversationId, true);
|
|
567
|
+
if (!lane || lane.workspaceEpoch.id !== hostState.workspaceEpoch.id) {
|
|
568
|
+
throw cockpitError('CONVERSATION_BINDING_REQUIRED', 'Select a provider lane before starting a turn.');
|
|
569
|
+
}
|
|
570
|
+
if (lane.runtimeProviderId !== request.selection.providerId) {
|
|
571
|
+
throw cockpitError('MODEL_RESUME_INCOMPATIBLE', 'Turn provider does not match the active provider lane.');
|
|
572
|
+
}
|
|
573
|
+
if (lane.modelId && request.selection.modelId && lane.modelId !== request.selection.modelId) {
|
|
574
|
+
throw cockpitError('MODEL_RESUME_INCOMPATIBLE', 'Turn model does not match the active provider lane.');
|
|
575
|
+
}
|
|
576
|
+
if (lane.status === 'active_native') {
|
|
577
|
+
throw cockpitError('LANE_WRITER_ACTIVE', 'The provider lane is owned by a native TUI.');
|
|
578
|
+
}
|
|
579
|
+
if (lane.status === 'active_structured') {
|
|
580
|
+
throw cockpitError('LANE_WRITER_ACTIVE', 'The provider lane already has an active writer for a structured turn.');
|
|
581
|
+
}
|
|
582
|
+
if (lane.status === 'externally_advanced' || lane.status === 'needs_reconciliation') {
|
|
583
|
+
throw cockpitError('RECONCILIATION_REQUIRED', 'The provider lane must be reconciled before structured execution.');
|
|
584
|
+
}
|
|
585
|
+
const continuation = readContinuation(lane.continuation);
|
|
586
|
+
const prepared = structuredClone(request);
|
|
587
|
+
prepared.workspace = {
|
|
588
|
+
...(request.workspace?.permissionProfile ? { permissionProfile: request.workspace.permissionProfile } : {}),
|
|
589
|
+
primaryDirectory: hostState.workspaceDirectory,
|
|
590
|
+
filesystem: request.workspace?.filesystem ?? 'workspace-write',
|
|
591
|
+
};
|
|
592
|
+
delete prepared.continuation;
|
|
593
|
+
if (continuation)
|
|
594
|
+
prepared.continuation = continuation;
|
|
595
|
+
return prepared;
|
|
596
|
+
}
|
|
597
|
+
beginStructuredTurn(request) {
|
|
598
|
+
const lane = this.options.repository.providerLanes.getActiveLane(request.conversationId, true);
|
|
599
|
+
if (!lane)
|
|
600
|
+
throw cockpitError('CONVERSATION_BINDING_REQUIRED', 'Active provider lane was not found.');
|
|
601
|
+
if (lane.status === 'active_native') {
|
|
602
|
+
throw cockpitError('LANE_WRITER_ACTIVE', 'The provider lane is owned by a native TUI.');
|
|
603
|
+
}
|
|
604
|
+
if (lane.status === 'active_structured') {
|
|
605
|
+
throw cockpitError('LANE_WRITER_ACTIVE', 'The provider lane already has an active writer for a structured turn.');
|
|
606
|
+
}
|
|
607
|
+
if (lane.status === 'externally_advanced' || lane.status === 'needs_reconciliation') {
|
|
608
|
+
throw cockpitError('RECONCILIATION_REQUIRED', 'The provider lane must be reconciled before structured execution.');
|
|
609
|
+
}
|
|
610
|
+
const timestamp = this.now().toISOString();
|
|
611
|
+
const saved = this.options.repository.providerLanes.saveLane({
|
|
612
|
+
...lane,
|
|
613
|
+
status: 'active_structured',
|
|
614
|
+
lastRequestId: boundedId(request.requestId, 'requestId'),
|
|
615
|
+
lastUsedAt: timestamp,
|
|
616
|
+
updatedAt: timestamp,
|
|
617
|
+
}, lane.revision, true);
|
|
618
|
+
if (!saved.success)
|
|
619
|
+
throw cockpitError(saved.code ?? 'LANE_REVISION_CONFLICT', saved.error || 'Provider lane writer acquisition failed.');
|
|
620
|
+
}
|
|
621
|
+
finishStructuredTurn(conversationId, requestId) {
|
|
622
|
+
const lane = this.options.repository.providerLanes.getActiveLane(conversationId, true);
|
|
623
|
+
if (!lane || lane.status !== 'active_structured' || lane.lastRequestId !== requestId)
|
|
624
|
+
return;
|
|
625
|
+
const timestamp = this.now().toISOString();
|
|
626
|
+
this.options.repository.providerLanes.saveLane({
|
|
627
|
+
...lane,
|
|
628
|
+
status: 'clean',
|
|
629
|
+
lastUsedAt: timestamp,
|
|
630
|
+
updatedAt: timestamp,
|
|
631
|
+
}, lane.revision, true);
|
|
632
|
+
}
|
|
633
|
+
commitTurnContinuation(input) {
|
|
634
|
+
try {
|
|
635
|
+
const hostState = this.requireHostState(input.conversationId);
|
|
636
|
+
const lane = this.options.repository.providerLanes.getActiveLane(input.conversationId, true);
|
|
637
|
+
if (!lane || lane.workspaceEpoch.id !== hostState.workspaceEpoch.id) {
|
|
638
|
+
return failure('CONVERSATION_BINDING_REQUIRED', 'Active provider lane was not found.');
|
|
639
|
+
}
|
|
640
|
+
const continuation = readContinuation(input.continuation);
|
|
641
|
+
if (!continuation || continuation.providerId !== lane.runtimeProviderId) {
|
|
642
|
+
return failure('CONTINUATION_INVALID', 'Provider continuation does not match the active lane.');
|
|
643
|
+
}
|
|
644
|
+
const timestamp = this.now().toISOString();
|
|
645
|
+
const result = this.options.repository.providerLanes.saveLane({
|
|
646
|
+
...lane,
|
|
647
|
+
continuation,
|
|
648
|
+
continuationHash: hashCanonical(continuation),
|
|
649
|
+
status: lane.status === 'active_structured' ? 'active_structured' : 'clean',
|
|
650
|
+
lastRequestId: boundedId(input.requestId, 'requestId'),
|
|
651
|
+
lastUsedAt: timestamp,
|
|
652
|
+
updatedAt: timestamp,
|
|
653
|
+
}, lane.revision, true);
|
|
654
|
+
return result.success
|
|
655
|
+
? { success: true, ...(result.projection ? { lane: result.projection } : {}) }
|
|
656
|
+
: failure(result.code ?? 'LANE_REVISION_CONFLICT', result.error || 'Provider continuation could not be committed.');
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
return failureFromError(error);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
conversationResult(conversationId, hostState) {
|
|
663
|
+
const lanes = this.options.repository.providerLanes.listLaneProjections(conversationId, true);
|
|
664
|
+
const activeLane = lanes.find((lane) => lane.workspaceEpoch.id === hostState.workspaceEpoch.id
|
|
665
|
+
&& this.options.repository.providerLanes.getLane(lane.laneId)?.isActive);
|
|
666
|
+
return {
|
|
667
|
+
success: true,
|
|
668
|
+
conversationId,
|
|
669
|
+
hostStorageIdentity: this.options.hostStorageIdentity,
|
|
670
|
+
mode: hostState.mode,
|
|
671
|
+
workspaceEpoch: workspaceEpochProjection(hostState.workspaceEpoch),
|
|
672
|
+
...(activeLane ? { activeLane } : {}),
|
|
673
|
+
lanes,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
requireHostState(conversationId) {
|
|
677
|
+
const conversation = this.options.repository.loadConversationForHost(conversationId);
|
|
678
|
+
const hostState = readHostState(conversation?.[HOST_STATE_KEY]);
|
|
679
|
+
if (!conversation || !hostState || hostState.hostStorageIdentity !== this.options.hostStorageIdentity) {
|
|
680
|
+
throw cockpitError('CONVERSATION_NOT_FOUND', 'Conversation is not bound to this host storage authority.');
|
|
681
|
+
}
|
|
682
|
+
return hostState;
|
|
683
|
+
}
|
|
684
|
+
resolveWorkspace(conversationId, mode, requestedDirectory) {
|
|
685
|
+
if (mode === 'chat') {
|
|
686
|
+
const directory = this.options.workspaceService.ensureManagedConversationWorkspace(conversationId);
|
|
687
|
+
return { directory, fingerprint: workspaceFingerprint(mode, directory) };
|
|
688
|
+
}
|
|
689
|
+
if (!requestedDirectory?.trim())
|
|
690
|
+
throw cockpitError('WORKSPACE_REQUIRED', 'Agent mode requires a selected local folder.');
|
|
691
|
+
const absolute = resolve(requestedDirectory.trim());
|
|
692
|
+
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
|
|
693
|
+
throw cockpitError('WORKSPACE_REQUIRED', 'Selected local folder does not exist.');
|
|
694
|
+
}
|
|
695
|
+
const directory = realpathSync.native(absolute);
|
|
696
|
+
return { directory, fingerprint: workspaceFingerprint(mode, directory) };
|
|
697
|
+
}
|
|
698
|
+
createWorkspaceEpoch(mode, directory, fingerprint) {
|
|
699
|
+
return {
|
|
700
|
+
id: `epoch-${this.createId()}`,
|
|
701
|
+
fingerprint,
|
|
702
|
+
wireMode: mode,
|
|
703
|
+
kind: mode === 'chat' ? 'private-chat' : 'local-workspace',
|
|
704
|
+
label: mode === 'chat' ? 'Private chat workspace' : directory,
|
|
705
|
+
allowedDirectoryFingerprints: [hashPath(directory)],
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
function resolveModel(provider, requestedModelId) {
|
|
710
|
+
const modelId = requestedModelId ?? provider.defaultModelId;
|
|
711
|
+
if (!modelId) {
|
|
712
|
+
return provider.allowsProviderDefaultModel
|
|
713
|
+
? {}
|
|
714
|
+
: { code: 'PROVIDER_UNAVAILABLE', error: 'Selected provider requires an explicit model.' };
|
|
715
|
+
}
|
|
716
|
+
const model = provider.models.find((candidate) => candidate.id === modelId);
|
|
717
|
+
if (!model)
|
|
718
|
+
return { code: 'PROVIDER_UNAVAILABLE', error: 'Selected model is not in the current provider catalog.' };
|
|
719
|
+
if (model.availability !== 'available' && model.availability !== 'degraded') {
|
|
720
|
+
return { code: 'PROVIDER_UNAVAILABLE', error: model.safeUnavailableReason || 'Selected model is unavailable.' };
|
|
721
|
+
}
|
|
722
|
+
return { value: model };
|
|
723
|
+
}
|
|
724
|
+
function resumeCompatibilityKey(provider, modelId) {
|
|
725
|
+
const providerKey = provider.providerSession?.resumeCompatibilityKey?.trim() || `${provider.id}:provider-session-v1`;
|
|
726
|
+
const supportsModelSwitch = provider.providerSession?.modelSwitchInPlace?.status === 'available';
|
|
727
|
+
return supportsModelSwitch || !modelId ? providerKey : `${providerKey}:model=${modelId}`;
|
|
728
|
+
}
|
|
729
|
+
function isReusableLane(lane, providerId, modelId, compatibilityKey, workspaceEpochId) {
|
|
730
|
+
return lane.runtimeProviderId === providerId
|
|
731
|
+
&& lane.workspaceEpoch.id === workspaceEpochId
|
|
732
|
+
&& lane.resumeCompatibilityKey === compatibilityKey
|
|
733
|
+
&& lane.modelId === modelId
|
|
734
|
+
&& lane.status !== 'invalidated'
|
|
735
|
+
&& lane.status !== 'error';
|
|
736
|
+
}
|
|
737
|
+
function projectionForRevision(lane, revision) {
|
|
738
|
+
return {
|
|
739
|
+
schemaVersion: 1,
|
|
740
|
+
laneId: lane.laneId,
|
|
741
|
+
conversationId: lane.conversationId,
|
|
742
|
+
runtimeProviderId: lane.runtimeProviderId,
|
|
743
|
+
providerKind: lane.providerKind,
|
|
744
|
+
...(lane.providerDisplayName ? { providerDisplayName: lane.providerDisplayName } : {}),
|
|
745
|
+
...(lane.modelId ? { modelId: lane.modelId } : {}),
|
|
746
|
+
...(lane.modelDisplayName ? { modelDisplayName: lane.modelDisplayName } : {}),
|
|
747
|
+
resumeCompatibilityKey: lane.resumeCompatibilityKey,
|
|
748
|
+
workspaceEpoch: workspaceEpochProjection(lane.workspaceEpoch),
|
|
749
|
+
status: lane.status,
|
|
750
|
+
revision,
|
|
751
|
+
...(lane.capabilities ? { capabilities: lane.capabilities } : {}),
|
|
752
|
+
...(lane.lastUsedAt ? { lastUsedAt: lane.lastUsedAt } : {}),
|
|
753
|
+
...(lane.lastRequestId ? { lastRequestId: lane.lastRequestId } : {}),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
function workspaceEpochProjection(epoch) {
|
|
757
|
+
return {
|
|
758
|
+
id: epoch.id,
|
|
759
|
+
fingerprint: epoch.fingerprint,
|
|
760
|
+
wireMode: epoch.wireMode,
|
|
761
|
+
kind: epoch.kind,
|
|
762
|
+
...(epoch.label ? { label: epoch.label } : {}),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function readHostState(value) {
|
|
766
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
767
|
+
return null;
|
|
768
|
+
const record = value;
|
|
769
|
+
const epoch = record.workspaceEpoch;
|
|
770
|
+
if (record.schemaVersion !== 1
|
|
771
|
+
|| typeof record.hostStorageIdentity !== 'string'
|
|
772
|
+
|| (record.mode !== 'chat' && record.mode !== 'development')
|
|
773
|
+
|| typeof record.workspaceDirectory !== 'string'
|
|
774
|
+
|| !epoch || typeof epoch !== 'object' || Array.isArray(epoch))
|
|
775
|
+
return null;
|
|
776
|
+
const epochRecord = epoch;
|
|
777
|
+
if (typeof epochRecord.id !== 'string'
|
|
778
|
+
|| typeof epochRecord.fingerprint !== 'string'
|
|
779
|
+
|| (epochRecord.wireMode !== 'chat' && epochRecord.wireMode !== 'development')
|
|
780
|
+
|| (epochRecord.kind !== 'private-chat' && epochRecord.kind !== 'local-workspace')
|
|
781
|
+
|| !Array.isArray(epochRecord.allowedDirectoryFingerprints)
|
|
782
|
+
|| !epochRecord.allowedDirectoryFingerprints.every((entry) => typeof entry === 'string'))
|
|
783
|
+
return null;
|
|
784
|
+
return structuredClone(record);
|
|
785
|
+
}
|
|
786
|
+
function readContinuation(value) {
|
|
787
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
788
|
+
return null;
|
|
789
|
+
const record = value;
|
|
790
|
+
if (typeof record.providerId !== 'string' || !record.providerId.trim()
|
|
791
|
+
|| typeof record.providerSessionId !== 'string' || !record.providerSessionId.trim())
|
|
792
|
+
return null;
|
|
793
|
+
if (record.state !== undefined && (!record.state || typeof record.state !== 'object' || Array.isArray(record.state))) {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
for (const field of ['providerConfigFingerprint', 'workspaceFingerprint']) {
|
|
797
|
+
if (record[field] !== undefined && (typeof record[field] !== 'string' || !record[field].trim()))
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
return structuredClone(record);
|
|
801
|
+
}
|
|
802
|
+
function readNativeContinuation(value) {
|
|
803
|
+
const continuation = readContinuation(value);
|
|
804
|
+
const state = continuation?.state;
|
|
805
|
+
if (!state || state.schemaVersion !== 1
|
|
806
|
+
|| typeof state.providerId !== 'string' || !state.providerId.trim()
|
|
807
|
+
|| typeof state.nativeSessionId !== 'string' || !state.nativeSessionId.trim())
|
|
808
|
+
return null;
|
|
809
|
+
return { providerId: state.providerId.trim(), nativeSessionId: state.nativeSessionId.trim() };
|
|
810
|
+
}
|
|
811
|
+
function createWriterLease(input) {
|
|
812
|
+
const processStart = new Date(Date.now() - process.uptime() * 1_000).toISOString();
|
|
813
|
+
return {
|
|
814
|
+
schemaVersion: 1,
|
|
815
|
+
leaseId: input.leaseId,
|
|
816
|
+
laneId: input.lane.laneId,
|
|
817
|
+
laneRevision: input.lane.revision,
|
|
818
|
+
leaseRevision: 0,
|
|
819
|
+
conversationId: input.lane.conversationId,
|
|
820
|
+
hostInstanceId: input.hostInstanceId,
|
|
821
|
+
runtimeProviderId: input.lane.runtimeProviderId,
|
|
822
|
+
nativeProviderId: input.nativeProviderId,
|
|
823
|
+
nativeSessionHash: input.nativeSessionHash,
|
|
824
|
+
ownerSurface: input.ownerSurface,
|
|
825
|
+
ownerPid: process.pid,
|
|
826
|
+
ownerProcessStartTime: processStart,
|
|
827
|
+
ownerProcessIdentity: `${process.execPath}:${process.pid}`,
|
|
828
|
+
state: 'active',
|
|
829
|
+
acquiredAt: input.timestamp,
|
|
830
|
+
expiresAt: new Date(Date.parse(input.timestamp) + 45_000).toISOString(),
|
|
831
|
+
lastHeartbeatAt: input.timestamp,
|
|
832
|
+
workspaceEpochId: input.workspaceEpochId,
|
|
833
|
+
authoritySnapshotHash: input.authoritySnapshotHash,
|
|
834
|
+
createdAt: input.timestamp,
|
|
835
|
+
updatedAt: input.timestamp,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
function releaseWriterLease(service, lease) {
|
|
839
|
+
const releasing = lease.state === 'releasing'
|
|
840
|
+
? { success: true, lease }
|
|
841
|
+
: service.beginRelease(lease.leaseId, lease.laneId, lease.leaseRevision);
|
|
842
|
+
if (releasing.success && releasing.lease) {
|
|
843
|
+
service.confirmReleased(releasing.lease.leaseId, releasing.lease.laneId, releasing.lease.leaseRevision, true);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
function hashText(value) {
|
|
847
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
848
|
+
}
|
|
849
|
+
function boundedId(value, label) {
|
|
850
|
+
const normalized = value.trim();
|
|
851
|
+
if (!normalized || normalized.length > 240 || normalized.includes('\0')) {
|
|
852
|
+
throw new Error(`${label} is invalid.`);
|
|
853
|
+
}
|
|
854
|
+
return normalized;
|
|
855
|
+
}
|
|
856
|
+
function workspaceFingerprint(mode, directory) {
|
|
857
|
+
return hashCanonical({ mode, directory: normalizedPath(directory) });
|
|
858
|
+
}
|
|
859
|
+
function hashPath(path) {
|
|
860
|
+
return createHash('sha256').update(normalizedPath(path), 'utf8').digest('hex');
|
|
861
|
+
}
|
|
862
|
+
function normalizedPath(path) {
|
|
863
|
+
const canonical = realpathSync.native(path);
|
|
864
|
+
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
|
865
|
+
}
|
|
866
|
+
function hashCanonical(value) {
|
|
867
|
+
return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex');
|
|
868
|
+
}
|
|
869
|
+
function canonicalJson(value) {
|
|
870
|
+
if (Array.isArray(value))
|
|
871
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
872
|
+
if (value && typeof value === 'object') {
|
|
873
|
+
const record = value;
|
|
874
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
|
|
875
|
+
}
|
|
876
|
+
return JSON.stringify(value);
|
|
877
|
+
}
|
|
878
|
+
function cockpitError(code, message) {
|
|
879
|
+
return Object.assign(new Error(message), { code });
|
|
880
|
+
}
|
|
881
|
+
function failure(code, error) {
|
|
882
|
+
return { success: false, code, error };
|
|
883
|
+
}
|
|
884
|
+
function failureFromError(error) {
|
|
885
|
+
const record = error;
|
|
886
|
+
const code = typeof record?.code === 'string' ? record.code : 'CONVERSATION_NOT_FOUND';
|
|
887
|
+
const message = typeof record?.message === 'string' && record.message.trim()
|
|
888
|
+
? record.message.slice(0, 1_000)
|
|
889
|
+
: 'Provider lane operation failed.';
|
|
890
|
+
return failure(code, message);
|
|
891
|
+
}
|
|
892
|
+
//# sourceMappingURL=ProviderLaneService.js.map
|