@ai-workbench/remote-agent 0.8.0 → 0.8.2
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/README.md +11 -1
- package/dist/acp.d.ts +8 -0
- package/dist/acp.js +59 -21
- package/dist/auth-reason.d.ts +1 -0
- package/dist/auth-reason.js +27 -0
- package/dist/connect-contract.d.ts +2 -1
- package/dist/conversation-details.d.ts +47 -0
- package/dist/conversation-details.js +319 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +110 -10
- package/dist/input.js +2 -2
- package/dist/login-diagnostic.d.ts +38 -0
- package/dist/login-diagnostic.js +29 -0
- package/dist/mapping.d.ts +11 -5
- package/dist/mapping.js +105 -141
- package/dist/presentation.js +4 -0
- package/dist/provider.d.ts +13 -4
- package/dist/provider.js +87 -17
- package/dist/readiness.js +7 -5
- package/dist/remote-details.d.ts +39 -0
- package/dist/remote-details.js +381 -0
- package/dist/remote.d.ts +3 -0
- package/dist/remote.js +2 -0
- package/dist/workspace-notice.d.ts +22 -1
- package/dist/workspace-notice.js +148 -129
- package/package.json +3 -3
package/dist/provider.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loginDiagnostic, loginReason } from "./login-diagnostic.js";
|
|
1
2
|
import { NeedError } from "./remote.js";
|
|
2
3
|
import { CONNECTION_REQUIRED, TEST_HOST_NOTICE } from "./presentation.js";
|
|
3
4
|
import { CODEX_AGENT } from "./agents.js";
|
|
@@ -8,6 +9,8 @@ export function requireLoginCoordinator(api) {
|
|
|
8
9
|
export function requireLoginState(state) {
|
|
9
10
|
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
10
11
|
const need = (v) => object(v) && typeof v.code === 'string' && v.code.length > 0 && ['enrollment', 'network', 'ssh', 'auth', 'integration', 'settings', 'projects'].includes(v.layer) && typeof v.message === 'string' && typeof v.requires === 'string' && ['employee', 'administrator', 'ai-administrator', 'plugin'].includes(v.actor) && object(v.action) && typeof v.action.id === 'string' && typeof v.action.label === 'string' && typeof v.action.available === 'boolean';
|
|
12
|
+
if (state.queuedProviders !== undefined && (!Array.isArray(state.queuedProviders) || state.queuedProviders.some(p => p !== 'codex' && p !== 'claude') || new Set(state.queuedProviders).size !== state.queuedProviders.length || state.queuedProviders.some(p => state.checkingProviders?.includes(p))))
|
|
13
|
+
throw new NeedError('CONNECT_API_MISMATCH', '我需要真实的品牌排队与进行中状态', 'L >=0.7.10');
|
|
11
14
|
if (!Array.isArray(state.checkingProviders) || state.checkingProviders.some(p => p !== 'codex' && p !== 'claude') || !object(state.providerNeeds) || Object.entries(state.providerNeeds).some(([p, v]) => !['codex', 'claude'].includes(p) || !need(v)))
|
|
12
15
|
throw new NeedError('CONNECT_API_MISMATCH', '我需要按助手区分的真实登录状态', 'L >=0.6.1');
|
|
13
16
|
}
|
|
@@ -32,15 +35,77 @@ function verifiedLogin(api, brand, expected) {
|
|
|
32
35
|
if (!connection.instanceId || !Number.isSafeInteger(connection.epoch) || connection.epoch < 0 || connection.phase !== 'connected' || expected && (connection.instanceId !== expected.instanceId || connection.epoch !== expected.epoch))
|
|
33
36
|
throw new NeedError('CONNECTION_LOST', 'connection binding changed', 'current employee connection');
|
|
34
37
|
requireLoginState(login);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
throw new NeedError('LOGIN_REQUIRED', '需要登录,请到设置 → 远程主机连接 点登录', '本人登录');
|
|
38
|
+
const reason = loginReason(login, connection, brand);
|
|
39
|
+
if (reason)
|
|
40
|
+
throw Object.assign(new NeedError(reason === 'LOGIN_REQUIRED' ? 'LOGIN_REQUIRED' : 'AUTH_STATUS_INVALID', reason === 'LOGIN_REQUIRED' ? '需要登录,请到设置 → 远程主机连接 点登录' : '无法确认当前品牌的登录状态', 'L核验的本品牌登录状态'), { technical: loginDiagnostic('verified-login', reason, brand, connection, login), ...(typeof login.providerNeeds[brand]?.officialReason === 'string' ? { officialReason: login.providerNeeds[brand].officialReason } : {}) });
|
|
39
41
|
return { ...connection };
|
|
40
42
|
}
|
|
43
|
+
/** Wait only for L's explicit in-progress evidence. No timer, polling, refresh or authorization. */
|
|
44
|
+
async function waitForLogin(api, brand, expected, get, signal) {
|
|
45
|
+
const check = () => {
|
|
46
|
+
signal?.throwIfAborted();
|
|
47
|
+
if (get() !== api)
|
|
48
|
+
throw new NeedError('CONNECTION_LOST', '远程主机连接组件已更换', '当前员工连接');
|
|
49
|
+
const connection = api.getConnectionState(), state = api.getLoginPromptState();
|
|
50
|
+
requireLoginState(state);
|
|
51
|
+
if (connection.phase !== 'connected' || connection.instanceId !== expected.instanceId || connection.epoch !== expected.epoch)
|
|
52
|
+
return verifiedLogin(api, brand, expected);
|
|
53
|
+
const pending = state.instanceId === connection.instanceId && state.phase !== 'blocked' && state.phase !== 'disposed' && (state.queuedProviders?.includes(brand) || state.checkingProviders.includes(brand) || state.phase === 'authorizing' && state.provider === brand);
|
|
54
|
+
return pending ? undefined : verifiedLogin(api, brand, expected);
|
|
55
|
+
};
|
|
56
|
+
const ready = check();
|
|
57
|
+
if (ready)
|
|
58
|
+
return ready;
|
|
59
|
+
if (typeof api.onLoginPromptState !== 'function' || typeof api.onConnectionState !== 'function')
|
|
60
|
+
throw new NeedError('CONNECT_API_MISMATCH', '我需要登录检查状态订阅能力', 'L onLoginPromptState / onConnectionState');
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let ended = false;
|
|
63
|
+
const disposers = [];
|
|
64
|
+
const clear = () => { ended = true; signal?.removeEventListener('abort', abort); for (const off of disposers)
|
|
65
|
+
off(); };
|
|
66
|
+
const changed = () => { if (ended)
|
|
67
|
+
return; try {
|
|
68
|
+
const value = check();
|
|
69
|
+
if (value) {
|
|
70
|
+
clear();
|
|
71
|
+
resolve(value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
clear();
|
|
76
|
+
reject(error);
|
|
77
|
+
} };
|
|
78
|
+
const abort = () => { if (ended)
|
|
79
|
+
return; clear(); reject(new NeedError('ABORTED', 'consumer wait cancelled', 'employee action')); };
|
|
80
|
+
const register = (off) => { if (ended)
|
|
81
|
+
off();
|
|
82
|
+
else
|
|
83
|
+
disposers.push(off); };
|
|
84
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
85
|
+
try {
|
|
86
|
+
if (signal?.aborted)
|
|
87
|
+
abort();
|
|
88
|
+
if (!ended)
|
|
89
|
+
register(api.onLoginPromptState(changed));
|
|
90
|
+
if (!ended)
|
|
91
|
+
register(api.onConnectionState(changed));
|
|
92
|
+
changed();
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
clear();
|
|
96
|
+
reject(error);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async function ensureReady(api, brand, signal) {
|
|
101
|
+
const result = await awaitL(() => api.ensureReady(brand), signal);
|
|
102
|
+
if (!result.ok)
|
|
103
|
+
throw Object.assign(new NeedError(result.error.code, result.error.message, result.error.requires), { technical: loginDiagnostic('ensure-ready', 'L_ENSURE_READY_FAILED', brand, api.getConnectionState(), api.getLoginPromptState(), result.error.officialReason), ...(typeof result.error.officialReason === 'string' ? { officialReason: result.error.officialReason } : {}) });
|
|
104
|
+
return result.value;
|
|
105
|
+
}
|
|
41
106
|
export function unwrap(result) {
|
|
42
107
|
if (!result.ok)
|
|
43
|
-
throw new NeedError(result.error.code, result.error.message, result.error.requires);
|
|
108
|
+
throw Object.assign(new NeedError(result.error.code, result.error.message, result.error.requires), typeof result.error.officialReason === 'string' ? { officialReason: result.error.officialReason } : {});
|
|
44
109
|
return result.value;
|
|
45
110
|
}
|
|
46
111
|
export function validateTarget(t) {
|
|
@@ -76,12 +141,12 @@ export async function offerLogin(connect, request, currentApi, agent = CODEX_AGE
|
|
|
76
141
|
requireNativeWorkspace(connect);
|
|
77
142
|
request.signal?.throwIfAborted();
|
|
78
143
|
const before = connect.getConnectionState();
|
|
79
|
-
const target =
|
|
144
|
+
const target = await ensureReady(connect, agent.loginProvider, request.signal);
|
|
80
145
|
validateTarget(target);
|
|
81
146
|
request.signal?.throwIfAborted();
|
|
82
147
|
if (currentApi && currentApi() !== connect)
|
|
83
148
|
throw new NeedError('CONNECTION_LOST', '远程主机连接组件已更换', '当前员工连接');
|
|
84
|
-
|
|
149
|
+
await waitForLogin(connect, agent.loginProvider, before, currentApi ?? (() => connect), request.signal);
|
|
85
150
|
}
|
|
86
151
|
export function sameTarget(a, b) {
|
|
87
152
|
return identity(a) === identity(b) && a.projectRoot === b.projectRoot && a.knownHostsFile === b.knownHostsFile && a.proxyCommand === b.proxyCommand && a.session.command === b.session.command && a.session.root === b.session.root && a.session.settingsPath === b.session.settingsPath;
|
|
@@ -99,12 +164,12 @@ export function connectProvider(get, options = {}) {
|
|
|
99
164
|
if (request.mode === 'one-shot' && !agent.oneShot)
|
|
100
165
|
throw new NeedError('MODE_UNSUPPORTED', '此远程模型仅支持会话模式', '使用会话模式');
|
|
101
166
|
const before = api.getConnectionState();
|
|
102
|
-
const target =
|
|
167
|
+
const target = await ensureReady(api, agent.loginProvider, request.signal);
|
|
103
168
|
validateTarget(target);
|
|
104
169
|
request.signal?.throwIfAborted();
|
|
105
170
|
if (get() !== api)
|
|
106
171
|
throw new NeedError('CONNECTION_LOST', '远程主机连接组件已更换', '当前员工连接');
|
|
107
|
-
const connection =
|
|
172
|
+
const connection = await waitForLogin(api, agent.loginProvider, before, get, request.signal);
|
|
108
173
|
if (request.identity && request.identity !== identity(target))
|
|
109
174
|
throw new NeedError('SESSION_CONTEXT_MISMATCH', '员工主机与原会话不一致,请新建会话', '原员工主机');
|
|
110
175
|
admissions.set(request, { api, target, connection });
|
|
@@ -115,15 +180,14 @@ export function connectProvider(get, options = {}) {
|
|
|
115
180
|
const admitted = admissions.get(request);
|
|
116
181
|
admissions.delete(request);
|
|
117
182
|
const { api, target, connection } = admitted;
|
|
118
|
-
const stable = () =>
|
|
119
|
-
|
|
120
|
-
stable();
|
|
183
|
+
const stable = () => waitForLogin(api, agent.loginProvider, connection, get, request.signal);
|
|
184
|
+
await stable();
|
|
121
185
|
if (!request.sessionId)
|
|
122
186
|
throw new NeedError('PROJECT_BINDING_REQUIRED', '我需要当前会话和已选择的工作区,请使用原生工作区入口', 'DSH会话与原生工作区');
|
|
123
187
|
await options.notice?.reconcile(request, api);
|
|
124
|
-
stable();
|
|
188
|
+
await stable();
|
|
125
189
|
const resolved = unwrap(await awaitL(() => api.resolveSessionProject(request.sessionId, { provider: agent.loginProvider, purpose: 'user-turn' }), request.signal));
|
|
126
|
-
stable();
|
|
190
|
+
await stable();
|
|
127
191
|
if (!resolved || resolved.instanceId !== connection.instanceId || resolved.epoch !== connection.epoch)
|
|
128
192
|
throw new NeedError('CONNECTION_LOST', '工作区解析回复已失效', '当前连接代次');
|
|
129
193
|
validateTarget(resolved.target);
|
|
@@ -132,22 +196,28 @@ export function connectProvider(get, options = {}) {
|
|
|
132
196
|
throw new NeedError('PROJECT_BINDING_INVALID', '工作区绑定与当前会话或可信主机不一致', 'L核验的原生工作区绑定');
|
|
133
197
|
if (request.cwd && request.cwd !== project.path || request.bindingId && request.bindingId !== resolved.bindingId || request.workspaceId && request.workspaceId !== resolved.workspaceId)
|
|
134
198
|
throw new NeedError('SESSION_CONTEXT_MISMATCH', '原会话的工作区绑定已改变,请新建会话', '原工作区绑定');
|
|
199
|
+
const details = await options.details?.(request, api, connection, target);
|
|
200
|
+
await stable();
|
|
201
|
+
let workspaceNotice = undefined;
|
|
135
202
|
if (resolved.notice) {
|
|
136
203
|
if (!options.notice)
|
|
137
204
|
throw new NeedError('DEPENDENCY_REQUIRED', '我需要官方会话持久记录来确认工作区说明', '官方会话与持久化服务');
|
|
138
|
-
await options.notice.deliver(resolved.notice, resolved, request, api);
|
|
139
|
-
stable();
|
|
205
|
+
workspaceNotice = await options.notice.deliver(resolved.notice, resolved, request, api);
|
|
206
|
+
await stable();
|
|
140
207
|
}
|
|
141
208
|
if (!options.open)
|
|
142
209
|
throw new NeedError('RUNTIME_REQUIRED', '我需要主机运行适配器', '主机broker适配');
|
|
143
210
|
const runtime = await options.open(resolved.target, project, { ...request, agent });
|
|
144
211
|
try {
|
|
145
|
-
stable();
|
|
212
|
+
await stable();
|
|
146
213
|
}
|
|
147
214
|
catch (error) {
|
|
148
215
|
await runtime.dispose?.();
|
|
149
216
|
throw error;
|
|
150
217
|
}
|
|
218
|
+
// The transport can outlive a turn; presentation belongs only to this invocation.
|
|
219
|
+
runtime.workspaceNotice = workspaceNotice || undefined;
|
|
220
|
+
runtime.details = details ?? runtime.details;
|
|
151
221
|
runtime.binding = { bindingId: resolved.bindingId, workspaceId: resolved.workspaceId, revision: resolved.bindingRevision };
|
|
152
222
|
runtime.connection = { ...connection };
|
|
153
223
|
runtime.loginRequired = () => offerLogin(api, request, get, agent);
|
package/dist/readiness.js
CHANGED
|
@@ -32,6 +32,7 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
32
32
|
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
33
|
done = true;
|
|
34
34
|
};
|
|
35
|
+
import { loginDiagnostic } from "./login-diagnostic.js";
|
|
35
36
|
import { randomUUID } from 'node:crypto';
|
|
36
37
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
37
38
|
import { unwrap, awaitL, requireLoginCoordinator, requireLoginState } from "./provider.js";
|
|
@@ -56,18 +57,19 @@ function snapshot(api, agent = CODEX_AGENT) {
|
|
|
56
57
|
if (connection.phase !== 'connected')
|
|
57
58
|
return denied(connection.need?.code ?? (connection.phase === 'disposed' ? 'DISPOSED' : 'CONNECTION_LOST'));
|
|
58
59
|
const state = api.getLoginPromptState();
|
|
60
|
+
const diagnostic = (reason) => ({ label: '登录检查快照', value: JSON.stringify(loginDiagnostic('readiness', reason, brand, connection, state)) });
|
|
59
61
|
if (!connection.instanceId || state.instanceId !== connection.instanceId)
|
|
60
|
-
return denied('AUTH_STATUS_INVALID');
|
|
62
|
+
return denied('AUTH_STATUS_INVALID', [diagnostic('LOGIN_INSTANCE_MISMATCH')]);
|
|
61
63
|
requireLoginState(state);
|
|
62
64
|
const technical = details({ hostContext: state.technicalDetails?.hostContext });
|
|
63
65
|
if (state.phase === 'blocked' || state.phase === 'disposed')
|
|
64
|
-
return denied(state.technicalDetails?.code ?? 'AUTH_STATUS_INVALID', technical);
|
|
65
|
-
if (state.checkingProviders.includes(brand) || state.phase === 'authorizing' && state.provider === brand)
|
|
66
|
+
return denied(state.technicalDetails?.code ?? 'AUTH_STATUS_INVALID', [...technical, diagnostic(state.phase === 'disposed' ? 'LOGIN_PHASE_DISPOSED' : 'LOGIN_PHASE_BLOCKED')]);
|
|
67
|
+
if (state.queuedProviders?.includes(brand) || state.checkingProviders.includes(brand) || state.phase === 'authorizing' && state.provider === brand)
|
|
66
68
|
return { phase: 'checking', loginStatus: 'unknown', title: `正在检查 ${agent.name.zh} 登录状态`, message: '远程主机连接正在检查登录状态。', technical };
|
|
67
69
|
if (state.providerNeeds[brand])
|
|
68
|
-
return denied(state.providerNeeds[brand].code, technical);
|
|
70
|
+
return denied(state.providerNeeds[brand].code, [...technical, diagnostic('LOGIN_PROVIDER_NEED')]);
|
|
69
71
|
if (typeof state.logins?.[brand] !== 'boolean')
|
|
70
|
-
return denied('AUTH_STATUS_INVALID', technical);
|
|
72
|
+
return denied('AUTH_STATUS_INVALID', [...technical, diagnostic('LOGIN_VALUE_INVALID')]);
|
|
71
73
|
return state.logins[brand] ? { phase: 'ready', loginStatus: 'logged-in', title: `${agent.name.zh}已登录`, message: '本次远程主机连接和主机登录检查已完成。', technical } : { phase: 'login-required', loginStatus: 'not-logged-in', title: `${agent.name.zh} 尚未登录`, message: '需要登录,请到设置 → 远程主机连接 点登录', technical };
|
|
72
74
|
}
|
|
73
75
|
export function createReadiness(get, agent = CODEX_AGENT) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type DetailObservation } from './conversation-details.ts';
|
|
2
|
+
import type { LoginDiagnostic } from './login-diagnostic.ts';
|
|
3
|
+
import type { ConnectApi as LConnectApi, ConnectionState } from '@ai-workbench/connect/api';
|
|
4
|
+
export type { RemoteConversationDetail as RemoteDetail } from '@ai-workbench/connect/api';
|
|
5
|
+
type Connection = Pick<ConnectionState, 'instanceId' | 'epoch' | 'phase'>;
|
|
6
|
+
export type RemoteDetailsApi = Pick<LConnectApi, 'getConnectionState'> & Partial<Pick<LConnectApi, 'getContractInfo' | 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'listRemoteConversationDetails' | 'appendLoginDiagnostic' | 'getLoginDiagnosticReceipt' | 'listLoginDiagnostics'>>;
|
|
7
|
+
export interface DetailChannel {
|
|
8
|
+
record(observation: DetailObservation): void;
|
|
9
|
+
beginTurn(ids: {
|
|
10
|
+
turn: string | number | null;
|
|
11
|
+
step: string | number | null;
|
|
12
|
+
}): void;
|
|
13
|
+
endTurn(): void;
|
|
14
|
+
flush(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface DetailRequest {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
provider: 'claude' | 'codex';
|
|
19
|
+
identity: string;
|
|
20
|
+
connection: Connection;
|
|
21
|
+
}
|
|
22
|
+
export declare function createRemoteDetails(options: {
|
|
23
|
+
profileDir: string;
|
|
24
|
+
getApi: () => RemoteDetailsApi | undefined;
|
|
25
|
+
}): {
|
|
26
|
+
recordLoginFailure(request: {
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
code: string;
|
|
29
|
+
diagnostic: LoginDiagnostic;
|
|
30
|
+
context?: {
|
|
31
|
+
turn: string | number | null;
|
|
32
|
+
step: string | number | null;
|
|
33
|
+
};
|
|
34
|
+
}): Promise<{
|
|
35
|
+
eventId: string;
|
|
36
|
+
recorded: true;
|
|
37
|
+
}>;
|
|
38
|
+
open(request: DetailRequest): Promise<DetailChannel>;
|
|
39
|
+
};
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { mkdir, open, lstat, realpath, readdir } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, join, parse } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { sanitizeObservation, isSanitizedRedaction } from "./conversation-details.js";
|
|
7
|
+
import { NeedError } from "./remote.js";
|
|
8
|
+
const failure = (code, message = '技术详情未能持久记录,本轮已停止。') => new NeedError(code, message, '可用的远程主机连接技术详情');
|
|
9
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
function unwrap(result) {
|
|
12
|
+
if (!object(result))
|
|
13
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
14
|
+
if (result.ok === false && object(result.error) && typeof result.error.code === 'string')
|
|
15
|
+
throw failure(result.error.code);
|
|
16
|
+
if (result.ok !== true)
|
|
17
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
18
|
+
return result.value;
|
|
19
|
+
}
|
|
20
|
+
function identifier(value) { return value === null || typeof value === 'string' && value.trim().length > 0 || typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; }
|
|
21
|
+
function kind(observation) {
|
|
22
|
+
if (/UNKNOWN|UNSUPPORTED|FOREIGN/.test(observation.code))
|
|
23
|
+
return observation.severity === 'action-required' ? 'action-required' : 'unknown';
|
|
24
|
+
return { extension: 'extensions', tool: 'tools', need: 'action-required', notice: 'extensions', session: 'extensions' }[observation.kind] ?? observation.kind;
|
|
25
|
+
}
|
|
26
|
+
async function privateDirectory(path) {
|
|
27
|
+
if (!isAbsolute(path))
|
|
28
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
29
|
+
// Existing parent/profile ownership is the host's responsibility; reject symlinks
|
|
30
|
+
// along the full path before creating our own private child directories.
|
|
31
|
+
let part = parse(path).root;
|
|
32
|
+
for (const member of path.slice(part.length).split('/').filter(Boolean)) {
|
|
33
|
+
part = join(part, member);
|
|
34
|
+
try {
|
|
35
|
+
const info = await lstat(part);
|
|
36
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
37
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error?.code !== 'ENOENT')
|
|
41
|
+
throw error;
|
|
42
|
+
try {
|
|
43
|
+
await mkdir(part, { mode: 0o700 });
|
|
44
|
+
}
|
|
45
|
+
catch (creation) {
|
|
46
|
+
if (creation?.code !== 'EEXIST')
|
|
47
|
+
throw creation;
|
|
48
|
+
const info = await lstat(part);
|
|
49
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
50
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (await realpath(path) !== path)
|
|
55
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
56
|
+
const info = await lstat(path);
|
|
57
|
+
if ((info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid()))
|
|
58
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
59
|
+
}
|
|
60
|
+
async function durableWrite(path, value) {
|
|
61
|
+
const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
62
|
+
try {
|
|
63
|
+
await file.writeFile(JSON.stringify(value) + '\n');
|
|
64
|
+
await file.sync();
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await file.close();
|
|
68
|
+
}
|
|
69
|
+
const parent = await open(join(path, '..'), 'r');
|
|
70
|
+
try {
|
|
71
|
+
await parent.sync();
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await parent.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function readJournal(path) {
|
|
78
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
79
|
+
try {
|
|
80
|
+
const info = await file.stat();
|
|
81
|
+
if (!info.isFile() || (info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid()))
|
|
82
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
83
|
+
return JSON.parse(await file.readFile('utf8'));
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await file.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function createRemoteDetails(options) {
|
|
90
|
+
const root = join(options.profileDir, 'ai-workbench-remote-agent', 'diagnostics');
|
|
91
|
+
const scopes = new Map(), loginScopes = new Map();
|
|
92
|
+
const initialize = async () => { await privateDirectory(join(options.profileDir, 'ai-workbench-remote-agent')); await privateDirectory(root); };
|
|
93
|
+
return {
|
|
94
|
+
async recordLoginFailure(request) {
|
|
95
|
+
await initialize();
|
|
96
|
+
const eventId = randomUUID(), diagnostic = request.diagnostic;
|
|
97
|
+
// The original observed IDs and full approved snapshot are durable before any L call.
|
|
98
|
+
await durableWrite(join(root, eventId + '.json'), { version: 1, eventId, kind: 'login-check', code: request.code, sessionHash: request.sessionId ? hash(request.sessionId) : null, diagnostic, delivery: 'pending' });
|
|
99
|
+
if (!request.sessionId)
|
|
100
|
+
throw failure('REMOTE_DETAIL_SCOPE_INVALID');
|
|
101
|
+
const sessionId = request.sessionId, provider = diagnostic.provider, api = options.getApi(), connection = diagnostic.connection;
|
|
102
|
+
const stable = () => { const now = api?.getConnectionState(); if (!api || options.getApi() !== api || now?.phase !== 'connected' || now.instanceId !== connection.instanceId || now.epoch !== connection.epoch)
|
|
103
|
+
throw failure('CONNECTION_LOST'); };
|
|
104
|
+
stable();
|
|
105
|
+
if (api.getContractInfo?.().loginDiagnostics !== 1 || !['appendLoginDiagnostic', 'getLoginDiagnosticReceipt', 'listLoginDiagnostics'].every(key => typeof api[key] === 'function'))
|
|
106
|
+
throw failure('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。');
|
|
107
|
+
const parent = join(root, 'login');
|
|
108
|
+
await privateDirectory(parent);
|
|
109
|
+
const key = hash(sessionId + '\0' + provider), dir = join(parent, key);
|
|
110
|
+
await privateDirectory(dir);
|
|
111
|
+
const scope = loginScopes.get(key) ?? { tail: Promise.resolve(), maximum: 0, loaded: false };
|
|
112
|
+
loginScopes.set(key, scope);
|
|
113
|
+
const operation = scope.tail.then(async () => {
|
|
114
|
+
stable();
|
|
115
|
+
const generation = connection.instanceId + '\0' + connection.epoch;
|
|
116
|
+
if (!scope.loaded || scope.generation !== generation) {
|
|
117
|
+
const names = await readdir(dir);
|
|
118
|
+
for (const name of names) {
|
|
119
|
+
const match = /^(\d+)-([0-9a-f-]+)\.json$/.exec(name);
|
|
120
|
+
if (!match)
|
|
121
|
+
continue;
|
|
122
|
+
const sequence = Number(match[1]);
|
|
123
|
+
if (!Number.isSafeInteger(sequence))
|
|
124
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
125
|
+
scope.maximum = Math.max(scope.maximum, sequence);
|
|
126
|
+
const saved = await readJournal(join(dir, name));
|
|
127
|
+
if (!object(saved) || !object(saved.detail) || saved.detail.sequence !== sequence || saved.detail.eventId !== match[2] || saved.detail.sessionId !== sessionId || saved.detail.provider !== provider)
|
|
128
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
129
|
+
const settled = 'settled-' + match[2] + '.json';
|
|
130
|
+
if (names.includes(settled)) {
|
|
131
|
+
const receipt = await readJournal(join(dir, settled));
|
|
132
|
+
if (!object(receipt) || receipt.eventId !== match[2] || !['recorded', 'rejected'].includes(receipt.state))
|
|
133
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
stable();
|
|
137
|
+
const receipt = unwrap(await api.getLoginDiagnosticReceipt({ eventId: match[2], sessionId, provider }));
|
|
138
|
+
stable();
|
|
139
|
+
if (receipt.eventId !== match[2] || !receipt.recorded)
|
|
140
|
+
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
141
|
+
if (!isDeepStrictEqual(receipt.detail, saved.detail))
|
|
142
|
+
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
143
|
+
await durableWrite(join(dir, settled), { eventId: match[2], state: 'recorded' });
|
|
144
|
+
}
|
|
145
|
+
let cursor, previous = -1, count = 0, expected;
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
do {
|
|
148
|
+
stable();
|
|
149
|
+
const page = unwrap(await api.listLoginDiagnostics({ sessionId, provider, ...(cursor ? { cursor } : {}) }));
|
|
150
|
+
stable();
|
|
151
|
+
if (!object(page) || !Array.isArray(page.items) || ![page.total, page.unknownCount, page.actionRequiredCount].every(n => Number.isSafeInteger(n) && n >= 0))
|
|
152
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
153
|
+
const counts = { total: page.total, unknownCount: page.unknownCount, actionRequiredCount: page.actionRequiredCount };
|
|
154
|
+
if (expected && !isDeepStrictEqual(expected, counts))
|
|
155
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
156
|
+
expected = counts;
|
|
157
|
+
for (const entry of page.items) {
|
|
158
|
+
if (entry.sessionId !== sessionId || entry.provider !== provider || !Number.isSafeInteger(entry.sequence) || entry.sequence < 0 || entry.sequence <= previous)
|
|
159
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
160
|
+
previous = entry.sequence;
|
|
161
|
+
scope.maximum = Math.max(scope.maximum, entry.sequence);
|
|
162
|
+
count++;
|
|
163
|
+
}
|
|
164
|
+
cursor = page.nextCursor;
|
|
165
|
+
if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || seen.has(cursor)))
|
|
166
|
+
throw failure('REMOTE_DETAIL_CURSOR_INVALID');
|
|
167
|
+
if (cursor)
|
|
168
|
+
seen.add(cursor);
|
|
169
|
+
} while (cursor);
|
|
170
|
+
if (count !== expected?.total)
|
|
171
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
172
|
+
scope.loaded = true;
|
|
173
|
+
scope.generation = generation;
|
|
174
|
+
}
|
|
175
|
+
const mismatch = diagnostic.reason === 'LOGIN_INSTANCE_MISMATCH', unsupported = !!diagnostic.officialReasonRejected;
|
|
176
|
+
const reason = mismatch ? 'LOGIN_INSTANCE_MISMATCH' : unsupported ? 'OFFICIAL_REASON_UNSUPPORTED' : diagnostic.reason;
|
|
177
|
+
// L 0.7.11: observed evidence never changes the current connection scope.
|
|
178
|
+
const login = { phase: diagnostic.login.phase, instanceId: diagnostic.login.instanceId, logins: Object.fromEntries(Object.entries(diagnostic.login.logins).filter(([, v]) => typeof v === 'boolean' || v === 'absent')), queuedProviders: diagnostic.login.queuedProviders, checkingProviders: diagnostic.login.checkingProviders };
|
|
179
|
+
const payload = { reason, connection: { ...connection }, login, ...(mismatch ? { observedLoginInstanceId: diagnostic.login.instanceId } : {}), ...(diagnostic.officialReason ? { officialReason: diagnostic.officialReason } : {}), ...(unsupported && !mismatch ? { officialReasonRejected: diagnostic.officialReasonRejected } : {}) };
|
|
180
|
+
// The two payload reasons are exclusive in L's contract. Preserve both facts
|
|
181
|
+
// using the approved fixed summary and full digest, without inventing a field.
|
|
182
|
+
const rejected = diagnostic.officialReasonRejected;
|
|
183
|
+
const summary = '登录检查:' + diagnostic.reason + (rejected ? '; OFFICIAL_REASON_UNSUPPORTED; bytes=' + rejected.bytes + '; SHA256=' + rejected.sha256 : '');
|
|
184
|
+
const context = request.context ?? { turn: null, step: null };
|
|
185
|
+
if (!identifier(context.turn) || !identifier(context.step))
|
|
186
|
+
throw failure('REMOTE_DETAIL_SCOPE_INVALID');
|
|
187
|
+
const sequence = scope.maximum + 1;
|
|
188
|
+
if (!Number.isSafeInteger(sequence))
|
|
189
|
+
throw failure('REMOTE_DETAIL_SEQUENCE_INVALID');
|
|
190
|
+
scope.maximum = sequence;
|
|
191
|
+
const detail = { version: 1, eventId, instanceId: connection.instanceId, epoch: connection.epoch, sessionId, provider, turn: context.turn, step: context.step, sequence, kind: 'login-check', severity: 'action-required', code: request.code, summary, method: diagnostic.source, payload };
|
|
192
|
+
await durableWrite(join(dir, sequence + '-' + eventId + '.json'), { state: 'pending', detail });
|
|
193
|
+
stable();
|
|
194
|
+
let response;
|
|
195
|
+
try {
|
|
196
|
+
response = await api.appendLoginDiagnostic(structuredClone(detail));
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
stable();
|
|
200
|
+
const receipt = unwrap(await api.getLoginDiagnosticReceipt({ eventId, sessionId, provider }));
|
|
201
|
+
stable();
|
|
202
|
+
if (receipt.eventId !== eventId)
|
|
203
|
+
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
204
|
+
if (!receipt.recorded)
|
|
205
|
+
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
206
|
+
if (!isDeepStrictEqual(receipt.detail, detail))
|
|
207
|
+
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
208
|
+
await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'recorded' });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
stable();
|
|
212
|
+
if (response?.ok === false)
|
|
213
|
+
await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'rejected', code: response.error.code });
|
|
214
|
+
const ack = unwrap(response);
|
|
215
|
+
if (ack.eventId !== eventId || ack.recorded !== true)
|
|
216
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
217
|
+
await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'recorded' });
|
|
218
|
+
});
|
|
219
|
+
scope.tail = operation.then(() => { }, () => { scope.loaded = false; });
|
|
220
|
+
await operation;
|
|
221
|
+
return { eventId, recorded: true };
|
|
222
|
+
},
|
|
223
|
+
async open(request) {
|
|
224
|
+
if (!request.sessionId || !request.identity || !request.connection.instanceId || !Number.isSafeInteger(request.connection.epoch) || request.connection.epoch < 0 || !['claude', 'codex'].includes(request.provider))
|
|
225
|
+
throw failure('REMOTE_DETAIL_SCOPE_INVALID');
|
|
226
|
+
try {
|
|
227
|
+
await initialize();
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
231
|
+
}
|
|
232
|
+
const api = options.getApi();
|
|
233
|
+
const stable = () => {
|
|
234
|
+
const now = api?.getConnectionState();
|
|
235
|
+
if (!api || options.getApi() !== api || now?.phase !== 'connected' || now.instanceId !== request.connection.instanceId || now.epoch !== request.connection.epoch)
|
|
236
|
+
throw failure('CONNECTION_LOST');
|
|
237
|
+
};
|
|
238
|
+
stable();
|
|
239
|
+
const capability = api.getContractInfo?.();
|
|
240
|
+
if (!object(capability) || capability.remoteConversationDetails !== 1 || !['appendRemoteConversationDetail', 'getRemoteConversationDetailReceipt', 'listRemoteConversationDetails'].every(key => typeof api[key] === 'function')) {
|
|
241
|
+
await durableWrite(join(root, randomUUID() + '.json'), { version: 1, code: 'TECHNICAL_DETAILS_UNAVAILABLE', summary: '技术详情暂不可用', session: hash(request.sessionId), provider: request.provider });
|
|
242
|
+
throw failure('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。');
|
|
243
|
+
}
|
|
244
|
+
const key = hash(request.identity + '\0' + request.sessionId + '\0' + request.provider), dir = join(root, key);
|
|
245
|
+
await privateDirectory(dir);
|
|
246
|
+
const scope = scopes.get(key) ?? { tail: Promise.resolve(), maximum: 0, loaded: false };
|
|
247
|
+
scopes.set(key, scope);
|
|
248
|
+
// Serialize open and appends in this host-user/session/brand scope.
|
|
249
|
+
const load = scope.tail.then(async () => {
|
|
250
|
+
stable();
|
|
251
|
+
const generation = request.connection.instanceId + '\0' + request.connection.epoch;
|
|
252
|
+
if (scope.loaded && scope.generation === generation)
|
|
253
|
+
return;
|
|
254
|
+
const names = await readdir(dir);
|
|
255
|
+
for (const name of names) {
|
|
256
|
+
const match = /^(\d+)-([0-9a-f-]+)\.json$/.exec(name);
|
|
257
|
+
if (!match)
|
|
258
|
+
continue;
|
|
259
|
+
const n = Number(match[1]);
|
|
260
|
+
if (!Number.isSafeInteger(n))
|
|
261
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
262
|
+
scope.maximum = Math.max(scope.maximum, n);
|
|
263
|
+
const saved = await readJournal(join(dir, name));
|
|
264
|
+
if (!object(saved) || !object(saved.detail) || saved.detail.eventId !== match[2] || saved.detail.sequence !== n || saved.detail.sessionId !== request.sessionId || saved.detail.provider !== request.provider)
|
|
265
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
266
|
+
const settled = 'settled-' + match[2] + '.json';
|
|
267
|
+
if (names.includes(settled)) {
|
|
268
|
+
const result = await readJournal(join(dir, settled));
|
|
269
|
+
if (!object(result) || result.eventId !== match[2] || !['recorded', 'rejected'].includes(result.state))
|
|
270
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// An interrupted append is reconciled by pure read, with the original
|
|
274
|
+
// event's epoch intact. Missing receipts stay unknown and never trigger append.
|
|
275
|
+
stable();
|
|
276
|
+
const receipt = unwrap(await api.getRemoteConversationDetailReceipt({ eventId: match[2], sessionId: request.sessionId, provider: request.provider }));
|
|
277
|
+
stable();
|
|
278
|
+
if (receipt.eventId !== match[2])
|
|
279
|
+
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
280
|
+
if (!receipt.recorded)
|
|
281
|
+
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
282
|
+
if (!isDeepStrictEqual(receipt.detail, saved.detail))
|
|
283
|
+
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
284
|
+
await durableWrite(join(dir, settled), { eventId: match[2], state: 'recorded' });
|
|
285
|
+
}
|
|
286
|
+
let cursor, previous = -1;
|
|
287
|
+
const seen = new Set();
|
|
288
|
+
let expected;
|
|
289
|
+
let count = 0;
|
|
290
|
+
do {
|
|
291
|
+
stable();
|
|
292
|
+
const page = unwrap(await api.listRemoteConversationDetails({ sessionId: request.sessionId, provider: request.provider, ...(cursor ? { cursor } : {}) }));
|
|
293
|
+
stable();
|
|
294
|
+
if (!object(page) || !Array.isArray(page.items) || ![page.total, page.unknownCount, page.actionRequiredCount].every(n => Number.isSafeInteger(n) && n >= 0))
|
|
295
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
296
|
+
const counts = { total: page.total, unknownCount: page.unknownCount, actionRequiredCount: page.actionRequiredCount };
|
|
297
|
+
if (expected && !isDeepStrictEqual(expected, counts))
|
|
298
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
299
|
+
expected = counts;
|
|
300
|
+
for (const entry of page.items) {
|
|
301
|
+
if (entry.sessionId !== request.sessionId || entry.provider !== request.provider || !Number.isSafeInteger(entry.sequence) || entry.sequence <= previous)
|
|
302
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
303
|
+
previous = entry.sequence;
|
|
304
|
+
scope.maximum = Math.max(scope.maximum, entry.sequence);
|
|
305
|
+
count++;
|
|
306
|
+
}
|
|
307
|
+
cursor = page.nextCursor;
|
|
308
|
+
if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || seen.has(cursor)))
|
|
309
|
+
throw failure('REMOTE_DETAIL_CURSOR_INVALID');
|
|
310
|
+
if (cursor)
|
|
311
|
+
seen.add(cursor);
|
|
312
|
+
} while (cursor);
|
|
313
|
+
if (count !== expected?.total)
|
|
314
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
315
|
+
scope.loaded = true;
|
|
316
|
+
scope.generation = generation;
|
|
317
|
+
});
|
|
318
|
+
scope.tail = load.then(() => { }, () => { });
|
|
319
|
+
await load;
|
|
320
|
+
let turn = null, step = null;
|
|
321
|
+
let tail = Promise.resolve(), firstFailure;
|
|
322
|
+
const enqueue = (operation) => {
|
|
323
|
+
const task = scope.tail.then(operation);
|
|
324
|
+
scope.tail = task.then(() => { }, () => { });
|
|
325
|
+
tail = task.catch(error => { scope.loaded = false; firstFailure ??= { error }; });
|
|
326
|
+
};
|
|
327
|
+
return {
|
|
328
|
+
beginTurn(ids) { if (!identifier(ids.turn) || !identifier(ids.step))
|
|
329
|
+
throw failure('REMOTE_DETAIL_SCOPE_INVALID'); turn = ids.turn; step = ids.step; },
|
|
330
|
+
endTurn() { turn = null; step = null; },
|
|
331
|
+
record(observation) {
|
|
332
|
+
const safe = sanitizeObservation(observation, true);
|
|
333
|
+
const wire = (value) => Array.isArray(value) ? value.map(wire) : object(value) ? isSanitizedRedaction(value) ? (typeof value.valueType === 'string' ? { redacted: true, valueType: value.valueType, value: '已脱敏' } : '已脱敏') : Object.fromEntries(Object.entries(value).map(([key, child]) => [key, wire(child)])) : value;
|
|
334
|
+
const sequence = scope.maximum + 1;
|
|
335
|
+
if (!Number.isSafeInteger(sequence))
|
|
336
|
+
throw failure('REMOTE_DETAIL_SEQUENCE_INVALID');
|
|
337
|
+
scope.maximum = sequence;
|
|
338
|
+
const detail = Object.freeze({ ...safe, payload: wire(safe.payload), version: 1, eventId: randomUUID(), instanceId: request.connection.instanceId, epoch: request.connection.epoch, sessionId: request.sessionId, provider: request.provider, turn, step, sequence, kind: kind(observation) });
|
|
339
|
+
enqueue(async () => {
|
|
340
|
+
// A private X journal is retained even if L is disconnected or rejects it.
|
|
341
|
+
const file = join(dir, sequence + '-' + detail.eventId + '.json');
|
|
342
|
+
try {
|
|
343
|
+
await durableWrite(file, { state: 'pending', detail });
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
347
|
+
}
|
|
348
|
+
stable();
|
|
349
|
+
let response;
|
|
350
|
+
try {
|
|
351
|
+
response = await api.appendRemoteConversationDetail(structuredClone(detail));
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
stable();
|
|
355
|
+
const receipt = unwrap(await api.getRemoteConversationDetailReceipt({ eventId: detail.eventId, sessionId: detail.sessionId, provider: detail.provider }));
|
|
356
|
+
stable();
|
|
357
|
+
if (receipt.eventId !== detail.eventId)
|
|
358
|
+
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
359
|
+
if (!receipt.recorded)
|
|
360
|
+
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
361
|
+
if (!isDeepStrictEqual(receipt.detail, detail))
|
|
362
|
+
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
363
|
+
await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'recorded' });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
stable();
|
|
367
|
+
if (response?.ok === false) {
|
|
368
|
+
await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'rejected', code: response.error.code });
|
|
369
|
+
}
|
|
370
|
+
const ack = unwrap(response);
|
|
371
|
+
if (ack.eventId !== detail.eventId || ack.recorded !== true)
|
|
372
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
373
|
+
await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'recorded' });
|
|
374
|
+
});
|
|
375
|
+
},
|
|
376
|
+
async flush() { await tail; if (firstFailure)
|
|
377
|
+
throw firstFailure.error; stable(); },
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}
|