@ai-workbench/connect 0.7.7 → 0.7.9

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/lib/connect.js CHANGED
@@ -14,6 +14,7 @@ export function validEnrollment(e) {
14
14
  export function createConnect(ports, emit, options = {}) {
15
15
  let state = { phase: 'idle' }, generation = 0, disposed = false;
16
16
  let flight;
17
+ let trustedTarget;
17
18
  const instanceId = randomUUID();
18
19
  let connection = { instanceId, phase: 'unavailable', epoch: 0 };
19
20
  const connectionListeners = new Set();
@@ -22,6 +23,8 @@ export function createConnect(ports, emit, options = {}) {
22
23
  const next = { instanceId, phase, epoch: generation, ...(need ? { need } : {}) };
23
24
  if (JSON.stringify(next) === JSON.stringify(connection))
24
25
  return;
26
+ if (phase !== 'connected')
27
+ trustedTarget = undefined;
25
28
  connection = structuredClone(next);
26
29
  startup?.connectionChanged(next);
27
30
  options.onConnectionState?.(structuredClone(next));
@@ -51,18 +54,24 @@ export function createConnect(ports, emit, options = {}) {
51
54
  const connect = () => {
52
55
  if (disposed)
53
56
  return Promise.resolve(fail('DISPOSED'));
54
- if (state.target)
55
- return Promise.resolve(ok(structuredClone(state.target)));
57
+ if (state.target && connection.phase === 'connected' && trustedTarget)
58
+ return Promise.resolve(ok(structuredClone(trustedTarget)));
56
59
  if (flight)
57
60
  return flight;
61
+ // Replacing an established transport is a new trusted generation, even without a network-lost event.
62
+ if (connection.phase === 'connected') {
63
+ generation++;
64
+ controller.abort();
65
+ controller = new AbortController();
66
+ loginFlights.clear();
67
+ }
58
68
  const g = generation, signal = controller.signal;
59
- if (connection.phase !== 'connected')
60
- setConnection('connecting');
69
+ setConnection('connecting');
61
70
  const run = async () => {
62
71
  if (!ports.enrollment)
63
- return transportBlock(dependency('S host-layout/enroll.json 适配器'));
72
+ return transportBlock(dependency('管理员发放的主机连接资料'));
64
73
  if (!ports.network)
65
- return transportBlock(dependency('N net-helper 生命周期/状态适配器'));
74
+ return transportBlock(dependency('远程网络连接服务'));
66
75
  if (!ports.ssh)
67
76
  return transportBlock(dependency('系统 SSH 适配器'));
68
77
  try {
@@ -83,7 +92,7 @@ export function createConnect(ports, emit, options = {}) {
83
92
  if (!n.ok)
84
93
  return transportBlock(n);
85
94
  if (typeof n.value.proxyCommand !== 'string' || n.value.proxyCommand.length === 0 || /[\r\n\x00]/.test(n.value.proxyCommand))
86
- return transportBlock(dependency('有效的 N ProxyCommand'));
95
+ return transportBlock(dependency('有效的远程网络连接通道'));
87
96
  const { host, username, port, projectRoot, knownHostsFile, session, hostContext } = e.value;
88
97
  const target = { host, username, port, projectRoot, knownHostsFile, session, ...(hostContext ? { hostContext } : {}), proxyCommand: n.value.proxyCommand };
89
98
  publish({ phase: 'ssh' });
@@ -94,11 +103,12 @@ export function createConnect(ports, emit, options = {}) {
94
103
  if (!verified.ok)
95
104
  return transportBlock(verified);
96
105
  publish({ phase: 'connected', target });
106
+ trustedTarget = structuredClone(target);
97
107
  setConnection('connected');
98
108
  return ok(structuredClone(target));
99
109
  }
100
110
  catch {
101
- return invalid(g) ?? transportBlock(dependency('提供方调用失败,根因未查明;请检查 S/N/SSH 适配器'));
111
+ return invalid(g) ?? transportBlock(dependency('连接服务调用失败,根因未查明;请管理员检查连接资料、网络连接服务和 SSH 通道'));
102
112
  }
103
113
  };
104
114
  const promise = track(run());
@@ -111,8 +121,8 @@ export function createConnect(ports, emit, options = {}) {
111
121
  const checkLogin = async (provider) => {
112
122
  if (provider !== 'claude' && provider !== 'codex')
113
123
  return fail('AUTH_STATUS_INVALID');
114
- const g = generation;
115
- const connected = await connect();
124
+ const connecting = connect(), g = generation;
125
+ const connected = await connecting;
116
126
  const staleConnection = invalid(g);
117
127
  if (staleConnection)
118
128
  return staleConnection;
@@ -143,8 +153,8 @@ export function createConnect(ports, emit, options = {}) {
143
153
  if (prior)
144
154
  return prior;
145
155
  const run = async () => {
146
- const g = generation;
147
- const connected = await connect();
156
+ const connecting = connect(), g = generation;
157
+ const connected = await connecting;
148
158
  const staleConnection = invalid(g);
149
159
  if (staleConnection)
150
160
  return staleConnection;
@@ -160,7 +170,9 @@ export function createConnect(ports, emit, options = {}) {
160
170
  return stale;
161
171
  if (!r.ok)
162
172
  return ['network', 'ssh', 'enrollment'].includes(r.error.layer) ? transportBlock(r) : block(r);
163
- const checked = await checkLogin(provider);
173
+ const checked = await checkLogin(provider), staleChecked = invalid(g);
174
+ if (staleChecked)
175
+ return staleChecked;
164
176
  return checked.ok && !checked.value.loggedIn ? block(fail('LOGIN_FAILED')) : checked;
165
177
  }
166
178
  catch {
@@ -179,7 +191,7 @@ export function createConnect(ports, emit, options = {}) {
179
191
  return fail('DISPOSED');
180
192
  if (operation === 'create' && !isProjectName(name))
181
193
  return fail('PROJECT_NAME_INVALID');
182
- const g = generation, connected = await connect();
194
+ const connecting = connect(), g = generation, connected = await connecting;
183
195
  const stale = invalid(g);
184
196
  if (stale)
185
197
  return stale;
@@ -215,6 +227,19 @@ export function createConnect(ports, emit, options = {}) {
215
227
  startup = createStartup(operations, options.providers ?? ['codex', 'claude'], options.onPromptState, options.onPromptResult, instanceId);
216
228
  return {
217
229
  ...operations,
230
+ getModelCatalogTarget: provider => {
231
+ if (disposed)
232
+ return fail('DISPOSED');
233
+ if (provider !== 'claude' && provider !== 'codex')
234
+ return fail('MODEL_CATALOG_CONTEXT_INVALID');
235
+ if (connection.phase !== 'connected' || !trustedTarget)
236
+ return fail('CONNECTION_LOST');
237
+ return ok({ version: 1, purpose: 'model-catalog', scope: 'host-user', instanceId, epoch: connection.epoch, provider, target: structuredClone(trustedTarget) });
238
+ },
239
+ appendRemoteConversationDetail: async () => dependency('官方会话存储与持久诊断记录'),
240
+ getRemoteConversationDetailReceipt: async () => dependency('官方会话存储与持久诊断记录'),
241
+ getRemoteConversationDetailState: () => ({ instanceId, epoch: connection.epoch, revision: 0, phase: disposed ? 'disposed' : 'unavailable', sessions: [], need: need(disposed ? 'DISPOSED' : 'DEPENDENCY_REQUIRED') }),
242
+ onRemoteConversationDetailState: () => () => { }, listRemoteConversationDetails: async () => dependency('官方会话存储与持久诊断记录'),
218
243
  getContractInfo: () => ({ apiVersion: '0.7.0', status: 'implemented', workspaceBinding: 'native-local-v1' }),
219
244
  getEnrollmentState: () => ({ instanceId, revision: 0, phase: 'blocked', need: need('DEPENDENCY_REQUIRED') }), onEnrollmentState: () => () => { },
220
245
  importEnrollment: async () => dependency('官方profileContext与原生目录选择服务'),
@@ -227,8 +252,8 @@ export function createConnect(ports, emit, options = {}) {
227
252
  login: async (provider) => { const request = await startup.request(provider, 'automatic'); if (!request.ok)
228
253
  return request; return request.value.logins[provider] === true ? ok({ provider, loggedIn: true }) : fail('LOGIN_REQUIRED'); },
229
254
  ensureReady: provider => track((async () => {
230
- const g = generation;
231
- const r = await checkLogin(provider);
255
+ const checking = checkLogin(provider), g = generation;
256
+ const r = await checking;
232
257
  const stale = invalid(g);
233
258
  if (stale)
234
259
  return stale;
@@ -238,7 +263,8 @@ export function createConnect(ports, emit, options = {}) {
238
263
  const request = await startup.request(provider, 'automatic');
239
264
  return invalid(g) ?? (!request.ok ? request : fail('LOGIN_REQUIRED'));
240
265
  }
241
- return connect();
266
+ const connected = await connect();
267
+ return invalid(g) ?? connected;
242
268
  })()),
243
269
  dispose: () => { if (disposal)
244
270
  return disposal; startup.stop(); disposed = true; generation++; controller.abort(); unsubscribe?.(); publish({ phase: 'disposed' }); setConnection('disposed'); connectionListeners.clear(); disposal = Promise.allSettled([...pending]).then(() => startup.dispose()); return disposal; },
@@ -0,0 +1,32 @@
1
+ export interface ConversationDetailSettingsValues {
2
+ maxEventBytes: number;
3
+ pageSize: number;
4
+ }
5
+ export interface ConversationDetailSettingsState {
6
+ instanceId: string;
7
+ revision: number;
8
+ phase: 'loading' | 'ready' | 'saving' | 'failed' | 'disposed';
9
+ values?: ConversationDetailSettingsValues;
10
+ source: 'config' | 'saved';
11
+ errorCode?: 'SETTINGS_REQUIRED' | 'REMOTE_DETAIL_STORAGE_FAILED';
12
+ }
13
+ export declare function validDetailSettings(v: any): v is ConversationDetailSettingsValues;
14
+ export declare function createConversationDetailSettings(options: {
15
+ profileDir?: string;
16
+ initial?: Partial<ConversationDetailSettingsValues>;
17
+ metadataMaxBytes?: number;
18
+ }): {
19
+ ready: Promise<void>;
20
+ getState: () => ConversationDetailSettingsState;
21
+ values: () => ConversationDetailSettingsValues | undefined;
22
+ onState(fn: () => void): () => void;
23
+ save: (input: ConversationDetailSettingsValues) => Promise<{
24
+ ok: false;
25
+ error: import("./api.ts").ConnectNeed;
26
+ } | {
27
+ ok: true;
28
+ value: undefined;
29
+ }>;
30
+ dispose(): Promise<void>;
31
+ };
32
+ export type ConversationDetailSettings = ReturnType<typeof createConversationDetailSettings>;
@@ -0,0 +1,119 @@
1
+ import { join, isAbsolute } from 'node:path';
2
+ import { lstat, open, realpath, rename, rm } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { CONVERSATION_DETAIL_DEFAULTS, L_DEFAULTS } from "./defaults.js";
6
+ import { privateDirectory, readPrivate } from "./private-files.js";
7
+ import { fail, ok } from "./needs.js";
8
+ const queues = new Map();
9
+ export function validDetailSettings(v) { return !!v && typeof v === 'object' && Object.keys(v).sort().join(',') === 'maxEventBytes,pageSize' && [v.maxEventBytes, v.pageSize].every(n => Number.isSafeInteger(n) && n > 0); }
10
+ export function createConversationDetailSettings(options) {
11
+ const root = options.profileDir ? join(options.profileDir, 'ai-workbench-connect') : undefined, file = root ? join(root, 'conversation-detail-settings.json') : undefined;
12
+ let state = { instanceId: randomUUID(), revision: 0, phase: 'loading', source: 'config' }, disposed = false, tail = Promise.resolve();
13
+ const listeners = new Set();
14
+ function publish(next) { state = { ...state, ...next, revision: state.revision + 1 }; for (const fn of listeners)
15
+ fn(); }
16
+ async function directory() { if (!options.profileDir || !root || !isAbsolute(options.profileDir) || await realpath(options.profileDir) !== options.profileDir)
17
+ throw Error('SETTINGS_PROFILE_INVALID'); await privateDirectory(root); if (await realpath(root) !== root)
18
+ throw Error('SETTINGS_PATH_INVALID'); const st = await lstat(root); if ((st.mode & 0o777) !== 0o700)
19
+ throw Error('SETTINGS_PATH_INVALID'); const parent = await open(options.profileDir, constants.O_RDONLY | constants.O_NOFOLLOW); try {
20
+ await parent.sync();
21
+ }
22
+ finally {
23
+ await parent.close();
24
+ } }
25
+ async function existing() { const st = await lstat(file); if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || (st.mode & 0o777) !== 0o600)
26
+ throw Error('SETTINGS_PATH_INVALID'); }
27
+ const ready = (async () => {
28
+ try {
29
+ await directory();
30
+ let values = { ...CONVERSATION_DETAIL_DEFAULTS, ...options.initial }, source = 'config';
31
+ try {
32
+ await existing();
33
+ const saved = JSON.parse(await readPrivate(file, options.metadataMaxBytes ?? L_DEFAULTS.enrollmentMetadataMaxBytes));
34
+ if (saved.version !== 1 || !validDetailSettings(saved.values))
35
+ throw Error('SETTINGS_INVALID');
36
+ values = saved.values;
37
+ source = 'saved';
38
+ }
39
+ catch (e) {
40
+ if (e.code !== 'ENOENT')
41
+ throw e;
42
+ }
43
+ if (!validDetailSettings(values))
44
+ throw Error('SETTINGS_INVALID');
45
+ if (!disposed)
46
+ publish({ phase: 'ready', values, source });
47
+ }
48
+ catch {
49
+ if (!disposed)
50
+ publish({ phase: 'failed', values: undefined, errorCode: 'REMOTE_DETAIL_STORAGE_FAILED' });
51
+ }
52
+ })();
53
+ function save(input) {
54
+ if (!validDetailSettings(input))
55
+ return Promise.resolve(fail('SETTINGS_REQUIRED'));
56
+ const values = structuredClone(input);
57
+ const task = (queues.get(file ?? '') ?? Promise.resolve()).then(async () => {
58
+ await ready;
59
+ if (disposed)
60
+ return fail('DISPOSED');
61
+ if (state.phase === 'failed')
62
+ return fail('REMOTE_DETAIL_STORAGE_FAILED');
63
+ publish({ phase: 'saving', errorCode: undefined });
64
+ let temp;
65
+ try {
66
+ await directory();
67
+ try {
68
+ await existing();
69
+ }
70
+ catch (e) {
71
+ if (e.code !== 'ENOENT')
72
+ throw e;
73
+ }
74
+ const bytes = JSON.stringify({ version: 1, values }) + '\n';
75
+ if (Buffer.byteLength(bytes) > (options.metadataMaxBytes ?? L_DEFAULTS.enrollmentMetadataMaxBytes))
76
+ throw Error('SETTINGS_LIMIT');
77
+ temp = join(root, '.conversation-detail-settings-' + randomUUID());
78
+ const h = await open(temp, 'wx', 0o600);
79
+ try {
80
+ await h.writeFile(bytes);
81
+ await h.sync();
82
+ }
83
+ finally {
84
+ await h.close();
85
+ }
86
+ if (disposed)
87
+ return fail('DISPOSED');
88
+ await rename(temp, file);
89
+ temp = undefined;
90
+ const dir = await open(root, constants.O_RDONLY | constants.O_NOFOLLOW);
91
+ try {
92
+ await dir.sync();
93
+ }
94
+ finally {
95
+ await dir.close();
96
+ }
97
+ if (disposed)
98
+ return fail('DISPOSED');
99
+ publish({ phase: 'ready', values, source: 'saved', errorCode: undefined });
100
+ return ok(undefined);
101
+ }
102
+ catch {
103
+ publish({ phase: 'failed', values: undefined, errorCode: 'REMOTE_DETAIL_STORAGE_FAILED' });
104
+ return fail('REMOTE_DETAIL_STORAGE_FAILED');
105
+ }
106
+ finally {
107
+ if (temp)
108
+ await rm(temp, { force: true });
109
+ }
110
+ });
111
+ const done = task.then(() => { }, () => { });
112
+ queues.set(file ?? '', done);
113
+ tail = done;
114
+ void done.then(() => { if (queues.get(file ?? '') === done)
115
+ queues.delete(file ?? ''); });
116
+ return task;
117
+ }
118
+ return { ready, getState: () => structuredClone(state), values: () => state.phase === 'ready' ? structuredClone(state.values) : undefined, onState(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }, save, async dispose() { disposed = true; await ready; await tail; publish({ phase: 'disposed', values: undefined }); listeners.clear(); } };
119
+ }
@@ -0,0 +1,21 @@
1
+ import type { ConnectApi, ConnectionState, Provider, RemoteConversationDetail, RemoteConversationDetailState, Result } from './api.ts';
2
+ export interface ConversationDetailOptions {
3
+ profileDir: string;
4
+ maxEventBytes: number;
5
+ pageSize: number;
6
+ getConnectionState: () => ConnectionState;
7
+ context: () => Promise<Result<{
8
+ identity: string;
9
+ }>>;
10
+ scope: (sessionId: string, provider: Provider) => Promise<Result<{
11
+ identity: string;
12
+ }>>;
13
+ onState?: (state: RemoteConversationDetailState) => void;
14
+ }
15
+ export declare function validRemoteConversationDetail(v: any): v is RemoteConversationDetail;
16
+ type DetailsApi = Pick<ConnectApi, 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'getRemoteConversationDetailState' | 'onRemoteConversationDetailState' | 'listRemoteConversationDetails'>;
17
+ export declare function createConversationDetails(options: ConversationDetailOptions): DetailsApi & {
18
+ observeConnection: (state: ConnectionState) => void;
19
+ dispose: () => Promise<void>;
20
+ };
21
+ export {};