devinfra-bridge 0.1.0-alpha.3 → 0.1.0-alpha.4
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 +20 -0
- package/dist/daemon/auto-sync.d.ts +20 -0
- package/dist/index.js +155 -6
- package/dist/main.js +155 -6
- package/dist/util/logger.d.ts +2 -1
- package/docs/development.md +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -186,6 +186,26 @@ node scripts/smoke-artifact.mjs # 等价于 npm run smoke:artifact,先 build
|
|
|
186
186
|
|
|
187
187
|
正常启动后 stdout 输出一行就绪 JSON。固定监听 `127.0.0.1:18765`;端口占用则退出。用 SIGINT/SIGTERM 停止,最多 10 秒排空 HTTP 请求,随后清理资源。
|
|
188
188
|
|
|
189
|
+
## 周期模型同步
|
|
190
|
+
|
|
191
|
+
daemon ready 后 30 秒调用 CLI;每轮结束后等待 10 分钟,单轮超时 120 秒。固定执行 `sync --yes --non-interactive`,不带 `--force`。模型判断和配置修改全部由 CLI 当前 sync 实现负责。
|
|
192
|
+
|
|
193
|
+
每轮读取 `<state_root>/auto-sync.json`,CLI 接入/同步和软件更新维护该文件:
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"enabled": true,
|
|
198
|
+
"cli_entry": "/absolute/path/to/cli/dist/main.js",
|
|
199
|
+
"claude_config_dir": "/Users/example/.claude"
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`enabled=false` 关闭周期同步。文件不存在、`cli_entry` 缺失或目标文件不存在时,直接调用 PATH 中的 `devinfra-bridge-cli` 兜底。有效入口使用 daemon 自身 Node 启动;状态目录固定传入 `DEVINFRA_BRIDGE_STATE_ROOT`,CC 目录使用登记值。配置无效时记录错误,不执行该轮;下一轮重新读取,无需重启。CLI 缺失或执行失败不影响 Query 代理。
|
|
204
|
+
|
|
205
|
+
`daemon.log` 记录 `auto_sync_started`、`auto_sync_finished`、`auto_sync_failed`、`auto_sync_fallback`,包含耗时、退出码和固定错误码,不保存 CLI stdout/stderr。超时或 daemon 退出时结束同步子进程及其派生进程。目录变化写入后,新开的 CC/Codex 会话使用新列表。
|
|
206
|
+
|
|
207
|
+
CLI 必须支持 `sync --yes --non-interactive`;旧 CLI 会返回参数错误,日志为 `CLI_SYNC_FAILED`。`--yes` 仅自动确认当前 sync 修改;`--non-interactive` 禁止交互登录。普通手动 sync 继续要求确认。
|
|
208
|
+
|
|
189
209
|
## 首版行为
|
|
190
210
|
|
|
191
211
|
- 转发 `POST /v1/messages`(可带 `?beta=true`)和 `POST /v1/responses`(无 query 参数);共用同一转发实现,原始 body 与 JSON/SSE 字节不转换。不解析请求 JSON、不验证 UTF-8 或 model,模型及参数是否合法交给 Gateway。保留请求大小、未压缩编码、超时和并发限制。
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Logger } from "../util/logger.js";
|
|
2
|
+
export declare const AUTO_SYNC_STARTUP_MS = 30000;
|
|
3
|
+
export declare const AUTO_SYNC_INTERVAL_MS = 600000;
|
|
4
|
+
export declare const AUTO_SYNC_TIMEOUT_MS = 120000;
|
|
5
|
+
interface RunOptions {
|
|
6
|
+
signal: AbortSignal;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
killGraceMs?: number;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}
|
|
11
|
+
export declare function runAutoSync(stateRoot: string, logger: Logger, options: RunOptions): Promise<void>;
|
|
12
|
+
interface SchedulerOptions {
|
|
13
|
+
startupMs?: number;
|
|
14
|
+
intervalMs?: number;
|
|
15
|
+
run?: (signal: AbortSignal) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function startAutoSync(stateRoot: string, logger: Logger, options?: SchedulerOptions): {
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -400,6 +400,7 @@ function createLogger(stateRoot, maxBytes = 5 * 1024 * 1024) {
|
|
|
400
400
|
agent: record.agent,
|
|
401
401
|
duration_ms: record.duration_ms,
|
|
402
402
|
status: record.status,
|
|
403
|
+
exit_code: record.exit_code,
|
|
403
404
|
code: record.code
|
|
404
405
|
})}
|
|
405
406
|
`;
|
|
@@ -961,12 +962,157 @@ async function prepareStateRoot(path) {
|
|
|
961
962
|
}
|
|
962
963
|
|
|
963
964
|
// src/version.ts
|
|
964
|
-
var VERSION = true ? "0.1.0-alpha.
|
|
965
|
+
var VERSION = true ? "0.1.0-alpha.4" : "0.1.0-dev";
|
|
966
|
+
|
|
967
|
+
// src/daemon/auto-sync.ts
|
|
968
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
969
|
+
import { lstat as lstat2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
|
|
970
|
+
import { isAbsolute as isAbsolute2, join as join3 } from "node:path";
|
|
971
|
+
var AUTO_SYNC_STARTUP_MS = 3e4;
|
|
972
|
+
var AUTO_SYNC_INTERVAL_MS = 6e5;
|
|
973
|
+
var AUTO_SYNC_TIMEOUT_MS = 12e4;
|
|
974
|
+
var SYNC_ARGS = ["sync", "--yes", "--non-interactive"];
|
|
975
|
+
async function registration(stateRoot) {
|
|
976
|
+
const path = join3(stateRoot, "auto-sync.json");
|
|
977
|
+
try {
|
|
978
|
+
const info = await lstat2(path);
|
|
979
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 || process.platform !== "win32" && (info.uid !== process.getuid?.() || (info.mode & 18) !== 0)) {
|
|
980
|
+
throw new Error("Invalid auto-sync registration");
|
|
981
|
+
}
|
|
982
|
+
const value = JSON.parse(await readFile2(path, "utf8"));
|
|
983
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
984
|
+
throw new Error("Invalid registration");
|
|
985
|
+
const config = value;
|
|
986
|
+
if (config.enabled !== void 0 && typeof config.enabled !== "boolean")
|
|
987
|
+
throw new Error("Invalid enabled");
|
|
988
|
+
if (config.claude_config_dir !== void 0 && (typeof config.claude_config_dir !== "string" || !isAbsolute2(config.claude_config_dir) || config.claude_config_dir.includes("\0")))
|
|
989
|
+
throw new Error("Invalid directory");
|
|
990
|
+
return config;
|
|
991
|
+
} catch (error) {
|
|
992
|
+
if (error.code === "ENOENT") return {};
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
function terminate(child, force) {
|
|
997
|
+
if (!child.pid) return;
|
|
998
|
+
if (process.platform === "win32") {
|
|
999
|
+
const killer = spawn2("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
1000
|
+
shell: false,
|
|
1001
|
+
windowsHide: true,
|
|
1002
|
+
stdio: "ignore"
|
|
1003
|
+
});
|
|
1004
|
+
killer.on("error", () => child.kill("SIGKILL"));
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
1009
|
+
} catch {
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async function runAutoSync(stateRoot, logger, options) {
|
|
1013
|
+
const started = Date.now();
|
|
1014
|
+
let config;
|
|
1015
|
+
try {
|
|
1016
|
+
config = await registration(stateRoot);
|
|
1017
|
+
} catch {
|
|
1018
|
+
logger.write({ event: "auto_sync_failed", code: "AUTO_SYNC_CONFIG_INVALID" });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (config.enabled === false || options.signal.aborted) return;
|
|
1022
|
+
let useEntry = false;
|
|
1023
|
+
if (typeof config.cli_entry === "string" && isAbsolute2(config.cli_entry) && !config.cli_entry.includes("\0")) {
|
|
1024
|
+
useEntry = await stat2(config.cli_entry).then(
|
|
1025
|
+
(info) => info.isFile(),
|
|
1026
|
+
() => false
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
if (options.signal.aborted) return;
|
|
1030
|
+
if (!useEntry) logger.write({ event: "auto_sync_fallback", code: "CLI_ENTRY_UNAVAILABLE" });
|
|
1031
|
+
const env = {
|
|
1032
|
+
...options.env ?? process.env,
|
|
1033
|
+
DEVINFRA_BRIDGE_STATE_ROOT: stateRoot
|
|
1034
|
+
};
|
|
1035
|
+
if (config.claude_config_dir) env.CLAUDE_CONFIG_DIR = config.claude_config_dir;
|
|
1036
|
+
const command = useEntry ? process.execPath : "devinfra-bridge-cli";
|
|
1037
|
+
const args = useEntry ? [config.cli_entry, ...SYNC_ARGS] : SYNC_ARGS;
|
|
1038
|
+
logger.write({ event: "auto_sync_started" });
|
|
1039
|
+
await new Promise((resolve) => {
|
|
1040
|
+
const child = spawn2(command, args, {
|
|
1041
|
+
shell: false,
|
|
1042
|
+
windowsHide: true,
|
|
1043
|
+
detached: process.platform !== "win32",
|
|
1044
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1045
|
+
env
|
|
1046
|
+
});
|
|
1047
|
+
child.stdout.resume();
|
|
1048
|
+
child.stderr.resume();
|
|
1049
|
+
let reason;
|
|
1050
|
+
let killTimer;
|
|
1051
|
+
const stop = (code) => {
|
|
1052
|
+
if (reason) return;
|
|
1053
|
+
reason = code;
|
|
1054
|
+
terminate(child, false);
|
|
1055
|
+
killTimer = setTimeout(() => terminate(child, true), options.killGraceMs ?? 1e3);
|
|
1056
|
+
};
|
|
1057
|
+
const onAbort = () => stop("AUTO_SYNC_CANCELLED");
|
|
1058
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
1059
|
+
const timer = setTimeout(
|
|
1060
|
+
() => stop("AUTO_SYNC_TIMEOUT"),
|
|
1061
|
+
options.timeoutMs ?? AUTO_SYNC_TIMEOUT_MS
|
|
1062
|
+
);
|
|
1063
|
+
child.on("error", () => {
|
|
1064
|
+
reason ??= "CLI_UNAVAILABLE";
|
|
1065
|
+
});
|
|
1066
|
+
child.once("close", (exitCode) => {
|
|
1067
|
+
clearTimeout(timer);
|
|
1068
|
+
if (reason && killTimer) terminate(child, true);
|
|
1069
|
+
clearTimeout(killTimer);
|
|
1070
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
1071
|
+
logger.write({
|
|
1072
|
+
event: reason || exitCode !== 0 ? "auto_sync_failed" : "auto_sync_finished",
|
|
1073
|
+
code: reason ?? (exitCode === 0 ? "AUTO_SYNC_SUCCEEDED" : "CLI_SYNC_FAILED"),
|
|
1074
|
+
duration_ms: Date.now() - started,
|
|
1075
|
+
exit_code: exitCode ?? void 0
|
|
1076
|
+
});
|
|
1077
|
+
resolve();
|
|
1078
|
+
});
|
|
1079
|
+
if (options.signal.aborted) onAbort();
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
function startAutoSync(stateRoot, logger, options = {}) {
|
|
1083
|
+
const controller = new AbortController();
|
|
1084
|
+
let timer;
|
|
1085
|
+
let running = Promise.resolve();
|
|
1086
|
+
const schedule = (delay) => {
|
|
1087
|
+
timer = setTimeout(() => {
|
|
1088
|
+
if (controller.signal.aborted) return;
|
|
1089
|
+
running = (async () => {
|
|
1090
|
+
try {
|
|
1091
|
+
if (options.run) await options.run(controller.signal);
|
|
1092
|
+
else await runAutoSync(stateRoot, logger, { signal: controller.signal });
|
|
1093
|
+
} catch {
|
|
1094
|
+
logger.write({ event: "auto_sync_failed", code: "AUTO_SYNC_FAILED" });
|
|
1095
|
+
} finally {
|
|
1096
|
+
if (!controller.signal.aborted) schedule(options.intervalMs ?? AUTO_SYNC_INTERVAL_MS);
|
|
1097
|
+
}
|
|
1098
|
+
})();
|
|
1099
|
+
}, delay);
|
|
1100
|
+
timer.unref();
|
|
1101
|
+
};
|
|
1102
|
+
schedule(options.startupMs ?? AUTO_SYNC_STARTUP_MS);
|
|
1103
|
+
return {
|
|
1104
|
+
close() {
|
|
1105
|
+
clearTimeout(timer);
|
|
1106
|
+
controller.abort();
|
|
1107
|
+
return running;
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
965
1111
|
|
|
966
1112
|
// src/daemon/single-instance.ts
|
|
967
1113
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
968
|
-
import { readFile as
|
|
969
|
-
import { join as
|
|
1114
|
+
import { readFile as readFile3, rename, unlink, writeFile } from "node:fs/promises";
|
|
1115
|
+
import { join as join4 } from "node:path";
|
|
970
1116
|
async function bindSingleInstance(server, port = PORT) {
|
|
971
1117
|
await new Promise((resolve, reject) => {
|
|
972
1118
|
const onError = () => reject(new Error(`Cannot bind ${HOST}:${port}; port is occupied or unavailable.`));
|
|
@@ -979,8 +1125,8 @@ async function bindSingleInstance(server, port = PORT) {
|
|
|
979
1125
|
return server.address().port;
|
|
980
1126
|
}
|
|
981
1127
|
async function writeInstance(stateRoot, id, version, port = PORT) {
|
|
982
|
-
const path =
|
|
983
|
-
const temp =
|
|
1128
|
+
const path = join4(stateRoot, "instance.json");
|
|
1129
|
+
const temp = join4(stateRoot, `.instance-${randomUUID2()}.tmp`);
|
|
984
1130
|
try {
|
|
985
1131
|
await writeFile(
|
|
986
1132
|
temp,
|
|
@@ -995,7 +1141,7 @@ async function writeInstance(stateRoot, id, version, port = PORT) {
|
|
|
995
1141
|
}
|
|
996
1142
|
return async () => {
|
|
997
1143
|
try {
|
|
998
|
-
if (JSON.parse(await
|
|
1144
|
+
if (JSON.parse(await readFile3(path, "utf8")).instance_id === id) await unlink(path);
|
|
999
1145
|
} catch {
|
|
1000
1146
|
}
|
|
1001
1147
|
};
|
|
@@ -1028,6 +1174,7 @@ async function startDaemon(config, { port: requestedPort = PORT } = {}) {
|
|
|
1028
1174
|
await provider.close();
|
|
1029
1175
|
throw error;
|
|
1030
1176
|
}
|
|
1177
|
+
const autoSync = startAutoSync(stateRoot, logger);
|
|
1031
1178
|
let closing;
|
|
1032
1179
|
const onSignal = () => {
|
|
1033
1180
|
void close().catch(() => logger.write({ event: "shutdown_error" }));
|
|
@@ -1035,6 +1182,7 @@ async function startDaemon(config, { port: requestedPort = PORT } = {}) {
|
|
|
1035
1182
|
const close = ({ drainMs = 1e4 } = {}) => {
|
|
1036
1183
|
if (closing) return closing;
|
|
1037
1184
|
context.closing = true;
|
|
1185
|
+
const syncStopped = autoSync.close();
|
|
1038
1186
|
process.off("SIGINT", onSignal);
|
|
1039
1187
|
process.off("SIGTERM", onSignal);
|
|
1040
1188
|
closing = (async () => {
|
|
@@ -1054,6 +1202,7 @@ async function startDaemon(config, { port: requestedPort = PORT } = {}) {
|
|
|
1054
1202
|
for (const controller of context.active) controller.abort();
|
|
1055
1203
|
context.httpsAgent.destroy();
|
|
1056
1204
|
await provider.close();
|
|
1205
|
+
await syncStopped;
|
|
1057
1206
|
await cleanup?.();
|
|
1058
1207
|
logger.write({ event: "stopped" });
|
|
1059
1208
|
}
|
package/dist/main.js
CHANGED
|
@@ -399,6 +399,7 @@ function createLogger(stateRoot, maxBytes = 5 * 1024 * 1024) {
|
|
|
399
399
|
agent: record.agent,
|
|
400
400
|
duration_ms: record.duration_ms,
|
|
401
401
|
status: record.status,
|
|
402
|
+
exit_code: record.exit_code,
|
|
402
403
|
code: record.code
|
|
403
404
|
})}
|
|
404
405
|
`;
|
|
@@ -960,12 +961,157 @@ async function prepareStateRoot(path) {
|
|
|
960
961
|
}
|
|
961
962
|
|
|
962
963
|
// src/version.ts
|
|
963
|
-
var VERSION = true ? "0.1.0-alpha.
|
|
964
|
+
var VERSION = true ? "0.1.0-alpha.4" : "0.1.0-dev";
|
|
965
|
+
|
|
966
|
+
// src/daemon/auto-sync.ts
|
|
967
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
968
|
+
import { lstat as lstat2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
|
|
969
|
+
import { isAbsolute as isAbsolute2, join as join3 } from "node:path";
|
|
970
|
+
var AUTO_SYNC_STARTUP_MS = 3e4;
|
|
971
|
+
var AUTO_SYNC_INTERVAL_MS = 6e5;
|
|
972
|
+
var AUTO_SYNC_TIMEOUT_MS = 12e4;
|
|
973
|
+
var SYNC_ARGS = ["sync", "--yes", "--non-interactive"];
|
|
974
|
+
async function registration(stateRoot) {
|
|
975
|
+
const path = join3(stateRoot, "auto-sync.json");
|
|
976
|
+
try {
|
|
977
|
+
const info = await lstat2(path);
|
|
978
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 || process.platform !== "win32" && (info.uid !== process.getuid?.() || (info.mode & 18) !== 0)) {
|
|
979
|
+
throw new Error("Invalid auto-sync registration");
|
|
980
|
+
}
|
|
981
|
+
const value = JSON.parse(await readFile2(path, "utf8"));
|
|
982
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
983
|
+
throw new Error("Invalid registration");
|
|
984
|
+
const config = value;
|
|
985
|
+
if (config.enabled !== void 0 && typeof config.enabled !== "boolean")
|
|
986
|
+
throw new Error("Invalid enabled");
|
|
987
|
+
if (config.claude_config_dir !== void 0 && (typeof config.claude_config_dir !== "string" || !isAbsolute2(config.claude_config_dir) || config.claude_config_dir.includes("\0")))
|
|
988
|
+
throw new Error("Invalid directory");
|
|
989
|
+
return config;
|
|
990
|
+
} catch (error) {
|
|
991
|
+
if (error.code === "ENOENT") return {};
|
|
992
|
+
throw error;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
function terminate(child, force) {
|
|
996
|
+
if (!child.pid) return;
|
|
997
|
+
if (process.platform === "win32") {
|
|
998
|
+
const killer = spawn2("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
999
|
+
shell: false,
|
|
1000
|
+
windowsHide: true,
|
|
1001
|
+
stdio: "ignore"
|
|
1002
|
+
});
|
|
1003
|
+
killer.on("error", () => child.kill("SIGKILL"));
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
try {
|
|
1007
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
1008
|
+
} catch {
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
async function runAutoSync(stateRoot, logger, options) {
|
|
1012
|
+
const started = Date.now();
|
|
1013
|
+
let config;
|
|
1014
|
+
try {
|
|
1015
|
+
config = await registration(stateRoot);
|
|
1016
|
+
} catch {
|
|
1017
|
+
logger.write({ event: "auto_sync_failed", code: "AUTO_SYNC_CONFIG_INVALID" });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (config.enabled === false || options.signal.aborted) return;
|
|
1021
|
+
let useEntry = false;
|
|
1022
|
+
if (typeof config.cli_entry === "string" && isAbsolute2(config.cli_entry) && !config.cli_entry.includes("\0")) {
|
|
1023
|
+
useEntry = await stat2(config.cli_entry).then(
|
|
1024
|
+
(info) => info.isFile(),
|
|
1025
|
+
() => false
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
if (options.signal.aborted) return;
|
|
1029
|
+
if (!useEntry) logger.write({ event: "auto_sync_fallback", code: "CLI_ENTRY_UNAVAILABLE" });
|
|
1030
|
+
const env = {
|
|
1031
|
+
...options.env ?? process.env,
|
|
1032
|
+
DEVINFRA_BRIDGE_STATE_ROOT: stateRoot
|
|
1033
|
+
};
|
|
1034
|
+
if (config.claude_config_dir) env.CLAUDE_CONFIG_DIR = config.claude_config_dir;
|
|
1035
|
+
const command = useEntry ? process.execPath : "devinfra-bridge-cli";
|
|
1036
|
+
const args = useEntry ? [config.cli_entry, ...SYNC_ARGS] : SYNC_ARGS;
|
|
1037
|
+
logger.write({ event: "auto_sync_started" });
|
|
1038
|
+
await new Promise((resolve) => {
|
|
1039
|
+
const child = spawn2(command, args, {
|
|
1040
|
+
shell: false,
|
|
1041
|
+
windowsHide: true,
|
|
1042
|
+
detached: process.platform !== "win32",
|
|
1043
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1044
|
+
env
|
|
1045
|
+
});
|
|
1046
|
+
child.stdout.resume();
|
|
1047
|
+
child.stderr.resume();
|
|
1048
|
+
let reason;
|
|
1049
|
+
let killTimer;
|
|
1050
|
+
const stop = (code) => {
|
|
1051
|
+
if (reason) return;
|
|
1052
|
+
reason = code;
|
|
1053
|
+
terminate(child, false);
|
|
1054
|
+
killTimer = setTimeout(() => terminate(child, true), options.killGraceMs ?? 1e3);
|
|
1055
|
+
};
|
|
1056
|
+
const onAbort = () => stop("AUTO_SYNC_CANCELLED");
|
|
1057
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
1058
|
+
const timer = setTimeout(
|
|
1059
|
+
() => stop("AUTO_SYNC_TIMEOUT"),
|
|
1060
|
+
options.timeoutMs ?? AUTO_SYNC_TIMEOUT_MS
|
|
1061
|
+
);
|
|
1062
|
+
child.on("error", () => {
|
|
1063
|
+
reason ??= "CLI_UNAVAILABLE";
|
|
1064
|
+
});
|
|
1065
|
+
child.once("close", (exitCode) => {
|
|
1066
|
+
clearTimeout(timer);
|
|
1067
|
+
if (reason && killTimer) terminate(child, true);
|
|
1068
|
+
clearTimeout(killTimer);
|
|
1069
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
1070
|
+
logger.write({
|
|
1071
|
+
event: reason || exitCode !== 0 ? "auto_sync_failed" : "auto_sync_finished",
|
|
1072
|
+
code: reason ?? (exitCode === 0 ? "AUTO_SYNC_SUCCEEDED" : "CLI_SYNC_FAILED"),
|
|
1073
|
+
duration_ms: Date.now() - started,
|
|
1074
|
+
exit_code: exitCode ?? void 0
|
|
1075
|
+
});
|
|
1076
|
+
resolve();
|
|
1077
|
+
});
|
|
1078
|
+
if (options.signal.aborted) onAbort();
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
function startAutoSync(stateRoot, logger, options = {}) {
|
|
1082
|
+
const controller = new AbortController();
|
|
1083
|
+
let timer;
|
|
1084
|
+
let running = Promise.resolve();
|
|
1085
|
+
const schedule = (delay) => {
|
|
1086
|
+
timer = setTimeout(() => {
|
|
1087
|
+
if (controller.signal.aborted) return;
|
|
1088
|
+
running = (async () => {
|
|
1089
|
+
try {
|
|
1090
|
+
if (options.run) await options.run(controller.signal);
|
|
1091
|
+
else await runAutoSync(stateRoot, logger, { signal: controller.signal });
|
|
1092
|
+
} catch {
|
|
1093
|
+
logger.write({ event: "auto_sync_failed", code: "AUTO_SYNC_FAILED" });
|
|
1094
|
+
} finally {
|
|
1095
|
+
if (!controller.signal.aborted) schedule(options.intervalMs ?? AUTO_SYNC_INTERVAL_MS);
|
|
1096
|
+
}
|
|
1097
|
+
})();
|
|
1098
|
+
}, delay);
|
|
1099
|
+
timer.unref();
|
|
1100
|
+
};
|
|
1101
|
+
schedule(options.startupMs ?? AUTO_SYNC_STARTUP_MS);
|
|
1102
|
+
return {
|
|
1103
|
+
close() {
|
|
1104
|
+
clearTimeout(timer);
|
|
1105
|
+
controller.abort();
|
|
1106
|
+
return running;
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
964
1110
|
|
|
965
1111
|
// src/daemon/single-instance.ts
|
|
966
1112
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
967
|
-
import { readFile as
|
|
968
|
-
import { join as
|
|
1113
|
+
import { readFile as readFile3, rename, unlink, writeFile } from "node:fs/promises";
|
|
1114
|
+
import { join as join4 } from "node:path";
|
|
969
1115
|
async function bindSingleInstance(server, port = PORT) {
|
|
970
1116
|
await new Promise((resolve, reject) => {
|
|
971
1117
|
const onError = () => reject(new Error(`Cannot bind ${HOST}:${port}; port is occupied or unavailable.`));
|
|
@@ -978,8 +1124,8 @@ async function bindSingleInstance(server, port = PORT) {
|
|
|
978
1124
|
return server.address().port;
|
|
979
1125
|
}
|
|
980
1126
|
async function writeInstance(stateRoot, id, version, port = PORT) {
|
|
981
|
-
const path =
|
|
982
|
-
const temp =
|
|
1127
|
+
const path = join4(stateRoot, "instance.json");
|
|
1128
|
+
const temp = join4(stateRoot, `.instance-${randomUUID2()}.tmp`);
|
|
983
1129
|
try {
|
|
984
1130
|
await writeFile(
|
|
985
1131
|
temp,
|
|
@@ -994,7 +1140,7 @@ async function writeInstance(stateRoot, id, version, port = PORT) {
|
|
|
994
1140
|
}
|
|
995
1141
|
return async () => {
|
|
996
1142
|
try {
|
|
997
|
-
if (JSON.parse(await
|
|
1143
|
+
if (JSON.parse(await readFile3(path, "utf8")).instance_id === id) await unlink(path);
|
|
998
1144
|
} catch {
|
|
999
1145
|
}
|
|
1000
1146
|
};
|
|
@@ -1027,6 +1173,7 @@ async function startDaemon(config, { port: requestedPort = PORT } = {}) {
|
|
|
1027
1173
|
await provider.close();
|
|
1028
1174
|
throw error;
|
|
1029
1175
|
}
|
|
1176
|
+
const autoSync = startAutoSync(stateRoot, logger);
|
|
1030
1177
|
let closing;
|
|
1031
1178
|
const onSignal = () => {
|
|
1032
1179
|
void close().catch(() => logger.write({ event: "shutdown_error" }));
|
|
@@ -1034,6 +1181,7 @@ async function startDaemon(config, { port: requestedPort = PORT } = {}) {
|
|
|
1034
1181
|
const close = ({ drainMs = 1e4 } = {}) => {
|
|
1035
1182
|
if (closing) return closing;
|
|
1036
1183
|
context.closing = true;
|
|
1184
|
+
const syncStopped = autoSync.close();
|
|
1037
1185
|
process.off("SIGINT", onSignal);
|
|
1038
1186
|
process.off("SIGTERM", onSignal);
|
|
1039
1187
|
closing = (async () => {
|
|
@@ -1053,6 +1201,7 @@ async function startDaemon(config, { port: requestedPort = PORT } = {}) {
|
|
|
1053
1201
|
for (const controller of context.active) controller.abort();
|
|
1054
1202
|
context.httpsAgent.destroy();
|
|
1055
1203
|
await provider.close();
|
|
1204
|
+
await syncStopped;
|
|
1056
1205
|
await cleanup?.();
|
|
1057
1206
|
logger.write({ event: "stopped" });
|
|
1058
1207
|
}
|
package/dist/util/logger.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentIdentity } from "../agents/identity.js";
|
|
2
2
|
import type { ModelProtocol } from "../agents/types.js";
|
|
3
3
|
export interface LogRecord {
|
|
4
|
-
event: "request" | "ready" | "stopped" | "shutdown_error" | "jwt_refresh_started" | "jwt_refresh_succeeded" | "jwt_refresh_failed" | "query_retry";
|
|
4
|
+
event: "request" | "ready" | "stopped" | "shutdown_error" | "jwt_refresh_started" | "jwt_refresh_succeeded" | "jwt_refresh_failed" | "query_retry" | "auto_sync_started" | "auto_sync_finished" | "auto_sync_failed" | "auto_sync_fallback";
|
|
5
5
|
request_id?: string;
|
|
6
6
|
gateway_logid?: string;
|
|
7
7
|
gateway_request_id?: string;
|
|
@@ -11,6 +11,7 @@ export interface LogRecord {
|
|
|
11
11
|
agent?: AgentIdentity;
|
|
12
12
|
duration_ms?: number;
|
|
13
13
|
status?: number;
|
|
14
|
+
exit_code?: number;
|
|
14
15
|
code?: string;
|
|
15
16
|
}
|
|
16
17
|
export interface Logger {
|
package/docs/development.md
CHANGED
|
@@ -133,3 +133,9 @@ npm run publish:dry # 全量验证、列出包内容,不上传
|
|
|
133
133
|
alpha/beta/rc 版本对应同名 dist-tag,其他预发布版本使用 next,正式版本使用 latest。本期先发布 alpha 包给 CLI 项目联调,真实 CC + Gateway 未验收完成前不作为正式稳定版。
|
|
134
134
|
|
|
135
135
|
程序化调用 `startDaemon(config, { port: 0 })` 可使用临时端口;返回值 `handle.port` 和 `instance.json` 记录实际监听端口。该选项用于测试隔离,配置文件与 `serve --config` 命令仍固定使用 `127.0.0.1:18765`。artifact smoke 用仅依赖 Node 内置模块的子进程启动解包后的 daemon API,另行验证打包 CLI 的版本。
|
|
136
|
+
|
|
137
|
+
## 周期调用 CLI
|
|
138
|
+
|
|
139
|
+
`src/daemon/auto-sync.ts` 每轮读取 `state_root/auto-sync.json`。ready 后 30 秒首次运行,结束后等待 600 秒,单轮最多 120 秒。有效绝对 `cli_entry` 由 `process.execPath` 启动,否则使用 PATH 命令 `devinfra-bridge-cli`;固定参数 `sync --yes --non-interactive`。不复制模型目录查询或客户端配置逻辑。
|
|
140
|
+
|
|
141
|
+
CLI 原子维护 enabled、cli_entry、claude_config_dir。关闭开关时不调用;文件缺失时走命令兜底,损坏配置时记录错误。状态目录通过 DEVINFRA_BRIDGE_STATE_ROOT 传入,继承认证和网络环境。每轮最多一个子进程,超时/退出按进程树取消;日志只记录固定错误码、耗时、退出码。macOS/Linux 使用独立进程组取消,Windows 使用 taskkill /T /F;实际 CLI 平台验收仍按 CLI 项目范围。
|