@ai-workbench/network 0.4.5 → 0.4.7

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/network.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { writeSync } from 'node:fs';
3
+ import { randomUUID, createHash } from 'node:crypto';
2
4
  import { join, isAbsolute, normalize, dirname, basename, relative, sep } from 'node:path';
3
5
  import { realpath, lstat } from 'node:fs/promises';
4
6
  import { privateFile, verifyExecutable } from "./private.js";
@@ -38,11 +40,13 @@ function statusFrame(v) {
38
40
  const receipt = receiptFrame(v.receipt);
39
41
  return { ...receipt ? { receipt } : {}, apiVersion: 1, enrollment: v.enrollment, node: { name: v.node.name, ips: v.node.ips }, host: { name: v.host.name, online: v.host.online, lastSeenAt: v.host.lastSeenAt, observedAt: v.host.observedAt }, error };
40
42
  }
41
- export function createNetwork(settings, executable, emit = () => { }, controlIO = control) {
43
+ export function createNetwork(settings, executable, emit = () => { }, controlIO = control, resumeIdentityOnly = false, expectedConfigSHA256) {
42
44
  let state = { phase: 'starting' }, cfg, child;
43
45
  let starting, disposing, disposed = false, online = false;
44
46
  let poll;
45
47
  let processFailed = false, startupReceipt, terminalNeed, stoppingChild, shutdownNeed;
48
+ let logClosed, lifecycleNeed;
49
+ let configSHA256;
46
50
  const childGone = new AbortController();
47
51
  const lifetime = new AbortController(), listeners = new Set(), updates = new Set();
48
52
  const leases = new Set();
@@ -68,9 +72,20 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
68
72
  const block = (code, message, requires) => publish({ phase: 'blocked', error: needs(code, message, requires) });
69
73
  const socket = () => join(cfg.runtimeDir, 'control.sock');
70
74
  const alive = () => !!child?.pid && !processFailed && child.exitCode === null && child.signalCode === null;
71
- const terminal = (need) => { processFailed = true; terminalNeed ??= need; childGone.abort(); if (poll)
72
- clearTimeout(poll); if (!disposed)
73
- publish({ phase: 'blocked', error: terminalNeed, ...state.status ? { status: state.status } : startupReceipt ? { status: { apiVersion: 1, enrollment: 'error', node: { name: null, ips: [] }, host: { name: null, online: null, lastSeenAt: null, observedAt: null }, error: terminalNeed, receipt: startupReceipt } } : {} }); };
75
+ const terminal = (need) => {
76
+ processFailed = true;
77
+ terminalNeed ??= need;
78
+ if (lifecycleNeed) {
79
+ lifecycleNeed.cause ??= [terminalNeed.code, terminalNeed.message, terminalNeed.cause].filter(Boolean).join(';');
80
+ terminalNeed = lifecycleNeed;
81
+ }
82
+ childGone.abort();
83
+ if (poll)
84
+ clearTimeout(poll);
85
+ const previous = state.status, status = previous ? { ...previous, enrollment: 'error', host: { ...previous.host, online: null, observedAt: null }, error: terminalNeed } : startupReceipt ? { apiVersion: 1, enrollment: 'error', node: { name: null, ips: [] }, host: { name: null, online: null, lastSeenAt: null, observedAt: null }, error: terminalNeed, receipt: startupReceipt } : undefined;
86
+ if (!disposed)
87
+ publish({ phase: 'blocked', error: terminalNeed, ...status ? { status } : {} });
88
+ };
74
89
  const stopOwned = () => stoppingChild ??= (async () => {
75
90
  const owned = child;
76
91
  if (!owned?.pid || owned.exitCode !== null || owned.signalCode !== null)
@@ -124,11 +139,21 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
124
139
  let configStat;
125
140
  try {
126
141
  configStat = await file.stat();
127
- cfg = JSON.parse(await file.readFile('utf8'));
142
+ const bytes = await file.readFile();
143
+ configSHA256 = createHash('sha256').update(bytes).digest('hex');
144
+ cfg = JSON.parse(bytes.toString('utf8'));
128
145
  }
129
146
  finally {
130
147
  await file.close();
131
148
  }
149
+ if (resumeIdentityOnly && Object.hasOwn(cfg, 'authKeyFile')) {
150
+ startupNeed = needs('HELPER_RECOVERY_IDENTITY_REQUIRED', '无法安全恢复既有身份,连接配置仍含首次开通材料。', '管理员核对原身份与消费记录;不会读取或重新提交旧密钥');
151
+ throw new Error(startupNeed.code);
152
+ }
153
+ if (expectedConfigSHA256 !== undefined && configSHA256 !== expectedConfigSHA256) {
154
+ startupNeed = needs('HELPER_RECOVERY_CONFIG_CHANGED', '网络配置在组件运行后发生变化,已停止自动恢复。', '管理员核对原连接配置与身份;不启动其他身份、不重交密钥');
155
+ throw new Error(startupNeed.code);
156
+ }
132
157
  if (!validHelperPolicy(settings))
133
158
  throw new Error('CONFIG_INVALID');
134
159
  cfg = effectiveConfig(cfg, settings);
@@ -200,25 +225,45 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
200
225
  environment.AW_NET_PARENT_UMASK = String(employeeUmask);
201
226
  }
202
227
  try {
203
- child = spawn(executable.path, ['serve', '--config', settings.configFile, ...helperPolicyArgs(settings), '--controlResponseMaxBytes', String(settings.controlResponseMaxBytes)], { stdio: ['ignore', 'pipe', log.fd], env: environment });
228
+ child = spawn(executable.path, ['serve', '--config', settings.configFile, ...resumeIdentityOnly ? ['--resumeIdentityOnly'] : [], ...expectedConfigSHA256 ? ['--resumeConfigSHA256', expectedConfigSHA256] : [], ...helperPolicyArgs(settings), '--controlResponseMaxBytes', String(settings.controlResponseMaxBytes)], { stdio: ['ignore', 'pipe', log.fd], env: environment });
204
229
  }
205
230
  catch (error) {
206
231
  await log.close();
207
232
  throw error;
208
233
  }
209
234
  // Install process handlers before yielding: spawn errors can arrive on nextTick.
210
- const logClosed = log.close();
211
235
  const processChild = child;
212
- processChild.on('error', () => terminal(needs('HELPER_PROCESS_FAILED', '网络组件进程失败,根因未查明', '管理员检查helper诊断日志')));
213
- const exited = () => {
236
+ const runtimeId = randomUUID(), processStarted = performance.now();
237
+ let journalClosing = false, journalDone;
238
+ logClosed = new Promise(resolve => journalDone = resolve);
239
+ const journalFailed = () => { lifecycleNeed ??= needs('HELPER_LIFECYCLE_LOG_FAILED', '网络组件进程诊断无法完整写入,已停止自动恢复。', '管理员检查诊断日志权限、磁盘及退出记录;不重交密钥'); };
240
+ const record = (operation, exitCode = null, signal = null) => {
241
+ try {
242
+ const line = Buffer.from(JSON.stringify({ event: 'HELPER_PROCESS', operation, runtimeId, at: new Date().toISOString(), pid: processChild.pid ?? null, parentPid: process.pid, expected: disposed || !!stoppingChild, exitCode, signal, elapsedMs: performance.now() - processStarted }) + '\n');
243
+ if (writeSync(log.fd, line) !== line.length)
244
+ journalFailed();
245
+ }
246
+ catch {
247
+ journalFailed();
248
+ }
249
+ };
250
+ const closeJournal = () => { if (journalClosing)
251
+ return; journalClosing = true; void log.close().catch(() => { journalFailed(); terminal(lifecycleNeed); }).finally(journalDone); };
252
+ processChild.once('spawn', () => { record('spawn'); if (lifecycleNeed)
253
+ terminal(lifecycleNeed); });
254
+ processChild.on('error', () => { record('spawn-error'); terminal(needs('HELPER_PROCESS_FAILED', '网络组件进程启动或运行失败,根因未查明', '管理员检查helper完整性和进程诊断;不重交密钥')); });
255
+ const exited = (exitCode, signal) => {
256
+ record('exit', exitCode, signal);
214
257
  processFailed = true;
215
258
  childGone.abort();
216
259
  if (poll)
217
260
  clearTimeout(poll);
218
261
  if (handshakeComplete)
219
- terminal(startupNeed ?? needs('HELPER_EXITED', '网络组件已退出,根因未查明', '管理员查看网络组件日志;不自动重启或重交密钥'));
262
+ terminal(startupNeed ?? { ...needs('HELPER_EXITED', '网络组件已退出,当前连接不可用。', '由网络服务按既有身份及恢复策略监督;旧操作不重放,不重交密钥'), cause: `exitCode=${exitCode ?? 'null'};signal=${signal ?? 'null'}` });
263
+ void closeJournal();
220
264
  };
221
265
  processChild.once('exit', exited);
266
+ processChild.once('close', () => void closeJournal());
222
267
  const ready = new Promise((resolve, reject) => {
223
268
  let data = Buffer.alloc(0), settled = false;
224
269
  const started = performance.now();
@@ -270,12 +315,7 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
270
315
  processChild.stdout.on('data', read);
271
316
  lifetime.signal.addEventListener('abort', aborted, { once: true });
272
317
  });
273
- try {
274
- await ready;
275
- }
276
- finally {
277
- await logClosed;
278
- }
318
+ await ready;
279
319
  processChild.stdout.resume();
280
320
  if (!disposed)
281
321
  await observe();
@@ -293,7 +333,7 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
293
333
  }
294
334
  })();
295
335
  const api = {
296
- start, getState: snapshot,
336
+ start, getState: snapshot, getRecoveryConfigSHA256: () => configSHA256,
297
337
  onState(listener) { listeners.add(listener); return () => listeners.delete(listener); },
298
338
  async connect(target, signal) {
299
339
  await start();
@@ -415,7 +455,8 @@ export function createNetwork(settings, executable, emit = () => { }, controlIO
415
455
  await starting;
416
456
  await Promise.all([...leases].map(x => x.release()));
417
457
  const forced = await stopOwned();
418
- publish(forced ? { phase: 'blocked', error: shutdownNeed } : { phase: 'stopped' });
458
+ await logClosed;
459
+ publish(forced ? { phase: 'blocked', error: shutdownNeed } : lifecycleNeed ? { phase: 'blocked', error: lifecycleNeed } : { phase: 'stopped' });
419
460
  listeners.clear();
420
461
  updates.clear();
421
462
  })();
@@ -0,0 +1,113 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { recoveryPolicyFields } from './settings.ts';
3
+ import type { NetworkState } from './api.ts';
4
+ type Key = typeof recoveryPolicyFields[number];
5
+ type Raw = Record<string, unknown>;
6
+ interface Entry {
7
+ options: {
8
+ id?: string;
9
+ name?: string;
10
+ config?: Raw;
11
+ };
12
+ }
13
+ export interface PolicyConfigEditor {
14
+ entries(): Entry[];
15
+ configuration(): {
16
+ entry: Entry;
17
+ inherited: Raw;
18
+ override: Raw;
19
+ }[];
20
+ edit(entry: Entry, change: (current: Raw, inherited: Raw) => Raw): Promise<void>;
21
+ }
22
+ interface PolicyObservation {
23
+ network?: Pick<NetworkState, 'phase' | 'error'>;
24
+ connection?: {
25
+ instanceId: string;
26
+ epoch: number;
27
+ phase: string;
28
+ };
29
+ }
30
+ export declare function createPolicyEditor(editor: PolicyConfigEditor, observe?: () => PolicyObservation): {
31
+ describe: () => {
32
+ ok: false;
33
+ error: {
34
+ code: string;
35
+ message: string;
36
+ };
37
+ } | {
38
+ ok: true;
39
+ value: {
40
+ revision: string;
41
+ runtime: {
42
+ observedAt: string;
43
+ phase: string;
44
+ need: {
45
+ code: string;
46
+ message: string;
47
+ needs: string;
48
+ exit: {
49
+ exitCode: number | null;
50
+ signal: string | null;
51
+ } | null;
52
+ } | null;
53
+ connection: {
54
+ instanceId: string;
55
+ epoch: number;
56
+ phase: string;
57
+ } | null;
58
+ };
59
+ fields: Record<Key, {
60
+ value: number | null;
61
+ explicit: boolean;
62
+ defaultValue: number;
63
+ }>;
64
+ };
65
+ };
66
+ save: (params: URLSearchParams) => Promise<{
67
+ ok: false;
68
+ error: {
69
+ code: string;
70
+ message: string;
71
+ };
72
+ } | {
73
+ ok: true;
74
+ value: {
75
+ revision: string;
76
+ runtime: {
77
+ observedAt: string;
78
+ phase: string;
79
+ need: {
80
+ code: string;
81
+ message: string;
82
+ needs: string;
83
+ exit: {
84
+ exitCode: number | null;
85
+ signal: string | null;
86
+ } | null;
87
+ } | null;
88
+ connection: {
89
+ instanceId: string;
90
+ epoch: number;
91
+ phase: string;
92
+ } | null;
93
+ };
94
+ fields: Record<Key, {
95
+ value: number | null;
96
+ explicit: boolean;
97
+ defaultValue: number;
98
+ }>;
99
+ };
100
+ }>;
101
+ };
102
+ interface Web {
103
+ register(route: {
104
+ kind: 'prefix';
105
+ path: string;
106
+ handler(req: IncomingMessage, res: ServerResponse): Promise<void>;
107
+ }): () => void;
108
+ }
109
+ interface Connection {
110
+ requestRejection(req: IncomingMessage): number | undefined;
111
+ }
112
+ export declare function registerPolicyRoutes(web: Web, connection: Connection, service: ReturnType<typeof createPolicyEditor>): () => void;
113
+ export {};
@@ -0,0 +1,123 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { helperRecoveryDefaults, recoveryPolicyFields, validRecoveryPolicy } from "./settings.js";
3
+ const fields = (value) => Object.fromEntries(recoveryPolicyFields.filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]]));
4
+ const revision = (current, inherited) => createHash('sha256').update(JSON.stringify([fields(current), fields(inherited)])).digest('hex');
5
+ const failure = (code, message) => ({ ok: false, error: { code, message } });
6
+ const unavailable = () => failure('RECOVERY_SETTINGS_UNAVAILABLE', '我需要官方配置编辑服务和唯一且已启用的网络组件,才能读取或保存恢复设置。');
7
+ const recoveryNeeds = new Set(['HELPER_RECOVERING', 'HELPER_RECOVERY_EXHAUSTED', 'HELPER_RECOVERY_DISABLED', 'HELPER_RECOVERY_FAILED', 'HELPER_RECOVERY_IDENTITY_REQUIRED', 'HELPER_RECOVERY_CONFIG_CHANGED', 'HELPER_LIFECYCLE_LOG_FAILED', 'HELPER_EXITED', 'HELPER_PROCESS_FAILED', 'HELPER_START_FAILED', 'HELPER_STARTUP_EXPIRED', 'HELPER_SHUTDOWN_FORCED', 'HELPER_HANDOFF_FAILED']);
8
+ function runtimeObservation(observe) {
9
+ const observedAt = new Date().toISOString();
10
+ try {
11
+ const { network, connection } = observe(), error = network?.error;
12
+ const match = typeof error?.cause === 'string' ? error.cause.match(/(?:^|;)exitCode=(null|-?[0-9]+);signal=(null|SIG[A-Z0-9]+)(?:;|$)/) : null;
13
+ const exit = match && (match[1] === 'null' || Number.isSafeInteger(Number(match[1]))) ? { exitCode: match[1] === 'null' ? null : Number(match[1]), signal: match[2] === 'null' ? null : match[2] } : null;
14
+ const need = error && recoveryNeeds.has(error.code) ? { code: error.code, message: error.message, needs: error.needs, exit } : error ? { code: /^[A-Z][A-Z0-9_]+$/.test(error.code) ? error.code : 'NETWORK_STATUS_UNKNOWN', message: '网络组件当前不可用;请在远程主机连接设置中查看对应诊断。', needs: '我需要管理员核对组件状态;不会重新提交入网密钥。', exit: null } : network ? null : { code: 'NETWORK_SERVICE_REQUIRED', message: '我需要已启用的网络组件才能读取状态。', needs: '请在官方插件管理中查看网络组件是否启用。', exit: null };
15
+ return { observedAt, phase: network?.phase ?? 'missing', need, connection: connection && typeof connection.instanceId === 'string' && Number.isSafeInteger(connection.epoch) && connection.epoch >= 0 ? { instanceId: connection.instanceId, epoch: connection.epoch, phase: connection.phase } : null };
16
+ }
17
+ catch {
18
+ return { observedAt, phase: 'unknown', need: { code: 'NETWORK_STATUS_UNKNOWN', message: '组件状态读取失败,根因未查明。', needs: '我需要管理员查看组件诊断;本页不猜测当前连接状态。', exit: null }, connection: null };
19
+ }
20
+ }
21
+ export function createPolicyEditor(editor, observe = () => ({})) {
22
+ function target() {
23
+ const entries = editor.entries().filter(e => e.options.id === 'ai-workbench-network' && e.options.name === '@ai-workbench/network');
24
+ if (entries.length !== 1)
25
+ return null;
26
+ const entry = entries[0], layers = editor.configuration().filter(v => v.entry === entry);
27
+ if (layers.length !== 1)
28
+ return null;
29
+ return layers[0];
30
+ }
31
+ function describe() {
32
+ try {
33
+ const found = target();
34
+ if (!found)
35
+ return unavailable();
36
+ const current = found.entry.options.config ?? {};
37
+ if (!validRecoveryPolicy(current))
38
+ return failure('RECOVERY_SETTINGS_INVALID', '恢复设置包含非法值,请通过官方配置编辑流程修正。');
39
+ return { ok: true, value: { revision: revision(current, found.inherited), runtime: runtimeObservation(observe), fields: Object.fromEntries(recoveryPolicyFields.map(key => [key, { value: current[key] ?? null, explicit: Object.hasOwn(current, key), defaultValue: helperRecoveryDefaults[key] }])) } };
40
+ }
41
+ catch {
42
+ return unavailable();
43
+ }
44
+ }
45
+ async function save(params) {
46
+ const allowed = ['revision', ...recoveryPolicyFields];
47
+ if ([...params.keys()].some(k => !allowed.includes(k)) || allowed.some(k => params.getAll(k).length !== 1) || !/^[a-f0-9]{64}$/.test(params.get('revision') ?? ''))
48
+ return failure('RECOVERY_SETTINGS_INPUT_INVALID', '设置未保存:请只提交当前页面中的三项恢复设置。');
49
+ const values = {};
50
+ for (const key of recoveryPolicyFields) {
51
+ const raw = params.get(key);
52
+ if (raw === 'unset') {
53
+ values[key] = 'unset';
54
+ continue;
55
+ }
56
+ if (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(raw))
57
+ return failure('RECOVERY_SETTINGS_INPUT_INVALID', '设置未保存:次数须为非负安全整数;秒数须在0.001到2147483.647之间。');
58
+ const value = Number(raw);
59
+ if (!validRecoveryPolicy({ [key]: value }))
60
+ return failure('RECOVERY_SETTINGS_INPUT_INVALID', '设置未保存:次数须为非负安全整数;秒数须在0.001到2147483.647之间。');
61
+ values[key] = value;
62
+ }
63
+ try {
64
+ const found = target();
65
+ if (!found)
66
+ return unavailable();
67
+ await editor.edit(found.entry, (current, inherited) => {
68
+ if (revision(current, inherited) !== params.get('revision'))
69
+ throw new PolicyConflict();
70
+ const next = { ...current };
71
+ for (const key of recoveryPolicyFields) {
72
+ if (values[key] === 'unset') {
73
+ delete next[key];
74
+ }
75
+ else
76
+ next[key] = values[key];
77
+ }
78
+ if (!validRecoveryPolicy(next))
79
+ throw new Error('invalid-policy');
80
+ return next;
81
+ });
82
+ return describe();
83
+ }
84
+ catch (error) {
85
+ return error instanceof PolicyConflict ? failure('RECOVERY_SETTINGS_CONFLICT', '设置未保存:配置已被其他操作修改,请重新打开本页核对后保存。') : failure('RECOVERY_SETTINGS_SAVE_FAILED', '官方配置保存或重载失败,请检查插件管理中的错误;本页未确认修改生效。');
86
+ }
87
+ }
88
+ return { describe, save };
89
+ }
90
+ class PolicyConflict extends Error {
91
+ }
92
+ export function registerPolicyRoutes(web, connection, service) {
93
+ return web.register({ kind: 'prefix', path: '/ai-workbench-network/policy', async handler(req, res) {
94
+ res.setHeader('Cache-Control', 'no-store');
95
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
96
+ const reply = (status, result) => { res.statusCode = status; res.end(JSON.stringify(result)); };
97
+ const rejected = connection.requestRejection(req);
98
+ if (rejected !== undefined) {
99
+ reply(rejected, failure('RECOVERY_SETTINGS_FORBIDDEN', '需要当前官方客户端的有效连接。'));
100
+ return;
101
+ }
102
+ const url = new URL(req.url ?? '/', 'http://dsh');
103
+ if (url.pathname !== '/ai-workbench-network/policy') {
104
+ reply(404, failure('RECOVERY_SETTINGS_NOT_FOUND', '没有此设置操作。'));
105
+ return;
106
+ }
107
+ if (req.method === 'GET' && !url.search) {
108
+ const result = service.describe();
109
+ reply(result.ok ? 200 : 503, result);
110
+ return;
111
+ }
112
+ if (req.method === 'POST') {
113
+ if (req.headers['transfer-encoding'] || req.headers['content-length'] !== undefined && req.headers['content-length'] !== '0') {
114
+ reply(400, failure('RECOVERY_SETTINGS_INPUT_INVALID', '设置未保存:本操作不接受请求正文。'));
115
+ return;
116
+ }
117
+ const result = await service.save(url.searchParams);
118
+ reply(result.ok ? 200 : result.error.code === 'RECOVERY_SETTINGS_INPUT_INVALID' ? 400 : 409, result);
119
+ return;
120
+ }
121
+ reply(400, failure('RECOVERY_SETTINGS_INPUT_INVALID', '不支持此设置操作。'));
122
+ } });
123
+ }
@@ -0,0 +1,3 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ export declare const name = "ai-workbench-network-settings";
3
+ export declare function apply(ctx: Context): void;
@@ -0,0 +1,12 @@
1
+ import { createPolicyEditor, registerPolicyRoutes } from "./policy-management.js";
2
+ export const name = 'ai-workbench-network-settings';
3
+ // 伴生行不持有入网身份,目标 runtime 正常重载时保留保存应答路径。
4
+ export function apply(ctx) {
5
+ ctx.inject(['webServer', 'connection', 'configEditor'], host => {
6
+ host.effect(() => registerPolicyRoutes(host.get('webServer'), host.get('connection'), createPolicyEditor(host.get('configEditor'), () => {
7
+ const network = host.get('aiWorkbenchNetwork', false);
8
+ const connect = host.get('aiWorkbenchConnect', false);
9
+ return { network: network?.getState(), connection: connect?.getConnectionState?.() };
10
+ })));
11
+ });
12
+ }
package/lib/settings.d.ts CHANGED
@@ -1,9 +1,21 @@
1
1
  import type { PluginSettings, HelperConfig } from './api.ts';
2
2
  export declare const helperPolicyFields: readonly ["statusRefreshIntervalSeconds", "dialTimeoutSeconds", "headerTimeoutSeconds", "enrollmentObservationSeconds", "httpMaxHeaderBytes", "httpMaxResponseHeaderBytes"];
3
- export declare const pluginPolicyFields: readonly ["startupObservationSeconds", "shutdownObservationSeconds", "controlResponseMaxBytes", "downloadMaxRedirects"];
4
- export declare const settingsFields: readonly ["configFile", "helperLogFile", "startupObservationSeconds", "shutdownObservationSeconds", "controlResponseMaxBytes", "downloadMaxRedirects", "statusRefreshIntervalSeconds", "dialTimeoutSeconds", "headerTimeoutSeconds", "enrollmentObservationSeconds", "httpMaxHeaderBytes", "httpMaxResponseHeaderBytes"];
3
+ export declare const recoveryPolicyFields: readonly ["helperRecoveryMaxAttempts", "helperRecoveryDelaySeconds", "helperRecoveryStableSeconds"];
4
+ export declare const helperRecoveryDefaults: Readonly<{
5
+ helperRecoveryMaxAttempts: 3;
6
+ helperRecoveryDelaySeconds: 1;
7
+ helperRecoveryStableSeconds: 60;
8
+ }>;
9
+ export declare const pluginPolicyFields: readonly ["startupObservationSeconds", "shutdownObservationSeconds", "controlResponseMaxBytes", "downloadMaxRedirects", "helperRecoveryMaxAttempts", "helperRecoveryDelaySeconds", "helperRecoveryStableSeconds"];
10
+ export declare const settingsFields: readonly ["configFile", "helperLogFile", "startupObservationSeconds", "shutdownObservationSeconds", "controlResponseMaxBytes", "downloadMaxRedirects", "helperRecoveryMaxAttempts", "helperRecoveryDelaySeconds", "helperRecoveryStableSeconds", "statusRefreshIntervalSeconds", "dialTimeoutSeconds", "headerTimeoutSeconds", "enrollmentObservationSeconds", "httpMaxHeaderBytes", "httpMaxResponseHeaderBytes"];
5
11
  export declare const timerSeconds: (v: unknown) => v is number;
6
12
  export declare const positiveInteger: (v: unknown) => v is number;
13
+ export declare function validRecoveryPolicy(settings: PluginSettings): boolean;
14
+ export declare function recoveryPolicy(settings: PluginSettings): {
15
+ helperRecoveryMaxAttempts: number;
16
+ helperRecoveryDelaySeconds: number;
17
+ helperRecoveryStableSeconds: number;
18
+ };
7
19
  export declare function validHelperPolicy(settings: PluginSettings): boolean;
8
20
  export declare function mergedSettings(initial: PluginSettings, provided: PluginSettings): PluginSettings;
9
21
  export declare function effectiveConfig(config: HelperConfig, settings: PluginSettings): HelperConfig;
package/lib/settings.js CHANGED
@@ -1,8 +1,17 @@
1
1
  export const helperPolicyFields = ['statusRefreshIntervalSeconds', 'dialTimeoutSeconds', 'headerTimeoutSeconds', 'enrollmentObservationSeconds', 'httpMaxHeaderBytes', 'httpMaxResponseHeaderBytes'];
2
- export const pluginPolicyFields = ['startupObservationSeconds', 'shutdownObservationSeconds', 'controlResponseMaxBytes', 'downloadMaxRedirects'];
2
+ export const recoveryPolicyFields = ['helperRecoveryMaxAttempts', 'helperRecoveryDelaySeconds', 'helperRecoveryStableSeconds'];
3
+ export const helperRecoveryDefaults = Object.freeze({ helperRecoveryMaxAttempts: 3, helperRecoveryDelaySeconds: 1, helperRecoveryStableSeconds: 60 });
4
+ export const pluginPolicyFields = ['startupObservationSeconds', 'shutdownObservationSeconds', 'controlResponseMaxBytes', 'downloadMaxRedirects', ...recoveryPolicyFields];
3
5
  export const settingsFields = ['configFile', 'helperLogFile', ...pluginPolicyFields, ...helperPolicyFields];
4
6
  export const timerSeconds = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0.001 && v * 1000 <= 2147483647;
5
7
  export const positiveInteger = (v) => Number.isSafeInteger(v) && Number(v) > 0;
8
+ export function validRecoveryPolicy(settings) {
9
+ const max = settings.helperRecoveryMaxAttempts;
10
+ return (max === undefined || (Number.isSafeInteger(max) && max >= 0)) && recoveryPolicyFields.slice(1).every(key => settings[key] === undefined || timerSeconds(settings[key]));
11
+ }
12
+ export function recoveryPolicy(settings) {
13
+ return { helperRecoveryMaxAttempts: settings.helperRecoveryMaxAttempts ?? helperRecoveryDefaults.helperRecoveryMaxAttempts, helperRecoveryDelaySeconds: settings.helperRecoveryDelaySeconds ?? helperRecoveryDefaults.helperRecoveryDelaySeconds, helperRecoveryStableSeconds: settings.helperRecoveryStableSeconds ?? helperRecoveryDefaults.helperRecoveryStableSeconds };
14
+ }
6
15
  export function validHelperPolicy(settings) {
7
16
  return helperPolicyFields.every(key => {
8
17
  const v = settings[key];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-workbench/network",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24.13.1"
@@ -19,6 +19,11 @@
19
19
  "default": "./lib/cordis.js"
20
20
  },
21
21
  "./package.json": "./package.json",
22
+ "./settings": {
23
+ "types": "./lib/settings-host.d.ts",
24
+ "default": "./lib/settings-host.js"
25
+ },
26
+ "./client": "./client/index.cjs",
22
27
  "./locale/*.json": "./locale/*.json"
23
28
  },
24
29
  "files": [
@@ -26,9 +31,17 @@
26
31
  "NOTICE.md",
27
32
  "cordis.patch.yml",
28
33
  "locale",
29
- "helpers"
34
+ "helpers",
35
+ "client"
30
36
  ],
31
37
  "dsh": {
38
+ "client": {
39
+ "platform": "web",
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
42
+ "@deepseek-ai/dsh-client-ui-slots"
43
+ ]
44
+ },
32
45
  "bundle": {
33
46
  "patch": "./cordis.patch.yml"
34
47
  }
@@ -46,6 +59,8 @@
46
59
  "@deepseek-ai/cordis": "4.0.4",
47
60
  "@deepseek-ai/schemastery": "3.18.4",
48
61
  "@types/node": "24.0.0",
62
+ "react": "18.3.1",
63
+ "react-test-renderer": "18.3.1",
49
64
  "typescript": "5.9.3"
50
65
  },
51
66
  "publishConfig": {