@ai-workbench/remote-agent 0.8.19 → 0.8.23
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 +27 -134
- package/dist/acp.d.ts +23 -16
- package/dist/acp.js +209 -226
- package/dist/agents.d.ts +0 -1
- package/dist/agents.js +2 -2
- package/dist/broker-runtime.d.ts +3 -5
- package/dist/broker-runtime.js +45 -342
- package/dist/broker.d.ts +6 -31
- package/dist/broker.js +42 -113
- package/dist/catalog-ssh.d.ts +2 -31
- package/dist/catalog-ssh.js +9 -39
- package/dist/claude-local.d.ts +1 -1
- package/dist/claude-local.js +41 -105
- package/dist/client.js +34 -126
- package/dist/close-diagnostics.d.ts +6 -20
- package/dist/close-diagnostics.js +13 -57
- package/dist/connect-contract.d.ts +4 -8
- package/dist/conversation-details.d.ts +0 -29
- package/dist/conversation-details.js +8 -99
- package/dist/employee-endpoint.d.ts +1 -1
- package/dist/employee-endpoint.js +21 -19
- package/dist/employee-exec.d.ts +4 -3
- package/dist/employee-exec.js +25 -13
- package/dist/employee-info.d.ts +7 -1
- package/dist/employee-info.js +24 -3
- package/dist/employee-location.js +3 -4
- package/dist/employee-mcp.js +44 -69
- package/dist/employee-native.d.ts +1 -3
- package/dist/employee-native.js +6 -27
- package/dist/employee-presentation.d.ts +0 -5
- package/dist/employee-presentation.js +0 -15
- package/dist/employee-provider.d.ts +3 -11
- package/dist/employee-provider.js +69 -205
- package/dist/employee-reasons.js +1 -5
- package/dist/employee-shell.d.ts +2 -1
- package/dist/employee-shell.js +10 -13
- package/dist/employee-ssh.d.ts +1 -3
- package/dist/employee-ssh.js +13 -16
- package/dist/employee-tools.d.ts +5 -5
- package/dist/failure-evidence.d.ts +2 -10
- package/dist/failure-evidence.js +12 -61
- package/dist/host-workspace.d.ts +4 -2
- package/dist/host-workspace.js +6 -2
- package/dist/index.d.ts +13 -43
- package/dist/index.js +192 -484
- package/dist/input.js +2 -3
- package/dist/local-user-turn.js +5 -6
- package/dist/mapping.d.ts +5 -3
- package/dist/mapping.js +31 -36
- package/dist/model-catalog.d.ts +2 -41
- package/dist/model-catalog.js +24 -282
- package/dist/model-configuration.d.ts +0 -3
- package/dist/model-configuration.js +8 -47
- package/dist/model-presentation.d.ts +1 -0
- package/dist/model-presentation.js +2 -2
- package/dist/permissions.d.ts +15 -0
- package/dist/permissions.js +30 -0
- package/dist/preflight-diagnostic.js +11 -22
- package/dist/preflight-payload.d.ts +1 -1
- package/dist/preflight-payload.js +2 -2
- package/dist/presentation.js +21 -41
- package/dist/provider.d.ts +12 -34
- package/dist/provider.js +17 -92
- package/dist/readiness.js +1 -7
- package/dist/remote-details.d.ts +41 -17
- package/dist/remote-details.js +84 -381
- package/dist/workspace-source.d.ts +1 -25
- package/dist/workspace-source.js +1 -138
- package/package.json +11 -12
- package/dist/catalog-diagnostics.d.ts +0 -16
- package/dist/catalog-diagnostics.js +0 -108
- package/dist/host-layout.d.ts +0 -8
- package/dist/host-layout.js +0 -9
- package/dist/job.d.ts +0 -17
- package/dist/job.js +0 -57
- package/dist/model-defaults.d.ts +0 -29
- package/dist/model-defaults.js +0 -62
- package/dist/oneshot.d.ts +0 -13
- package/dist/oneshot.js +0 -78
- package/dist/protocol-boundary.d.ts +0 -13
- package/dist/protocol-boundary.js +0 -49
- package/dist/provider-locale.d.ts +0 -19
- package/dist/provider-locale.js +0 -73
- package/dist/recovery-checkpoint.d.ts +0 -6
- package/dist/recovery-checkpoint.js +0 -71
- package/dist/recovery.d.ts +0 -51
- package/dist/recovery.js +0 -61
- package/dist/sdk-ssh-wrapper.d.ts +0 -2
- package/dist/sdk-ssh-wrapper.js +0 -34
- package/dist/stage-timing.d.ts +0 -31
- package/dist/stage-timing.js +0 -61
- package/dist/workspace-notice.d.ts +0 -47
- package/dist/workspace-notice.js +0 -273
- package/dist/workspace-source-confirmation.d.ts +0 -4
- package/dist/workspace-source-confirmation.js +0 -12
package/dist/remote-details.js
CHANGED
|
@@ -1,30 +1,12 @@
|
|
|
1
|
-
import { closeCode
|
|
2
|
-
import {
|
|
1
|
+
import { closeCode } from "./close-diagnostics.js";
|
|
2
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
|
-
import { mkdir, open, lstat, realpath,
|
|
4
|
+
import { mkdir, open, lstat, realpath, appendFile } from 'node:fs/promises';
|
|
5
5
|
import { isAbsolute, join, parse } from 'node:path';
|
|
6
|
-
import { isDeepStrictEqual } from 'node:util';
|
|
7
6
|
import { sanitizeObservation, isSanitizedRedaction } from "./conversation-details.js";
|
|
8
|
-
import { validPreflightPayload } from "./preflight-payload.js";
|
|
9
7
|
import { NeedError } from "./remote.js";
|
|
10
|
-
const failure = (code
|
|
11
|
-
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
const failure = (code) => new NeedError(code, '技术详情未能保存', '诊断存储');
|
|
12
9
|
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
-
function unwrap(result) {
|
|
14
|
-
if (!object(result))
|
|
15
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
16
|
-
if (result.ok === false && object(result.error) && typeof result.error.code === 'string')
|
|
17
|
-
throw failure(result.error.code);
|
|
18
|
-
if (result.ok !== true)
|
|
19
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
20
|
-
return result.value;
|
|
21
|
-
}
|
|
22
|
-
function identifier(value) { return value === null || typeof value === 'string' && value.trim().length > 0 || typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; }
|
|
23
|
-
function kind(observation) {
|
|
24
|
-
if (/UNKNOWN|UNSUPPORTED|FOREIGN/.test(observation.code))
|
|
25
|
-
return observation.severity === 'action-required' ? 'action-required' : 'unknown';
|
|
26
|
-
return { extension: 'extensions', tool: 'tools', need: 'action-required', notice: 'extensions', session: 'extensions' }[observation.kind] ?? observation.kind;
|
|
27
|
-
}
|
|
28
10
|
export async function privateDirectory(path) {
|
|
29
11
|
if (!isAbsolute(path))
|
|
30
12
|
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
@@ -59,376 +41,97 @@ export async function privateDirectory(path) {
|
|
|
59
41
|
if ((info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid()))
|
|
60
42
|
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
61
43
|
}
|
|
62
|
-
|
|
63
|
-
const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
64
|
-
try {
|
|
65
|
-
await file.writeFile(JSON.stringify(value) + '\n');
|
|
66
|
-
await file.sync();
|
|
67
|
-
}
|
|
68
|
-
finally {
|
|
69
|
-
await file.close();
|
|
70
|
-
}
|
|
71
|
-
const parent = await open(join(path, '..'), 'r');
|
|
72
|
-
try {
|
|
73
|
-
await parent.sync();
|
|
74
|
-
}
|
|
75
|
-
finally {
|
|
76
|
-
await parent.close();
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
export async function readJournal(path) {
|
|
80
|
-
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
81
|
-
try {
|
|
82
|
-
const info = await file.stat();
|
|
83
|
-
if (!info.isFile() || (info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid()))
|
|
84
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
85
|
-
return JSON.parse(await file.readFile('utf8'));
|
|
86
|
-
}
|
|
87
|
-
finally {
|
|
88
|
-
await file.close();
|
|
89
|
-
}
|
|
90
|
-
}
|
|
44
|
+
/** Diagnostics are best effort. No delivery journal, receipt lookup, replay or turn gate. */
|
|
91
45
|
export function createRemoteDetails(options) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
46
|
+
let sequence = 0;
|
|
47
|
+
let writes = Promise.resolve();
|
|
48
|
+
const memory = new Map();
|
|
49
|
+
const filename = (sessionId, provider) => createHash('sha256').update(sessionId + '\0' + provider).digest('hex') + '.jsonl';
|
|
50
|
+
const root = options.profileDir ? join(options.profileDir, 'ai-workbench-remote-agent', 'logs') : undefined;
|
|
51
|
+
const warn = (error) => { try {
|
|
52
|
+
(options.warn ?? console.warn)('[remote-agent] 技术详情未保存:' + closeCode(error, 'DIAGNOSTIC_WRITE_FAILED'));
|
|
53
|
+
}
|
|
54
|
+
catch { /* Logging cannot become a turn gate. */ } };
|
|
55
|
+
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;
|
|
56
|
+
function send(request, observation, context, diagnosticKind, owner) {
|
|
98
57
|
const eventId = randomUUID();
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
stable();
|
|
141
|
-
const receipt = unwrap(await api.getLoginDiagnosticReceipt({ eventId: match[2], sessionId, provider }));
|
|
142
|
-
stable();
|
|
143
|
-
if (receipt.eventId !== match[2] || !receipt.recorded)
|
|
144
|
-
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
145
|
-
if (!isDeepStrictEqual(receipt.detail, saved.detail))
|
|
146
|
-
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
147
|
-
await durableWrite(join(dir, settled), { eventId: match[2], state: 'recorded' });
|
|
58
|
+
try {
|
|
59
|
+
const connection = request.connection;
|
|
60
|
+
if (!request.sessionId)
|
|
61
|
+
throw failure('DIAGNOSTIC_CONTEXT_UNAVAILABLE');
|
|
62
|
+
const safe = sanitizeObservation(observation, true);
|
|
63
|
+
sequence = Math.max(sequence + 1, Date.now());
|
|
64
|
+
const detail = { ...safe, payload: wire(safe.payload), version: 1, eventId, instanceId: connection?.instanceId ?? null, epoch: connection?.epoch ?? null, sessionId: request.sessionId, provider: request.provider, ...context, sequence, kind: diagnosticKind ?? { extension: 'extensions', tool: 'tools', need: 'action-required', notice: 'extensions', session: 'extensions' }[observation.kind] ?? observation.kind };
|
|
65
|
+
const name = filename(request.sessionId, request.provider), records = memory.get(name);
|
|
66
|
+
if (records && (!owner || owner === records))
|
|
67
|
+
records.push(detail);
|
|
68
|
+
writes = writes.then(async () => { if (!root)
|
|
69
|
+
throw failure('DIAGNOSTIC_CONTEXT_UNAVAILABLE'); await privateDirectory(join(root, '..')); await privateDirectory(root); await appendFile(join(root, name), JSON.stringify(detail) + '\n', { mode: 0o600, flag: constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW }); }).catch(warn);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
warn(error);
|
|
73
|
+
}
|
|
74
|
+
return { eventId };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
/** Await queued best-effort writes only when releasing the profile, never before dispatch. */
|
|
78
|
+
async dispose() { await writes; memory.clear(); },
|
|
79
|
+
releaseSession(sessionId) { for (const provider of ['claude', 'codex'])
|
|
80
|
+
memory.delete(filename(sessionId, provider)); },
|
|
81
|
+
async read(query) {
|
|
82
|
+
await writes;
|
|
83
|
+
const name = filename(query.sessionId, query.provider), records = new Map();
|
|
84
|
+
try {
|
|
85
|
+
if (!root)
|
|
86
|
+
throw failure('DIAGNOSTIC_CONTEXT_UNAVAILABLE');
|
|
87
|
+
await privateDirectory(join(root, '..'));
|
|
88
|
+
await privateDirectory(root);
|
|
89
|
+
const file = await open(join(root, name), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
90
|
+
let value;
|
|
91
|
+
try {
|
|
92
|
+
const info = await file.stat();
|
|
93
|
+
if (!info.isFile() || (info.mode & 0o077) !== 0 || process.getuid && info.uid !== process.getuid())
|
|
94
|
+
throw failure('DIAGNOSTIC_LOG_INVALID');
|
|
95
|
+
value = await file.readFile('utf8');
|
|
148
96
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (entry.sessionId !== sessionId || entry.provider !== provider || !Number.isSafeInteger(entry.sequence) || entry.sequence < 0 || entry.sequence <= previous)
|
|
163
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
164
|
-
previous = entry.sequence;
|
|
165
|
-
scope.maximum = Math.max(scope.maximum, entry.sequence);
|
|
166
|
-
count++;
|
|
97
|
+
finally {
|
|
98
|
+
await file.close();
|
|
99
|
+
}
|
|
100
|
+
for (const line of value.split('\n'))
|
|
101
|
+
if (line) {
|
|
102
|
+
try {
|
|
103
|
+
const record = JSON.parse(line);
|
|
104
|
+
if (record.sessionId === query.sessionId && record.provider === query.provider)
|
|
105
|
+
records.set(record.eventId, record);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
warn(failure('DIAGNOSTIC_LOG_INVALID'));
|
|
109
|
+
}
|
|
167
110
|
}
|
|
168
|
-
cursor = page.nextCursor;
|
|
169
|
-
if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || seen.has(cursor)))
|
|
170
|
-
throw failure('REMOTE_DETAIL_CURSOR_INVALID');
|
|
171
|
-
if (cursor)
|
|
172
|
-
seen.add(cursor);
|
|
173
|
-
} while (cursor);
|
|
174
|
-
if (count !== expected?.total)
|
|
175
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
176
|
-
scope.loaded = true;
|
|
177
|
-
scope.generation = generation;
|
|
178
111
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const sequence = scope.maximum + 1;
|
|
183
|
-
if (!Number.isSafeInteger(sequence))
|
|
184
|
-
throw failure('REMOTE_DETAIL_SEQUENCE_INVALID');
|
|
185
|
-
scope.maximum = sequence;
|
|
186
|
-
const detail = { version: 1, eventId, instanceId: connection.instanceId, epoch: connection.epoch, sessionId, provider, turn: context.turn, step: context.step, sequence, kind: request.kind, severity: request.severity, code: request.code, summary: request.summary, method: request.method, payload: request.payload };
|
|
187
|
-
await durableWrite(join(dir, sequence + '-' + eventId + '.json'), { state: 'pending', detail });
|
|
188
|
-
stable();
|
|
189
|
-
let response;
|
|
190
|
-
try {
|
|
191
|
-
response = await api.appendLoginDiagnostic(structuredClone(detail));
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code !== 'ENOENT')
|
|
114
|
+
warn(error);
|
|
192
115
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
stable();
|
|
197
|
-
if (receipt.eventId !== eventId)
|
|
198
|
-
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
199
|
-
if (!receipt.recorded)
|
|
200
|
-
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
201
|
-
if (!isDeepStrictEqual(receipt.detail, detail))
|
|
202
|
-
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
203
|
-
await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'recorded' });
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
stable();
|
|
207
|
-
if (response?.ok === false)
|
|
208
|
-
await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'rejected', code: response.error.code });
|
|
209
|
-
const ack = unwrap(response);
|
|
210
|
-
if (ack.eventId !== eventId || ack.recorded !== true)
|
|
211
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
212
|
-
await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'recorded' });
|
|
213
|
-
});
|
|
214
|
-
scope.tail = operation.then(() => { }, () => { scope.loaded = false; });
|
|
215
|
-
await operation;
|
|
216
|
-
return { eventId, recorded: true };
|
|
217
|
-
}
|
|
218
|
-
return {
|
|
219
|
-
async recordLocalFailure(request) {
|
|
220
|
-
await initialize();
|
|
221
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(request.code))
|
|
222
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
223
|
-
await durableWrite(join(root, randomUUID() + '.json'), { version: 1, kind: 'local-admission', code: request.code, provider: request.provider, sessionHash: request.sessionId ? hash(request.sessionId) : null, ...request.observation ? { observation: sanitizeObservation(request.observation, true) } : {} });
|
|
116
|
+
for (const record of memory.get(name) ?? [])
|
|
117
|
+
records.set(record.eventId, record);
|
|
118
|
+
return structuredClone([...records.values()]);
|
|
224
119
|
},
|
|
120
|
+
async recordLocalFailure(request) { send(request, request.observation ?? { kind: 'need', severity: 'warning', code: request.code, summary: '本轮未完成', payload: {} }, { turn: null, step: null }); },
|
|
225
121
|
async recordLoginFailure(request) {
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
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 };
|
|
231
|
-
const payload = { reason, connection: { ...connection }, login, ...(mismatch ? { observedLoginInstanceId: diagnostic.login.instanceId } : {}), ...(diagnostic.officialReason ? { officialReason: diagnostic.officialReason } : {}), ...(unsupported && !mismatch ? { officialReasonRejected: diagnostic.officialReasonRejected } : {}) };
|
|
232
|
-
// The two payload reasons are exclusive in L's contract. Preserve both facts
|
|
233
|
-
// using the approved fixed summary and full digest, without inventing a field.
|
|
234
|
-
const rejected = diagnostic.officialReasonRejected;
|
|
235
|
-
const summary = '登录检查:' + diagnostic.reason + (rejected ? '; OFFICIAL_REASON_UNSUPPORTED; bytes=' + rejected.bytes + '; SHA256=' + rejected.sha256 : '');
|
|
236
|
-
return recordConnectionDiagnostic({ ...request, provider: diagnostic.provider, connection: diagnostic.connection, kind: 'login-check', severity: 'action-required', summary, method: diagnostic.source, payload, snapshot: { diagnostic } });
|
|
122
|
+
const d = request.diagnostic, mismatch = d.reason === 'LOGIN_INSTANCE_MISMATCH', unsupported = !!d.officialReasonRejected;
|
|
123
|
+
const login = { phase: d.login.phase, instanceId: d.login.instanceId, logins: Object.fromEntries(Object.entries(d.login.logins).filter(([, v]) => typeof v === 'boolean' || v === 'absent')), queuedProviders: d.login.queuedProviders, checkingProviders: d.login.checkingProviders };
|
|
124
|
+
const payload = { reason: mismatch ? 'LOGIN_INSTANCE_MISMATCH' : unsupported ? 'OFFICIAL_REASON_UNSUPPORTED' : d.reason, connection: { ...d.connection }, login, ...(mismatch ? { observedLoginInstanceId: d.login.instanceId } : {}), ...(d.officialReason ? { officialReason: d.officialReason } : {}), ...(unsupported && !mismatch ? { officialReasonRejected: d.officialReasonRejected } : {}) };
|
|
125
|
+
return send({ sessionId: request.sessionId, provider: d.provider, connection: d.connection }, { kind: 'need', severity: 'warning', code: request.code, summary: '登录检查:' + d.reason, method: d.source, payload: payload }, request.context ?? { turn: null, step: null }, 'login-check');
|
|
237
126
|
},
|
|
238
127
|
async recordPreflightCheck(request) {
|
|
239
|
-
|
|
240
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(observation.code) || !validPreflightPayload(observation.payload))
|
|
241
|
-
throw failure('REMOTE_DETAIL_SCOPE_INVALID');
|
|
242
|
-
const api = request.binding ? request.binding.api : options.getApi(), connection = request.binding ? request.binding.connection : api?.getConnectionState();
|
|
243
|
-
return recordConnectionDiagnostic({ ...request, connection, binding: { api, connection }, kind: 'preflight-check', code: observation.code, summary: observation.summary, method: observation.payload.stage, severity: observation.severity, payload: observation.payload, snapshot: { observation } });
|
|
128
|
+
return send({ sessionId: request.sessionId, provider: request.provider, connection: request.binding?.connection }, request.observation, request.context ?? { turn: null, step: null }, 'preflight-check');
|
|
244
129
|
},
|
|
245
130
|
async open(request) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
throw failure('REMOTE_DETAIL_SCOPE_INVALID');
|
|
252
|
-
try {
|
|
253
|
-
await initialize();
|
|
254
|
-
}
|
|
255
|
-
catch {
|
|
256
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
257
|
-
}
|
|
258
|
-
const api = options.getApi();
|
|
259
|
-
const stable = () => {
|
|
260
|
-
const now = api?.getConnectionState();
|
|
261
|
-
if (!api || options.getApi() !== api || now?.phase !== 'connected' || now.instanceId !== request.connection.instanceId || now.epoch !== request.connection.epoch)
|
|
262
|
-
throw failure('CONNECTION_LOST');
|
|
263
|
-
};
|
|
264
|
-
stable();
|
|
265
|
-
const capability = api.getContractInfo?.();
|
|
266
|
-
if (!object(capability) || capability.remoteConversationDetails !== 1 || !['appendRemoteConversationDetail', 'getRemoteConversationDetailReceipt', 'listRemoteConversationDetails'].every(key => typeof api[key] === 'function')) {
|
|
267
|
-
await durableWrite(join(root, randomUUID() + '.json'), { version: 1, code: 'TECHNICAL_DETAILS_UNAVAILABLE', summary: '技术详情暂不可用', session: hash(request.sessionId), provider: request.provider });
|
|
268
|
-
throw failure('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。');
|
|
269
|
-
}
|
|
270
|
-
const key = hash(request.identity + '\0' + request.sessionId + '\0' + request.provider), dir = join(root, key);
|
|
271
|
-
await privateDirectory(dir);
|
|
272
|
-
const scope = scopes.get(key) ?? { tail: Promise.resolve(), maximum: 0, loaded: false };
|
|
273
|
-
scopes.set(key, scope);
|
|
274
|
-
// Serialize open and appends in this host-user/session/brand scope.
|
|
275
|
-
const load = scope.tail.then(async () => {
|
|
276
|
-
stable();
|
|
277
|
-
const generation = request.connection.instanceId + '\0' + request.connection.epoch;
|
|
278
|
-
if (scope.loaded && scope.generation === generation)
|
|
279
|
-
return;
|
|
280
|
-
const names = await readdir(dir);
|
|
281
|
-
for (const name of names) {
|
|
282
|
-
const match = /^(\d+)-([0-9a-f-]+)\.json$/.exec(name);
|
|
283
|
-
if (!match)
|
|
284
|
-
continue;
|
|
285
|
-
const n = Number(match[1]);
|
|
286
|
-
if (!Number.isSafeInteger(n))
|
|
287
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
288
|
-
scope.maximum = Math.max(scope.maximum, n);
|
|
289
|
-
const saved = await readJournal(join(dir, name));
|
|
290
|
-
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)
|
|
291
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
292
|
-
const settled = 'settled-' + match[2] + '.json';
|
|
293
|
-
if (names.includes(settled)) {
|
|
294
|
-
const result = await readJournal(join(dir, settled));
|
|
295
|
-
if (!object(result) || result.eventId !== match[2] || !['recorded', 'rejected'].includes(result.state))
|
|
296
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
// An interrupted append is reconciled by pure read, with the original
|
|
300
|
-
// event's epoch intact. Missing receipts stay unknown and never trigger append.
|
|
301
|
-
stable();
|
|
302
|
-
const receipt = unwrap(await api.getRemoteConversationDetailReceipt({ eventId: match[2], sessionId: request.sessionId, provider: request.provider }));
|
|
303
|
-
stable();
|
|
304
|
-
if (receipt.eventId !== match[2])
|
|
305
|
-
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
306
|
-
if (!receipt.recorded)
|
|
307
|
-
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
308
|
-
if (!isDeepStrictEqual(receipt.detail, saved.detail))
|
|
309
|
-
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
310
|
-
await durableWrite(join(dir, settled), { eventId: match[2], state: 'recorded' });
|
|
311
|
-
}
|
|
312
|
-
let cursor, previous = -1;
|
|
313
|
-
const seen = new Set();
|
|
314
|
-
let expected;
|
|
315
|
-
let count = 0;
|
|
316
|
-
do {
|
|
317
|
-
stable();
|
|
318
|
-
const page = unwrap(await api.listRemoteConversationDetails({ sessionId: request.sessionId, provider: request.provider, ...(cursor ? { cursor } : {}) }));
|
|
319
|
-
stable();
|
|
320
|
-
if (!object(page) || !Array.isArray(page.items) || ![page.total, page.unknownCount, page.actionRequiredCount].every(n => Number.isSafeInteger(n) && n >= 0))
|
|
321
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
322
|
-
const counts = { total: page.total, unknownCount: page.unknownCount, actionRequiredCount: page.actionRequiredCount };
|
|
323
|
-
if (expected && !isDeepStrictEqual(expected, counts))
|
|
324
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
325
|
-
expected = counts;
|
|
326
|
-
for (const entry of page.items) {
|
|
327
|
-
if (entry.sessionId !== request.sessionId || entry.provider !== request.provider || !Number.isSafeInteger(entry.sequence) || entry.sequence <= previous)
|
|
328
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
329
|
-
previous = entry.sequence;
|
|
330
|
-
scope.maximum = Math.max(scope.maximum, entry.sequence);
|
|
331
|
-
count++;
|
|
332
|
-
}
|
|
333
|
-
cursor = page.nextCursor;
|
|
334
|
-
if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || seen.has(cursor)))
|
|
335
|
-
throw failure('REMOTE_DETAIL_CURSOR_INVALID');
|
|
336
|
-
if (cursor)
|
|
337
|
-
seen.add(cursor);
|
|
338
|
-
} while (cursor);
|
|
339
|
-
if (count !== expected?.total)
|
|
340
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
341
|
-
scope.loaded = true;
|
|
342
|
-
scope.generation = generation;
|
|
343
|
-
});
|
|
344
|
-
scope.tail = load.then(() => { }, () => { });
|
|
345
|
-
await load;
|
|
346
|
-
let turn = null, step = null;
|
|
347
|
-
let tail = Promise.resolve(), firstFailure;
|
|
348
|
-
const enqueue = (operation) => {
|
|
349
|
-
const task = scope.tail.then(operation);
|
|
350
|
-
scope.tail = task.then(() => { }, () => { });
|
|
351
|
-
tail = task.catch(error => { scope.loaded = false; firstFailure ??= { error }; });
|
|
352
|
-
};
|
|
353
|
-
return {
|
|
354
|
-
beginTurn(ids) { if (!identifier(ids.turn) || !identifier(ids.step))
|
|
355
|
-
throw failure('REMOTE_DETAIL_SCOPE_INVALID'); turn = ids.turn; step = ids.step; },
|
|
356
|
-
endTurn() { turn = null; step = null; },
|
|
357
|
-
record(observation) {
|
|
358
|
-
const safe = sanitizeObservation(observation, true);
|
|
359
|
-
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;
|
|
360
|
-
const sequence = scope.maximum + 1;
|
|
361
|
-
if (!Number.isSafeInteger(sequence))
|
|
362
|
-
throw failure('REMOTE_DETAIL_SEQUENCE_INVALID');
|
|
363
|
-
scope.maximum = sequence;
|
|
364
|
-
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) });
|
|
365
|
-
enqueue(async () => {
|
|
366
|
-
// Pending is not a delivery verdict. Keep the exact failed stage separately.
|
|
367
|
-
const file = join(dir, sequence + '-' + detail.eventId + '.json');
|
|
368
|
-
let phase = 'journal', appendAttempted = false, outcomeKnown = false;
|
|
369
|
-
try {
|
|
370
|
-
try {
|
|
371
|
-
await durableWrite(file, { state: 'pending', detail });
|
|
372
|
-
}
|
|
373
|
-
catch {
|
|
374
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
375
|
-
}
|
|
376
|
-
phase = 'append-scope';
|
|
377
|
-
stable();
|
|
378
|
-
let response;
|
|
379
|
-
try {
|
|
380
|
-
phase = 'append';
|
|
381
|
-
appendAttempted = true;
|
|
382
|
-
response = await (localScope === undefined ? api.appendRemoteConversationDetail(structuredClone(detail)) : api.appendRemoteConversationDetail(structuredClone(detail), localScope));
|
|
383
|
-
}
|
|
384
|
-
catch {
|
|
385
|
-
phase = 'receipt-scope';
|
|
386
|
-
stable();
|
|
387
|
-
phase = 'receipt';
|
|
388
|
-
const receipt = unwrap(await api.getRemoteConversationDetailReceipt({ eventId: detail.eventId, sessionId: detail.sessionId, provider: detail.provider }));
|
|
389
|
-
phase = 'receipt-scope';
|
|
390
|
-
stable();
|
|
391
|
-
if (receipt.eventId !== detail.eventId)
|
|
392
|
-
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
393
|
-
if (!receipt.recorded)
|
|
394
|
-
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
395
|
-
if (!isDeepStrictEqual(receipt.detail, detail))
|
|
396
|
-
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
397
|
-
phase = 'settlement';
|
|
398
|
-
await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'recorded' });
|
|
399
|
-
outcomeKnown = true;
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
phase = 'ack-scope';
|
|
403
|
-
stable();
|
|
404
|
-
if (response?.ok === false) {
|
|
405
|
-
phase = 'settlement';
|
|
406
|
-
await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'rejected', code: response.error.code });
|
|
407
|
-
outcomeKnown = true;
|
|
408
|
-
}
|
|
409
|
-
const ack = unwrap(response);
|
|
410
|
-
if (ack.eventId !== detail.eventId || ack.recorded !== true)
|
|
411
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
412
|
-
phase = 'settlement';
|
|
413
|
-
await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'recorded' });
|
|
414
|
-
outcomeKnown = true;
|
|
415
|
-
}
|
|
416
|
-
catch (error) {
|
|
417
|
-
if (!outcomeKnown) {
|
|
418
|
-
try {
|
|
419
|
-
await durableWrite(join(dir, 'unconfirmed-' + detail.eventId + '.json'), { version: 1, eventId: detail.eventId, state: 'unconfirmed', phase, appendAttempted, code: closeCode(error, 'REMOTE_DETAIL_STORAGE_FAILED') });
|
|
420
|
-
}
|
|
421
|
-
catch {
|
|
422
|
-
throw withFailureEvidence(error, [failure('REMOTE_DETAIL_STORAGE_FAILED')], 'diagnostic');
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
throw error;
|
|
426
|
-
}
|
|
427
|
-
});
|
|
428
|
-
},
|
|
429
|
-
async flush() { await tail; if (firstFailure)
|
|
430
|
-
throw firstFailure.error; stable(); },
|
|
431
|
-
};
|
|
432
|
-
}
|
|
131
|
+
const name = filename(request.sessionId, request.provider), owner = memory.get(name) ?? [];
|
|
132
|
+
memory.set(name, owner);
|
|
133
|
+
let context = { turn: null, step: null };
|
|
134
|
+
return { beginTurn(ids) { context = { ...ids }; }, endTurn() { context = { turn: null, step: null }; }, record(observation) { send(request, observation, context, undefined, owner); } };
|
|
135
|
+
},
|
|
433
136
|
};
|
|
434
137
|
}
|
|
@@ -1,25 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import type { DetailObservation } from './conversation-details.ts';
|
|
3
|
-
export type SourceDecision = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
|
|
4
|
-
export type SourceAdmissionStage = 'source-context' | 'source-confirmation' | 'input-acceptance' | 'login-readiness' | 'local-admission' | 'input-prepare' | 'model-catalog' | 'runtime-resolve';
|
|
5
|
-
export interface WorkspaceSourceAdmission {
|
|
6
|
-
assertCurrent(): void;
|
|
7
|
-
recordFailure(error: unknown, stage: SourceAdmissionStage): Promise<void>;
|
|
8
|
-
}
|
|
9
|
-
export interface WorkspaceSourceOptions {
|
|
10
|
-
workspace(request: RuntimeRequest): Promise<{
|
|
11
|
-
id: string;
|
|
12
|
-
path: string;
|
|
13
|
-
}>;
|
|
14
|
-
currentUserTurn(request: RuntimeRequest): {
|
|
15
|
-
turn: number;
|
|
16
|
-
userMessageId: string;
|
|
17
|
-
};
|
|
18
|
-
confirm(question: {
|
|
19
|
-
question: string;
|
|
20
|
-
action: string;
|
|
21
|
-
}, request: RuntimeRequest): Promise<SourceDecision>;
|
|
22
|
-
record(request: RuntimeRequest, observation: DetailObservation): Promise<void>;
|
|
23
|
-
}
|
|
24
|
-
/** Explicit human source transition only. No source selection is inferred from a folder path. */
|
|
25
|
-
export declare function confirmLegacyWorkspaceSource(get: () => ConnectApi | undefined, api: ConnectApi, request: RuntimeRequest, hooks: WorkspaceSourceOptions): Promise<WorkspaceSourceAdmission | undefined>;
|
|
1
|
+
export type SourceAdmissionStage = 'source-context' | 'input-acceptance' | 'login-readiness' | 'local-admission' | 'input-prepare' | 'model-catalog' | 'runtime-resolve';
|