@twinsearth/dsh-master 0.0.0-stage → 3.2.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.
Files changed (97) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +93 -2
  3. package/client.js +334 -0
  4. package/cordis.patch.yml +71 -0
  5. package/dist-host/contract/client.js +82 -0
  6. package/dist-host/contract/constants.js +70 -0
  7. package/dist-host/contract/envelope.js +84 -0
  8. package/dist-host/contract/index.js +17 -0
  9. package/dist-host/contract/negotiate.js +87 -0
  10. package/dist-host/contract/server.js +71 -0
  11. package/dist-host/contract/transports.js +360 -0
  12. package/dist-host/index.js +260 -0
  13. package/dist-host/kernel.js +186 -0
  14. package/dist-host/plugins/cost-optimizer.js +147 -0
  15. package/dist-host/plugins/dual-manager.js +251 -0
  16. package/dist-host/plugins/superagent-router.js +499 -0
  17. package/dist-host/plugins/superagent-worker.js +73 -0
  18. package/dist-host/superagent/bridge-tokens.js +236 -0
  19. package/dist-host/superagent/dispatch.js +198 -0
  20. package/dist-host/superagent/feishu.js +115 -0
  21. package/dist-host/superagent/github.js +119 -0
  22. package/dist-host/superagent/pr-delivery.js +351 -0
  23. package/dist-host/superagent/router.js +219 -0
  24. package/dist-host/superagent/stream-adapter.js +238 -0
  25. package/dist-host/superagent/stream-tokens.js +141 -0
  26. package/dist-host/superagent/ticket-store.js +108 -0
  27. package/dist-host/superagent/trust.js +145 -0
  28. package/dist-host/superagent/types.js +144 -0
  29. package/dist-host/superagent/worker.js +95 -0
  30. package/dist-host/superagent/writeback.js +162 -0
  31. package/dist-worker/agent-loop.js +493 -0
  32. package/dist-worker/errors.js +41 -0
  33. package/dist-worker/github-pr.js +44 -0
  34. package/dist-worker/index.js +323 -0
  35. package/dist-worker/llm-http.js +351 -0
  36. package/dist-worker/main.js +45 -0
  37. package/dist-worker/prompt.js +35 -0
  38. package/dist-worker/sink-http.js +135 -0
  39. package/dist-worker/tools.js +542 -0
  40. package/dist-worker/types.js +14 -0
  41. package/docs/ARCHITECTURE.md +114 -0
  42. package/docs/CHANGELOG.md +389 -0
  43. package/docs/INTEROP.md +60 -0
  44. package/docs/RELEASE-v3.2.8.md +27 -0
  45. package/docs/RELEASE-v3.2.9.md +29 -0
  46. package/docs/REVIEW-v3.2.7.md +121 -0
  47. package/docs/ROADMAP.md +106 -0
  48. package/docs/SECURITY.md +153 -0
  49. package/docs/SMOKE-3.2.5.md +77 -0
  50. package/docs/SMOKE-3.2.7.md +73 -0
  51. package/docs/SUPERAGENT.md +141 -0
  52. package/docs/WORKER-AGENT-LOOP.md +139 -0
  53. package/icon.svg +9 -0
  54. package/locale/en.json +6 -0
  55. package/locale/zh.json +6 -0
  56. package/package.json +79 -4
  57. package/src/contract/client.js +86 -0
  58. package/src/contract/constants.js +79 -0
  59. package/src/contract/envelope.js +83 -0
  60. package/src/contract/index.js +19 -0
  61. package/src/contract/negotiate.js +106 -0
  62. package/src/contract/server.js +77 -0
  63. package/src/contract/transports.js +319 -0
  64. package/src/index.ts +265 -0
  65. package/src/kernel.ts +190 -0
  66. package/src/plugins/cost-optimizer.ts +155 -0
  67. package/src/plugins/dual-manager.ts +252 -0
  68. package/src/plugins/superagent-router.ts +465 -0
  69. package/src/plugins/superagent-worker.ts +85 -0
  70. package/src/superagent/bridge-tokens.ts +236 -0
  71. package/src/superagent/dispatch.ts +197 -0
  72. package/src/superagent/feishu.ts +104 -0
  73. package/src/superagent/github.ts +111 -0
  74. package/src/superagent/pr-delivery.ts +308 -0
  75. package/src/superagent/router.ts +201 -0
  76. package/src/superagent/stream-adapter.ts +184 -0
  77. package/src/superagent/stream-tokens.ts +142 -0
  78. package/src/superagent/ticket-store.ts +104 -0
  79. package/src/superagent/trust.ts +139 -0
  80. package/src/superagent/types.ts +135 -0
  81. package/src/superagent/worker.ts +100 -0
  82. package/src/superagent/writeback.ts +154 -0
  83. package/tsconfig.host.build.json +12 -0
  84. package/tsconfig.host.json +21 -0
  85. package/tsconfig.worker.build.json +13 -0
  86. package/tsconfig.worker.json +17 -0
  87. package/types/superagent.d.ts +99 -0
  88. package/worker/agent-loop.ts +524 -0
  89. package/worker/errors.ts +47 -0
  90. package/worker/github-pr.ts +65 -0
  91. package/worker/index.ts +334 -0
  92. package/worker/llm-http.ts +359 -0
  93. package/worker/main.ts +45 -0
  94. package/worker/prompt.ts +39 -0
  95. package/worker/sink-http.ts +159 -0
  96. package/worker/tools.ts +531 -0
  97. package/worker/types.ts +247 -0
@@ -0,0 +1,84 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+ import crypto from 'node:crypto';
4
+ import { ERROR_CODES, PROTOCOL, PROTOCOL_VERSION } from './constants.js';
5
+ export function newId(prefix = 'msg') {
6
+ return `${prefix}_${crypto.randomUUID()}`;
7
+ }
8
+ /** 创建请求信封;未知字段由接收方忽略(兼容规则)。 */
9
+ export function createEnvelope({ method, params = null, id, source = '', instanceId = '', timeoutMs = 30_000, meta = {} } = {}) {
10
+ if (!method || typeof method !== 'string')
11
+ throw new Error('method is required');
12
+ return {
13
+ protocol: PROTOCOL,
14
+ protocolVersion: PROTOCOL_VERSION,
15
+ id: String(id ?? newId('req')),
16
+ method: String(method),
17
+ params: params ?? null,
18
+ meta: {
19
+ at: Date.now(),
20
+ deadline: Date.now() + Math.max(1, Number(timeoutMs) || 30_000),
21
+ source: String(source ?? ''),
22
+ instanceId: String(instanceId ?? ''),
23
+ ...(meta && typeof meta === 'object' ? meta : {}),
24
+ },
25
+ };
26
+ }
27
+ /** 校验请求/响应信封;返回 {ok, errors, envelope}。 */
28
+ export function validateEnvelope(value, { response = false } = {}) {
29
+ const errors = [];
30
+ const raw = value && typeof value === 'object' ? value : {};
31
+ if (raw.protocol !== PROTOCOL)
32
+ errors.push(`protocol-invalid:${String(raw.protocol)}`);
33
+ if (raw.protocolVersion !== PROTOCOL_VERSION)
34
+ errors.push(`protocol-version-invalid:${String(raw.protocolVersion)}`);
35
+ if (!raw.id)
36
+ errors.push('id-required');
37
+ if (response) {
38
+ if (typeof raw.ok !== 'boolean')
39
+ errors.push('ok-required');
40
+ if (raw.ok === false && !raw.error?.code)
41
+ errors.push('error-code-required');
42
+ }
43
+ else if (!raw.method) {
44
+ errors.push('method-required');
45
+ }
46
+ return { ok: errors.length === 0, errors, envelope: raw };
47
+ }
48
+ export function createResponse({ id, result, error, meta = {} } = {}) {
49
+ const base = {
50
+ protocol: PROTOCOL,
51
+ protocolVersion: PROTOCOL_VERSION,
52
+ id: String(id ?? newId('res')),
53
+ ok: !error,
54
+ meta: { at: Date.now(), ...(meta && typeof meta === 'object' ? meta : {}) },
55
+ };
56
+ if (error)
57
+ return { ...base, error: normalizeError(error) };
58
+ return { ...base, result: result ?? null };
59
+ }
60
+ export function normalizeError(error, fallbackCode = ERROR_CODES.REMOTE_ERROR) {
61
+ if (!error)
62
+ return { code: fallbackCode, message: String(fallbackCode), retryable: false };
63
+ if (typeof error === 'string')
64
+ return { code: fallbackCode, message: error, retryable: false };
65
+ return {
66
+ code: String(error.code ?? fallbackCode),
67
+ message: String(error.message ?? error.error ?? fallbackCode),
68
+ retryable: error.retryable === true,
69
+ ...(error.detail === undefined ? {} : { detail: error.detail }),
70
+ };
71
+ }
72
+ export class ContractTransportError extends Error {
73
+ constructor(code, message, { transport = '', detail = null, retryable = false } = {}) {
74
+ super(String(message ?? code));
75
+ this.name = 'ContractTransportError';
76
+ this.code = String(code);
77
+ this.transport = String(transport);
78
+ this.detail = detail;
79
+ this.retryable = retryable === true;
80
+ }
81
+ }
82
+ export function isCompatibleResponse(value) {
83
+ return validateEnvelope(value, { response: true }).ok;
84
+ }
@@ -0,0 +1,17 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+ /**
4
+ * @twinsearth/dsh-fang-contract
5
+ *
6
+ * 瓮城(dsh-Fang)↔ 房主(dsh-Master)的独立版本化互联 SDK。
7
+ * 设计目标:
8
+ * 1. 双方独立升级:语义化版本 + hello 协商 + 未知字段忽略 + 方法版本裁剪;
9
+ * 2. 互联互通保底:local → http → file → legacy 四级传输自动降级;
10
+ * 3. 接口包可独立替换:更新本包即升级接口架构,旧方法在 MAJOR 内保持可调用。
11
+ */
12
+ export * from './constants.js';
13
+ export * from './envelope.js';
14
+ export * from './negotiate.js';
15
+ export * from './server.js';
16
+ export * from './transports.js';
17
+ export * from './client.js';
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+ import { CAPABILITIES, CONTRACT_VERSION, ERROR_CODES, METHOD_VERSION, METHODS, MIN_COMPATIBLE_MAJOR, PROTOCOL, ROLES, } from './constants.js';
4
+ export function parseVersion(value) {
5
+ const text = String(value ?? '').trim();
6
+ const match = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(text);
7
+ if (!match)
8
+ return null;
9
+ return {
10
+ major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3] ?? 0), text,
11
+ };
12
+ }
13
+ export function compareVersions(a, b) {
14
+ const pa = parseVersion(a);
15
+ const pb = parseVersion(b);
16
+ if (!pa || !pb)
17
+ return 0;
18
+ if (pa.major !== pb.major)
19
+ return pa.major - pb.major;
20
+ if (pa.minor !== pb.minor)
21
+ return pa.minor - pb.minor;
22
+ return pa.patch - pb.patch;
23
+ }
24
+ /** 标准 hello payload(服务端暴露;客户端据此协商)。 */
25
+ export function helloPayload({ role = 'mother', instanceId = '', version = CONTRACT_VERSION, methods = Object.values(METHODS), capabilities = CAPABILITIES, transports = [], startedAt = Date.now(), } = {}) {
26
+ return {
27
+ protocol: PROTOCOL,
28
+ contractVersion: String(version),
29
+ role: ROLES.includes(String(role)) ? String(role) : 'fork',
30
+ instanceId: String(instanceId ?? ''),
31
+ startedAt: Number(startedAt) || Date.now(),
32
+ methods: [...new Set(methods.map(String))],
33
+ capabilities: [...new Set(capabilities.map(String))],
34
+ transports: [...new Set(transports.map(String))],
35
+ features: {
36
+ idempotency: true,
37
+ envelopes: true,
38
+ fileOutbox: true,
39
+ versionNegotiation: true,
40
+ },
41
+ };
42
+ }
43
+ /**
44
+ * 双向版本协商。
45
+ * @param {object} local 本端 helloPayload
46
+ * @param {object} remote 对端 helloPayload
47
+ * @returns {{ok:boolean, compatible:boolean, fallback:'legacy'|null, reason?:string, version?:string, methods:string[], capabilities:string[], features:object}}
48
+ */
49
+ export function negotiate(local, remote) {
50
+ const lv = parseVersion(local?.contractVersion);
51
+ const rv = parseVersion(remote?.contractVersion);
52
+ if (local?.protocol !== PROTOCOL || remote?.protocol !== PROTOCOL) {
53
+ return { ok: false, compatible: false, fallback: 'legacy', reason: 'protocol-mismatch', methods: [], capabilities: [], features: {} };
54
+ }
55
+ if (!lv || !rv) {
56
+ return { ok: false, compatible: false, fallback: 'legacy', reason: 'version-invalid', methods: [], capabilities: [], features: {} };
57
+ }
58
+ if (rv.major < MIN_COMPATIBLE_MAJOR) {
59
+ return { ok: false, compatible: false, fallback: 'legacy', reason: 'remote-too-old', methods: [], capabilities: [], features: {} };
60
+ }
61
+ if (rv.major > lv.major) {
62
+ return { ok: false, compatible: false, fallback: 'legacy', reason: 'remote-too-new', methods: [], capabilities: [], features: {} };
63
+ }
64
+ const localMethods = new Set(Array.isArray(local.methods) ? local.methods : []);
65
+ const methods = (Array.isArray(remote.methods) ? remote.methods : []).filter((method) => localMethods.has(method));
66
+ // 裁剪:方法所需契约 minor 不能高于双方共同支持的 minor。
67
+ const commonMinor = Math.min(lv.minor, rv.minor);
68
+ const supported = methods.filter((method) => {
69
+ const methodVersion = parseVersion(METHOD_VERSION[method] ?? '1.0');
70
+ return !methodVersion || methodVersion.minor <= commonMinor || methodVersion.major < lv.major;
71
+ });
72
+ const localCaps = new Set(Array.isArray(local.capabilities) ? local.capabilities : []);
73
+ const capabilities = (Array.isArray(remote.capabilities) ? remote.capabilities : []).filter((cap) => localCaps.has(cap));
74
+ const version = `${Math.min(lv.major, rv.major)}.${commonMinor}.0`;
75
+ return {
76
+ ok: true,
77
+ compatible: true,
78
+ fallback: null,
79
+ version,
80
+ methods: supported,
81
+ capabilities,
82
+ features: { ...(local.features ?? {}), ...(remote.features ?? {}) },
83
+ };
84
+ }
85
+ export function unsupportedMethodError(method) {
86
+ return { code: ERROR_CODES.UNSUPPORTED_METHOD, message: `unsupported method: ${String(method)}`, retryable: false };
87
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+ import { CAPABILITIES, CONTRACT_VERSION, ERROR_CODES, METHOD_VERSION, METHODS } from './constants.js';
4
+ import { createResponse, normalizeError, validateEnvelope } from './envelope.js';
5
+ import { helloPayload, parseVersion, unsupportedMethodError } from './negotiate.js';
6
+ /**
7
+ * 契约服务端(Fang 侧/任意一侧):
8
+ * * hello() 返回协商信息;
9
+ * * invoke(envelope) 校验信封、检查方法版本、调用 handler、返回标准响应。
10
+ *
11
+ * 兼容原则:新增方法只需加 handler;删除/改义必须同时保留 method@old 一个 MINOR 周期。
12
+ */
13
+ export function createContractServer({ role = 'mother', instanceId = '', version = CONTRACT_VERSION, capabilities = CAPABILITIES, transports = [], handlers = {}, logger = null, startedAt = Date.now(), } = {}) {
14
+ const registry = new Map(Object.entries(handlers));
15
+ const hello = () => helloPayload({
16
+ role,
17
+ instanceId,
18
+ version,
19
+ methods: [...new Set([METHODS.HELLO, METHODS.HEALTH, ...registry.keys()])],
20
+ capabilities,
21
+ transports,
22
+ startedAt,
23
+ });
24
+ const logs = { invoked: 0, failed: 0, lastError: null };
25
+ const invoke = async (envelope) => {
26
+ const validated = validateEnvelope(envelope);
27
+ if (!validated.ok) {
28
+ return createResponse({ id: envelope?.id, error: { code: ERROR_CODES.INVALID_ENVELOPE, message: validated.errors.join(', ') } });
29
+ }
30
+ const method = String(envelope.method);
31
+ try {
32
+ if (method === METHODS.HELLO)
33
+ return createResponse({ id: envelope.id, result: hello() });
34
+ if (method === METHODS.HEALTH) {
35
+ return createResponse({ id: envelope.id, result: { ok: true, role, instanceId, version, uptimeMs: Date.now() - startedAt, methods: [...registry.keys()] } });
36
+ }
37
+ const handler = registry.get(method);
38
+ if (!handler)
39
+ return createResponse({ id: envelope.id, error: unsupportedMethodError(method) });
40
+ const methodVersion = parseVersion(METHOD_VERSION[method] ?? '1.0');
41
+ const localVersion = parseVersion(version);
42
+ if (methodVersion && localVersion && methodVersion.major !== localVersion.major) {
43
+ return createResponse({ id: envelope.id, error: { code: ERROR_CODES.UNSUPPORTED_VERSION, message: `method ${method} requires contract major ${methodVersion.major}` } });
44
+ }
45
+ logs.invoked += 1;
46
+ const result = await handler(envelope.params ?? null, envelope);
47
+ return createResponse({ id: envelope.id, result: result ?? null, meta: { role, instanceId } });
48
+ }
49
+ catch (error) {
50
+ logs.failed += 1;
51
+ logs.lastError = normalizeError(error, ERROR_CODES.HANDLER_THREW);
52
+ if (!error?.code)
53
+ logs.lastError.code = ERROR_CODES.HANDLER_THREW;
54
+ try {
55
+ logger?.warn?.(`[contract] ${method} failed: ${logs.lastError.code} ${logs.lastError.message}`);
56
+ }
57
+ catch { /* ignore */ }
58
+ return createResponse({ id: envelope?.id, error: logs.lastError });
59
+ }
60
+ };
61
+ return {
62
+ hello,
63
+ invoke,
64
+ describe() {
65
+ return {
66
+ protocol: 'fang-master', contractVersion: version, role, instanceId,
67
+ methods: [...registry.keys()], capabilities, transports, stats: { ...logs },
68
+ };
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,360 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { CONTRACT_VERSION, DEFAULT_TRANSPORT_ORDER, ERROR_CODES, LEGACY_SERVICE_KEYS, METHODS, PROTOCOL, } from './constants.js';
6
+ import { ContractTransportError, createEnvelope, createResponse, normalizeError, validateEnvelope } from './envelope.js';
7
+ import { helloPayload, negotiate } from './negotiate.js';
8
+ function safeGet(ctx, name) {
9
+ try {
10
+ if (!ctx || typeof ctx !== 'object')
11
+ return null;
12
+ if (typeof ctx.get === 'function')
13
+ return ctx.get(name) ?? null;
14
+ return ctx[name] ?? null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(1, ms))); }
21
+ function authHeaders(token) {
22
+ return token ? { authorization: `Bearer ${token}` } : {};
23
+ }
24
+ function baseOf(url) { return String(url ?? '').trim().replace(/\/+$/, ''); }
25
+ /** 统一传输接口:{name, kind, available(), hello(), invoke(method, params, opts)}。 */
26
+ export function createLocalTransport({ ctx, serviceName = 'fangContract' } = {}) {
27
+ const service = () => safeGet(ctx, serviceName);
28
+ return {
29
+ name: 'local', kind: 'local', available: () => Boolean(service() && typeof service().invoke === 'function'),
30
+ async hello() {
31
+ const s = service();
32
+ if (!s)
33
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'local contract service is absent', { transport: 'local' });
34
+ return await s.hello();
35
+ },
36
+ async invoke(method, params, opts = {}) {
37
+ const s = service();
38
+ if (!s || typeof s.invoke !== 'function')
39
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'local contract service is absent', { transport: 'local' });
40
+ const response = await s.invoke(createEnvelope({ method, params, source: 'local', meta: opts.meta, timeoutMs: opts.timeoutMs }));
41
+ const check = validateEnvelope(response, { response: true });
42
+ if (!check.ok)
43
+ throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'local' });
44
+ return response;
45
+ },
46
+ };
47
+ }
48
+ export function createHttpTransport({ baseUrl = '', token = '', headers: extraHeaders = {}, fetchImpl = fetch, timeoutMs = 15_000, pathPrefix = '/fang/api/contract' } = {}) {
49
+ const base = baseOf(baseUrl);
50
+ const prefix = `/${String(pathPrefix).replace(/^\/+|\/+$/g, '')}`;
51
+ const available = () => Boolean(base);
52
+ const call = async (endpoint, role, fn, callTimeoutMs = timeoutMs) => {
53
+ if (!available())
54
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'http contract baseUrl is missing', { transport: 'http' });
55
+ const effectiveTimeout = Math.max(1, Number(callTimeoutMs) || timeoutMs);
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), effectiveTimeout);
58
+ timer.unref?.();
59
+ try {
60
+ const response = await fetchImpl(`${base}${prefix}${endpoint}`, {
61
+ method: 'POST',
62
+ headers: { 'content-type': 'application/json', accept: 'application/json', ...authHeaders(token), ...(extraHeaders && typeof extraHeaders === 'object' ? extraHeaders : {}) },
63
+ body: JSON.stringify(role),
64
+ signal: controller.signal,
65
+ });
66
+ const text = await response.text();
67
+ if (!response.ok) {
68
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_HTTP_ERROR, `HTTP ${response.status}`, { transport: 'http', detail: text.slice(0, 500) });
69
+ }
70
+ let parsed = null;
71
+ try {
72
+ parsed = JSON.parse(text);
73
+ }
74
+ catch {
75
+ throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, 'HTTP response is not JSON', { transport: 'http' });
76
+ }
77
+ return fn(parsed);
78
+ }
79
+ catch (error) {
80
+ if (error instanceof ContractTransportError)
81
+ throw error;
82
+ if (error?.name === 'AbortError')
83
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_TIMEOUT, `HTTP contract timeout (>${timeoutMs}ms)`, { transport: 'http', retryable: true });
84
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_HTTP_ERROR, String(error?.message ?? error), { transport: 'http', retryable: true });
85
+ }
86
+ finally {
87
+ clearTimeout(timer);
88
+ }
89
+ };
90
+ return {
91
+ name: 'http', kind: 'http', available,
92
+ async hello() {
93
+ return await call('/hello', { method: METHODS.HELLO }, (parsed) => {
94
+ // 允许 /hello 直接返回 payload,或返回标准 response envelope。
95
+ if (parsed && parsed.protocol)
96
+ return parsed;
97
+ if (parsed && parsed.ok && parsed.result)
98
+ return parsed.result;
99
+ throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, 'invalid hello payload', { transport: 'http' });
100
+ });
101
+ },
102
+ async invoke(method, params, opts = {}) {
103
+ const envelope = createEnvelope({ method, params, source: 'http', meta: opts.meta, timeoutMs: opts.timeoutMs });
104
+ return await call('/invoke', envelope, (parsed) => {
105
+ const check = validateEnvelope(parsed, { response: true });
106
+ if (!check.ok)
107
+ throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'http' });
108
+ return parsed;
109
+ }, opts.timeoutMs);
110
+ },
111
+ };
112
+ }
113
+ function atomicWrite(file, text) {
114
+ const tmp = `${file}.${process.pid}.tmp`;
115
+ return fs.writeFile(tmp, text, 'utf8').then(() => fs.rename(tmp, file));
116
+ }
117
+ /** Windows 对端仍持有句柄时 unlink 可能短暂 EPERM;成功响应后做有界重试清理。 */
118
+ async function removeFileWithRetry(file, attempts = 8) {
119
+ for (let index = 0; index < attempts; index += 1) {
120
+ try {
121
+ await fs.unlink(file);
122
+ return true;
123
+ }
124
+ catch (error) {
125
+ if (error?.code === 'ENOENT')
126
+ return true;
127
+ if (index + 1 < attempts)
128
+ await sleep(25 * (index + 1));
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+ /**
134
+ * 文件 outbox 兜底传输:同一文件系统、无网络/无 HTTP 时使用。
135
+ * 请求写 requests/<id>.json,对端(startFileContractServer)写 responses/<id>.json。
136
+ */
137
+ export function createFileTransport({ dir = '', fetchImpl = null, pollMs = 100, timeoutMs = 15_000 } = {}) {
138
+ const root = String(dir ?? '').trim();
139
+ const directories = {
140
+ request: path.join(root, 'requests'),
141
+ response: path.join(root, 'responses'),
142
+ };
143
+ const ensure = async () => {
144
+ await fs.mkdir(directories.request, { recursive: true });
145
+ await fs.mkdir(directories.response, { recursive: true });
146
+ };
147
+ return {
148
+ name: 'file', kind: 'file',
149
+ available: () => Boolean(root),
150
+ async hello() {
151
+ if (!root)
152
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'file transport dir is missing', { transport: 'file' });
153
+ return await this.invoke(METHODS.HELLO, null).then((response) => (response.ok ? response.result : Promise.reject(new ContractTransportError(ERROR_CODES.REMOTE_ERROR, response.error?.message ?? 'hello failed', { transport: 'file' }))));
154
+ },
155
+ async invoke(method, params, opts = {}) {
156
+ if (!root)
157
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'file transport dir is missing', { transport: 'file' });
158
+ await ensure();
159
+ const envelope = createEnvelope({ method, params, source: 'file', meta: opts.meta, timeoutMs: opts.timeoutMs });
160
+ const requestFile = path.join(directories.request, `${envelope.id}.json`);
161
+ const responseFile = path.join(directories.response, `${envelope.id}.json`);
162
+ await atomicWrite(requestFile, JSON.stringify(envelope));
163
+ const effectiveTimeout = Math.max(1, Number(opts.timeoutMs) || timeoutMs);
164
+ const deadline = Date.now() + effectiveTimeout;
165
+ while (Date.now() < deadline) {
166
+ try {
167
+ const text = await fs.readFile(responseFile, 'utf8');
168
+ await removeFileWithRetry(responseFile);
169
+ const parsed = JSON.parse(text);
170
+ const check = validateEnvelope(parsed, { response: true });
171
+ if (!check.ok)
172
+ throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'file' });
173
+ // 请求文件由调用方负责回收:服务端实现可能只写响应而不删请求,
174
+ // 成功匹配响应后必须幂等清理,避免 contract-outbox 长期积累。
175
+ await removeFileWithRetry(requestFile);
176
+ return parsed;
177
+ }
178
+ catch (error) {
179
+ if (error instanceof ContractTransportError)
180
+ throw error;
181
+ }
182
+ await sleep(pollMs);
183
+ }
184
+ await removeFileWithRetry(requestFile);
185
+ await removeFileWithRetry(responseFile);
186
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_TIMEOUT, `file contract timeout (>${effectiveTimeout}ms)`, { transport: 'file', retryable: true });
187
+ },
188
+ };
189
+ }
190
+ /**
191
+ * 文件 fallback 的服务端轮询器:读取 requests/,调用 contractServer.invoke,写 responses/。
192
+ * @returns {{stop:()=>void, describe:()=>(object)}}
193
+ */
194
+ export function startFileContractServer({ dir, server, pollMs = 100, maxAgeMs = 60_000, logger = null } = {}) {
195
+ const root = String(dir ?? '').trim();
196
+ if (!root)
197
+ throw new Error('startFileContractServer requires dir');
198
+ const requestDir = path.join(root, 'requests');
199
+ const responseDir = path.join(root, 'responses');
200
+ let stopped = false;
201
+ let timer = null;
202
+ const tick = async () => {
203
+ if (stopped)
204
+ return;
205
+ try {
206
+ await fs.mkdir(requestDir, { recursive: true });
207
+ await fs.mkdir(responseDir, { recursive: true });
208
+ const entries = await fs.readdir(requestDir).catch(() => []);
209
+ for (const name of entries) {
210
+ if (!name.endsWith('.json'))
211
+ continue;
212
+ const file = path.join(requestDir, name);
213
+ try {
214
+ const stat = await fs.stat(file);
215
+ if (Date.now() - stat.mtimeMs > maxAgeMs) {
216
+ await fs.unlink(file).catch(() => { });
217
+ continue;
218
+ }
219
+ const envelope = JSON.parse(await fs.readFile(file, 'utf8'));
220
+ const response = await server.invoke(envelope);
221
+ await atomicWrite(path.join(responseDir, `${envelope.id}.json`), JSON.stringify(response));
222
+ await fs.unlink(file).catch(() => { });
223
+ }
224
+ catch (error) {
225
+ try {
226
+ logger?.warn?.(`[contract:file] ${name}: ${String(error?.message ?? error)}`);
227
+ }
228
+ catch { /* ignore */ }
229
+ await fs.unlink(file).catch(() => { });
230
+ }
231
+ }
232
+ }
233
+ catch { /* poll must not die */ }
234
+ if (!stopped)
235
+ timer = setTimeout(tick, pollMs);
236
+ };
237
+ timer = setTimeout(tick, pollMs);
238
+ return {
239
+ stop() { stopped = true; if (timer)
240
+ clearTimeout(timer); timer = null; },
241
+ describe() { return { transport: 'file', dir: root, pollMs, stopped }; },
242
+ };
243
+ }
244
+ /** 旧版 3.1.x 服务键回退:接口包被替换/新端点不存在时仍能工作。 */
245
+ export function createLegacyTransport({ ctx } = {}) {
246
+ const forker = () => safeGet(ctx, LEGACY_SERVICE_KEYS.forker);
247
+ const decision = () => safeGet(ctx, LEGACY_SERVICE_KEYS.decisionCenter);
248
+ const sessions = () => safeGet(ctx, LEGACY_SERVICE_KEYS.sessionCatalog);
249
+ const orchestrator = () => safeGet(ctx, LEGACY_SERVICE_KEYS.orchestrator);
250
+ const available = () => Boolean(forker() || decision() || sessions() || orchestrator());
251
+ return {
252
+ name: 'legacy', kind: 'legacy', available,
253
+ async hello() {
254
+ return {
255
+ protocol: PROTOCOL, contractVersion: '0.9.0', role: 'mother', instanceId: 'legacy',
256
+ methods: [METHODS.HEALTH, METHODS.FORK_SPAWN, METHODS.DECISION_CREATE, METHODS.SESSION_LIST],
257
+ capabilities: ['sync'], transports: ['legacy'],
258
+ };
259
+ },
260
+ async invoke(method, params) {
261
+ if (method === METHODS.HEALTH)
262
+ return createResponse({ result: { ok: true, legacy: true } });
263
+ if (method === METHODS.FORK_SPAWN) {
264
+ const service = forker();
265
+ if (!service || typeof service.spawn !== 'function')
266
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangForker is absent', { transport: 'legacy' });
267
+ return createResponse({ result: await service.spawn(params ?? {}) });
268
+ }
269
+ if (method === METHODS.DECISION_CREATE) {
270
+ const service = decision();
271
+ if (!service || typeof service.create !== 'function')
272
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangDecisionCenter is absent', { transport: 'legacy' });
273
+ return createResponse({ result: await service.create(params ?? {}) });
274
+ }
275
+ if (method === METHODS.SESSION_LIST) {
276
+ const service = sessions();
277
+ if (!service || typeof service.list !== 'function')
278
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangSessionCatalog is absent', { transport: 'legacy' });
279
+ return createResponse({ result: await service.list(params ?? {}) });
280
+ }
281
+ throw new ContractTransportError(ERROR_CODES.UNSUPPORTED_METHOD, `legacy transport cannot serve ${method}`, { transport: 'legacy' });
282
+ },
283
+ };
284
+ }
285
+ /**
286
+ * 传输路由器:按 local → http → file → legacy 顺序尝试;
287
+ * 传输不可用/超时/协议不兼容/未知方法时自动降级;业务错误不降级(避免重复副作用)。
288
+ */
289
+ export function createTransportRouter({ transports = [], order = DEFAULT_TRANSPORT_ORDER, localHello = null, logger = null } = {}) {
290
+ const byName = new Map(transports.map((transport) => [transport.name, transport]));
291
+ const ordered = order.map((name) => byName.get(name)).filter((transport) => transport && (!transport.available || transport.available()));
292
+ const attempts = [];
293
+ const record = (entry) => { attempts.push({ at: Date.now(), ...entry }); if (attempts.length > 100)
294
+ attempts.shift(); };
295
+ return {
296
+ transports: () => ordered.map((transport) => ({ name: transport.name, kind: transport.kind })),
297
+ attempts: () => [...attempts],
298
+ describe() {
299
+ return {
300
+ order,
301
+ available: ordered.map((transport) => transport.name),
302
+ configured: transports.map((transport) => ({ name: transport.name, kind: transport.kind, available: transport.available ? transport.available() : true })),
303
+ };
304
+ },
305
+ async hello() {
306
+ const local = localHello ?? helloPayload({ role: 'client', instanceId: 'contract-client' });
307
+ for (const transport of ordered) {
308
+ try {
309
+ const remote = await transport.hello();
310
+ const negotiation = negotiate(local, remote);
311
+ record({ transport: transport.name, action: 'hello', ok: true, compatible: negotiation.compatible, reason: negotiation.reason ?? null });
312
+ if (negotiation.compatible)
313
+ return { ok: true, transport: transport.name, hello: remote, negotiation, fallback: null };
314
+ // remote-too-old/新:若还有后续传输(如 legacy),继续降级。
315
+ if (transport.name !== 'legacy')
316
+ continue;
317
+ return { ok: true, transport: transport.name, hello: remote, negotiation, fallback: 'legacy' };
318
+ }
319
+ catch (error) {
320
+ record({ transport: transport.name, action: 'hello', ok: false, code: error?.code ?? null });
321
+ try {
322
+ logger?.warn?.(`[contract] hello via ${transport.name} failed: ${error?.code ?? error?.message ?? error}`);
323
+ }
324
+ catch { /* ignore */ }
325
+ }
326
+ }
327
+ return { ok: false, code: ERROR_CODES.ALL_TRANSPORTS_FAILED, error: 'no transport could complete hello', attempts: this.attempts() };
328
+ },
329
+ async invoke(method, params, { idempotencyKey = '', timeoutMs = 30_000 } = {}) {
330
+ const envelopeMeta = idempotencyKey ? { idempotencyKey } : {};
331
+ const failures = [];
332
+ for (const transport of ordered) {
333
+ try {
334
+ const response = await transport.invoke(method, params, { timeoutMs, meta: envelopeMeta });
335
+ // 有的传输(local)返回 response envelope;统一读取。
336
+ const ok = response?.ok === true;
337
+ record({ transport: transport.name, action: method, ok, code: response?.error?.code ?? null });
338
+ if (ok)
339
+ return { ok: true, transport: transport.name, response };
340
+ const code = String(response?.error?.code ?? '');
341
+ const fallbackable = code === ERROR_CODES.UNSUPPORTED_METHOD || code === ERROR_CODES.UNSUPPORTED_VERSION || response?.error?.retryable === true;
342
+ failures.push({ transport: transport.name, code, message: response?.error?.message ?? '' });
343
+ if (!fallbackable)
344
+ return { ok: false, transport: transport.name, response, error: response?.error ?? normalizeError(code), attempts: this.attempts() };
345
+ }
346
+ catch (error) {
347
+ const code = error?.code ?? ERROR_CODES.TRANSPORT_UNAVAILABLE;
348
+ failures.push({ transport: transport.name, code, message: String(error?.message ?? error) });
349
+ record({ transport: transport.name, action: method, ok: false, code });
350
+ try {
351
+ logger?.warn?.(`[contract] ${method} via ${transport.name} failed: ${code} ${error?.message ?? ''}`);
352
+ }
353
+ catch { /* ignore */ }
354
+ }
355
+ }
356
+ return { ok: false, code: ERROR_CODES.ALL_TRANSPORTS_FAILED, error: 'all contract transports failed', failures, attempts: this.attempts() };
357
+ },
358
+ };
359
+ }
360
+ export { CONTRACT_VERSION, LEGACY_SERVICE_KEYS, METHODS };