xuym-codex-collab 0.4.4 → 0.6.0
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 +23 -2
- package/apps/onboarding/bundle.mjs +1 -6
- package/apps/onboarding/commands.mjs +46 -0
- package/apps/onboarding/guided.mjs +3 -2
- package/apps/onboarding/main.mjs +72 -19
- package/apps/onboarding/manager.mjs +116 -55
- package/apps/onboarding/reset.mjs +3 -1
- package/apps/onboarding/server.mjs +1 -1
- package/apps/onboarding/startup.mjs +61 -0
- package/apps/onboarding/supervisor.mjs +41 -0
- package/apps/onboarding/web/app.js +78 -13
- package/apps/onboarding/web/index.html +17 -17
- package/apps/onboarding/web/style.css +3 -0
- package/apps/onboarding/workspace.mjs +36 -0
- package/apps/peer/desktop.mjs +4 -3
- package/apps/peer/intent.mjs +19 -0
- package/apps/peer/mcp.mjs +1 -1
- package/apps/peer/process.mjs +13 -0
- package/apps/peer/runner.mjs +4 -3
- package/apps/peer/runtime.mjs +6 -1
- package/apps/peer/wecom.mjs +1 -1
- package/bin/codex-collab.mjs +21 -9
- package/docs/desktop-poll.md +2 -2
- package/docs/onboarding-audit.md +12 -2
- package/docs/onboarding.md +37 -9
- package/docs/peer.md +4 -2
- package/docs/releases/0.5.0.md +14 -0
- package/docs/team-registry.md +7 -1
- package/docs/terminal-onboarding.md +3 -3
- package/package.json +1 -1
- package/packages/codex-kit/templates/.agents/skills/team-collab/SKILL.md +2 -2
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +2 -2
- package/packages/peer/config.mjs +2 -0
- package/packages/peer/git.mjs +14 -0
- package/packages/peer/protocol.mjs +4 -2
- package/packages/peer/repository.mjs +67 -0
- package/packages/peer/service.mjs +11 -8
- package/packages/peer/store.mjs +16 -16
- package/schemas/peer-task.schema.json +15 -13
- package/schemas/team-policy.schema.json +12 -1
- package/scripts/peer/cli.mjs +14 -8
- package/scripts/peer/init-agent.mjs +6 -15
- package/scripts/peer/onboarding.mjs +26 -20
- package/scripts/peer/refresh-project.mjs +23 -15
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { inspectRepository, RepositoryError, resolveRemote, teamBaseBranch } from '../../packages/peer/repository.mjs';
|
|
1
2
|
import { defaultReviewRules } from '../../packages/peer/review.mjs';
|
|
2
3
|
import { SetupError } from './errors.mjs';
|
|
3
4
|
import { createPublicKey, randomBytes, randomUUID } from 'node:crypto';
|
|
4
5
|
import { chmod, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from 'node:fs/promises';
|
|
5
|
-
import { homedir } from 'node:os';
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { readRunnerIntent, writeRunnerIntent } from '../peer/intent.mjs';
|
|
10
|
+
import { localDefaults, workspaceHome } from './workspace.mjs';
|
|
11
|
+
import { startupStatus } from './startup.mjs';
|
|
8
12
|
import { fileURLToPath } from 'node:url';
|
|
9
13
|
import { canonical, digest, generateIdentity, readPrivateJson, validateTeam } from '../../packages/peer/config.mjs';
|
|
10
14
|
import { PeerGitStore, git } from '../../packages/peer/store.mjs';
|
|
@@ -50,9 +54,8 @@ function gitFailure(error) {
|
|
|
50
54
|
if (/could not resolve host|failed to connect|network is unreachable|connection refused|network failure/.test(detail)) {
|
|
51
55
|
return new SetupError('无法连接远程仓库,请检查网络或代理后重试。');
|
|
52
56
|
}
|
|
53
|
-
return new SetupError('Git
|
|
57
|
+
return new SetupError('Git 无法读取远程仓库,请检查所选远程地址、Git 权限和网络后重试。');
|
|
54
58
|
}
|
|
55
|
-
function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; }
|
|
56
59
|
function mcpSection(text, serverName) {
|
|
57
60
|
let active = false; const lines = [];
|
|
58
61
|
for (const line of String(text || '').split(/\r?\n/)) {
|
|
@@ -71,7 +74,7 @@ function projectMcpConfigured(projectConfig, configPath, config) {
|
|
|
71
74
|
const section = mcpSection(projectConfig, `team-collab-${config.agent_id}`);
|
|
72
75
|
return !/^\s*enabled\s*=\s*false\s*(?:#.*)?$/m.test(section) && paths.some((path) => section.includes(JSON.stringify(path)));
|
|
73
76
|
}
|
|
74
|
-
async function ensureRepo(path) {
|
|
77
|
+
async function ensureRepo(path, options = {}) {
|
|
75
78
|
if (typeof path !== 'string' || !isAbsolute(path)) throw new SetupError('请选择业务仓库的本机绝对路径。');
|
|
76
79
|
let repo;
|
|
77
80
|
try { repo = await realpath(path); } catch { throw new SetupError('请选择存在的业务仓库目录。'); }
|
|
@@ -81,25 +84,10 @@ async function ensureRepo(path) {
|
|
|
81
84
|
if (error instanceof SetupError) throw error;
|
|
82
85
|
throw new SetupError('请选择 Git 仓库根目录。');
|
|
83
86
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
catch (error) {
|
|
87
|
-
const detail = `${error?.stderr || ''}\n${error?.message || ''}`.toLowerCase();
|
|
88
|
-
if (/no such remote|does not appear to be a git repository/.test(detail)) throw new SetupError('该目录没有名为 origin 的远程仓库,请先配置 GitHub origin。');
|
|
89
|
-
throw gitFailure(error);
|
|
90
|
-
}
|
|
91
|
-
let main;
|
|
92
|
-
try { main = await git(repo, 'ls-remote', '--heads', 'origin', 'refs/heads/main'); }
|
|
93
|
-
catch (error) { throw gitFailure(error); }
|
|
94
|
-
if (!main) {
|
|
95
|
-
let heads;
|
|
96
|
-
try { heads = await git(repo, 'ls-remote', '--heads', 'origin'); }
|
|
97
|
-
catch (error) { throw gitFailure(error); }
|
|
98
|
-
if (!heads) throw new SetupError(`远程仓库为空,未找到 origin/main。请先在终端执行:\ngit -C ${shellQuote(repo)} push -u origin main`);
|
|
99
|
-
throw new SetupError(`远程仓库可访问,但没有 main 分支。请先将本地 main 推送到 origin:\ngit -C ${shellQuote(repo)} push -u origin main`);
|
|
100
|
-
}
|
|
101
|
-
return { repo, remote };
|
|
87
|
+
try { return { repo, ...await (options.remoteOnly ? resolveRemote(repo, options) : inspectRepository(repo, options)) }; }
|
|
88
|
+
catch (error) { if (error instanceof RepositoryError) throw new SetupError(error.message); throw gitFailure(error); }
|
|
102
89
|
}
|
|
90
|
+
|
|
103
91
|
function validateBundle(value) {
|
|
104
92
|
if (!value || value.schema_version !== 1 || value.kind !== 'codex-collab-invite' || typeof value.remote !== 'string' || !value.remote || !value.history || !value.invitation || !value.wecom || typeof value.wecom.secret !== 'string' || !value.wecom.secret) throw new SetupError('接入包格式无效。');
|
|
105
93
|
const history = validateHistory(value.history, value.root_hash);
|
|
@@ -111,12 +99,13 @@ function validateBundle(value) {
|
|
|
111
99
|
}
|
|
112
100
|
|
|
113
101
|
export class SetupManager {
|
|
114
|
-
constructor({ home =
|
|
102
|
+
constructor({ home = workspaceHome(), discovery = new BotDiscovery(), runCli } = {}) {
|
|
115
103
|
this.home = resolve(home); this.discovery = discovery; this.queue = Promise.resolve(); this.resets = new ResetActions(this);
|
|
116
|
-
this.runCli = runCli || ((command, config) => {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
104
|
+
this.runCli = runCli || (async (command, config) => {
|
|
105
|
+
try {
|
|
106
|
+
const result = await promisify(execFile)(process.execPath, [join(packageRoot, 'scripts/peer/cli.mjs'), command, config], { timeout: 35000, maxBuffer: 200000 });
|
|
107
|
+
return result.stdout.trim();
|
|
108
|
+
} catch { throw new Error(`本机 ${command} 操作未完成,请检查项目 Git 权限或 Runner 日志。`); }
|
|
120
109
|
});
|
|
121
110
|
}
|
|
122
111
|
async exclusive(fn) { const action = this.queue.then(fn); this.queue = action.catch(() => {}); return action; }
|
|
@@ -126,7 +115,7 @@ export class SetupManager {
|
|
|
126
115
|
async profileStore(state, { enrollmentOnly = false } = {}) {
|
|
127
116
|
const identity = await readPrivateJson(state.identity_path);
|
|
128
117
|
const initial = state.bootstrap_team;
|
|
129
|
-
return new PeerGitStore({ repo: state.repo, dataDir: state.data_dir || join(this.path(state.id), 'data'), team: initial,
|
|
118
|
+
return new PeerGitStore({ remoteName: state.remote_name, remoteUrl: state.remote, repo: state.repo, dataDir: state.data_dir || join(this.path(state.id), 'data'), team: initial,
|
|
130
119
|
agentId: state.agent_id, privateKey: identity.private_key, registryRoot: state.root_hash, enrollmentOnly }).init();
|
|
131
120
|
}
|
|
132
121
|
async overview() {
|
|
@@ -143,7 +132,8 @@ export class SetupManager {
|
|
|
143
132
|
const draft = await readPrivateJson(join(this.home, 'drafts', file));
|
|
144
133
|
if (!draft.profile_id) drafts.push({ id: draft.id, agent_id: draft.input.agent_id, display_name: draft.input.display_name, repo: draft.input.repo });
|
|
145
134
|
}
|
|
146
|
-
|
|
135
|
+
let context = {}; try { context = await readPrivateJson(join(this.home, 'context.json')); } catch {}
|
|
136
|
+
return { profiles, removed_profiles, drafts, defaults: await localDefaults(context.cwd || this.home), startup: await startupStatus(this.home) };
|
|
147
137
|
}
|
|
148
138
|
async checkBotAvailable(botId) {
|
|
149
139
|
for (const profile of (await this.overview()).profiles) {
|
|
@@ -162,7 +152,7 @@ export class SetupManager {
|
|
|
162
152
|
}
|
|
163
153
|
await this.checkBotAvailable(credentials.bot_id);
|
|
164
154
|
if (input.repo) {
|
|
165
|
-
const { repo } = await ensureRepo(input.repo);
|
|
155
|
+
const { repo } = await ensureRepo(input.repo, { remoteName: input.remote_name, expectedRemote: input.remote_url, remoteOnly: true });
|
|
166
156
|
const toml = await readFile(join(repo, '.codex/config.toml'), 'utf8').catch(() => '');
|
|
167
157
|
for (const match of toml.matchAll(/"((?:[^"\\]|\\.)*\.json)"/g)) {
|
|
168
158
|
try {
|
|
@@ -178,8 +168,8 @@ export class SetupManager {
|
|
|
178
168
|
}
|
|
179
169
|
async prepareProject(input) {
|
|
180
170
|
const path = input.repo_mode === 'clone'
|
|
181
|
-
? await cloneRepository({ remote: input.remote, destination: input.destination, privateHome: this.home }) : input.repo;
|
|
182
|
-
const selected = await ensureRepo(path);
|
|
171
|
+
? await cloneRepository({ remote: input.remote, destination: input.destination || suggestedRepo(input.remote, memberFields({ ...input, display_name: input.display_name || input.agent_id || 'owner' }).agent_id), privateHome: this.home }) : input.repo;
|
|
172
|
+
const selected = await ensureRepo(path, { remoteName: input.remote_name, baseBranch: input.base_branch, allowSelection: true });
|
|
183
173
|
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
184
174
|
return { ...selected, tests: await recommendTests(selected.repo) };
|
|
185
175
|
}
|
|
@@ -191,7 +181,8 @@ export class SetupManager {
|
|
|
191
181
|
const argv = input.test_command ? commandArgv(input.test_command) : testArgv(input.test_argv);
|
|
192
182
|
const selected = await this.prepareProject(input);
|
|
193
183
|
for (const profile of (await this.overview()).profiles) if (profile.repo === selected.repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
194
|
-
|
|
184
|
+
if (selected.selection_required) throw new SetupError('请先检查项目并选择协作远程和业务基准分支。');
|
|
185
|
+
const draft = { id: randomUUID(), input: { ...fields, repo: selected.repo, remote_name: selected.remote_name, remote_url: selected.remote, base_branch: selected.base_branch, test_argv: argv, team_name: input.team_name?.trim() || null,
|
|
195
186
|
bot_id: input.bot_id, secret: input.secret } };
|
|
196
187
|
await savePrivate(this.draftPath(draft.id), draft);
|
|
197
188
|
return this.adminDraft({ id: draft.id });
|
|
@@ -202,7 +193,7 @@ export class SetupManager {
|
|
|
202
193
|
let session;
|
|
203
194
|
if (draft.discovery_id && !reconnect) { try { session = this.discovery.view(draft.discovery_id); if (['failed', 'expired', 'disconnected'].includes(session.status)) session = null; } catch {} }
|
|
204
195
|
if (!session) {
|
|
205
|
-
session = await this.beginDiscovery({ bot_id: draft.input.bot_id, secret: draft.input.secret, repo: draft.input.repo });
|
|
196
|
+
session = await this.beginDiscovery({ bot_id: draft.input.bot_id, secret: draft.input.secret, repo: draft.input.repo, remote_name: draft.input.remote_name, remote_url: draft.input.remote_url });
|
|
206
197
|
draft.discovery_id = session.id; await savePrivate(this.draftPath(id), draft);
|
|
207
198
|
}
|
|
208
199
|
return { ...session, draft_id: id, agent_id: draft.input.agent_id, repo: draft.input.repo };
|
|
@@ -223,18 +214,18 @@ export class SetupManager {
|
|
|
223
214
|
async createAdmin(input, { onSaved } = {}) {
|
|
224
215
|
const fields = memberFields(input), agentId = fields.agent_id;
|
|
225
216
|
const caps = fields.capabilities; const argv = input.test_command ? commandArgv(input.test_command) : testArgv(input.test_argv || ['sh', 'test.sh']);
|
|
226
|
-
const { repo, remote } = await ensureRepo(input.repo);
|
|
217
|
+
const { repo, remote, remote_name, base_branch } = await ensureRepo(input.repo, { remoteName: input.remote_name, expectedRemote: input.remote_url, baseBranch: input.base_branch });
|
|
227
218
|
if (inside(repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
228
219
|
for (const profile of (await this.overview()).profiles) if (profile.repo === repo) throw new SetupError('这个 clone 已接入其他身份,请选择独立 clone。');
|
|
229
220
|
const proof = this.discovery.consume(input.discovery_id);
|
|
230
221
|
const id = randomUUID(); const directory = this.path(id);
|
|
231
222
|
const identity = await getOrCreateIdentityFiles(agentId, join(this.home, 'identities'));
|
|
232
|
-
const team = buildTeamPolicy({ repo: input.repo_id?.trim() || remote, reviewer: agentId, defaultRoute: caps[0],
|
|
223
|
+
const team = buildTeamPolicy({ baseBranch: base_branch, repo: input.repo_id?.trim() || remote, reviewer: agentId, defaultRoute: caps[0],
|
|
233
224
|
agents: [{ id: agentId, role: 'admin', capabilities: caps, publicKey: await readFile(identity.public_key_file, 'utf8'), wecomUserid: proof.userid }],
|
|
234
225
|
routes: Object.fromEntries(caps.map((cap) => [cap, [agentId]])) });
|
|
235
226
|
team.schema_version = 3; team.review_rules = defaultReviewRules(team.routes);
|
|
236
227
|
const record = policyVersion(team, null, agentId, (await readPrivateJson(identity.private_key_file)).private_key);
|
|
237
|
-
const state = { id, role: 'admin', agent_id: agentId, display_name: fields.display_name, team_name: input.team_name || null, repo, remote, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
228
|
+
const state = { id, role: 'admin', agent_id: agentId, display_name: fields.display_name, team_name: input.team_name || null, repo, remote, remote_name, identity_path: identity.private_key_file, bootstrap_team: team,
|
|
238
229
|
root_hash: recordHash(record), genesis: record, test_argv: argv,
|
|
239
230
|
wecom: { bot_id: proof.bot_id, secret: proof.secret, chat_ids: [proof.chatid], commands_enabled: true, allow_admin_commands: true }, phase: 'initializing' };
|
|
240
231
|
await this.save(state);
|
|
@@ -256,7 +247,7 @@ export class SetupManager {
|
|
|
256
247
|
const id = randomUUID(); const identityPath = join(this.path(id), 'identity.json');
|
|
257
248
|
await savePrivate(identityPath, identity);
|
|
258
249
|
let genesis, rootHash = config.team_registry_root;
|
|
259
|
-
const store = await new PeerGitStore({ repo: config.repo, dataDir: config.data_dir, team, agentId: config.agent_id, privateKey: config.private_key, registryRoot: rootHash }).init();
|
|
250
|
+
const store = await new PeerGitStore({ remoteName: config.remote_name, remoteUrl: config.remote_url, repo: config.repo, dataDir: config.data_dir, team, agentId: config.agent_id, privateKey: config.private_key, registryRoot: rootHash }).init();
|
|
260
251
|
if (!rootHash) {
|
|
261
252
|
const firstPath = join(store.checkout, '.agent-collab/team/versions/000001.json');
|
|
262
253
|
if (await exists(firstPath)) {
|
|
@@ -277,16 +268,17 @@ export class SetupManager {
|
|
|
277
268
|
}
|
|
278
269
|
}
|
|
279
270
|
const state = { id, role: team.agents[config.agent_id].role, agent_id: config.agent_id, repo: config.repo,
|
|
280
|
-
remote:
|
|
271
|
+
remote: store.remoteUrl, remote_name: store.remoteName, identity_path: identityPath, bootstrap_team: team, root_hash: rootHash,
|
|
281
272
|
test_argv: config.test_argv || ['sh', 'test.sh'], wecom: config.wecom, phase: 'ready', existing_config: resolve(config_path), data_dir: config.data_dir, attached: true };
|
|
282
273
|
await savePrivate(join(this.path(id), 'peer.json'), { ...config, team_registry_root: rootHash });
|
|
283
274
|
// Existing MCP processes keep using this path and the same private desktop state.
|
|
284
275
|
await savePrivate(resolve(config_path), { ...config, team_registry_root: rootHash });
|
|
285
|
-
await this.save(state); this.runCli('start', resolve(config_path)); return this.detail(id, { localOnly: true });
|
|
276
|
+
await this.save(state); await this.runCli('start', resolve(config_path)); return this.detail(id, { localOnly: true });
|
|
286
277
|
}
|
|
287
278
|
async refreshAttachment(state, configPath) {
|
|
288
279
|
try {
|
|
289
|
-
const installed = await installedProjectConfig({ repoPath: state.repo, agentId: state.agent_id });
|
|
280
|
+
const installed = await installedProjectConfig({ repoPath: state.repo, agentId: state.agent_id, allowMissing: true });
|
|
281
|
+
if (installed.missing) { await refreshProject({ configPath }); return; }
|
|
290
282
|
const source = (await readPeerConfig(configPath)).config;
|
|
291
283
|
const bound = (await readPeerConfig(installed.config_path)).config;
|
|
292
284
|
if (bound.agent_id !== source.agent_id || bound.private_key !== source.private_key ||
|
|
@@ -296,10 +288,11 @@ export class SetupManager {
|
|
|
296
288
|
}
|
|
297
289
|
async resume(id) {
|
|
298
290
|
const state = await this.state(id);
|
|
291
|
+
const wasReady = state.phase === 'ready';
|
|
299
292
|
if (state.phase === 'removed') throw new SetupError('该项目已移除,请从首页恢复接入。');
|
|
300
293
|
try {
|
|
301
294
|
if (state.role === 'admin' && state.phase === 'initializing') {
|
|
302
|
-
const bootstrap = new PeerGitStore({ repo: state.repo, dataDir: join(this.path(id), 'data'), team: state.bootstrap_team, agentId: state.agent_id,
|
|
295
|
+
const bootstrap = new PeerGitStore({ remoteName: state.remote_name, remoteUrl: state.remote, repo: state.repo, dataDir: join(this.path(id), 'data'), team: state.bootstrap_team, agentId: state.agent_id,
|
|
303
296
|
privateKey: (await readPrivateJson(state.identity_path)).private_key });
|
|
304
297
|
await bootstrap.init();
|
|
305
298
|
await bootstrap.registryTransaction(async () => {
|
|
@@ -351,7 +344,7 @@ export class SetupManager {
|
|
|
351
344
|
const teamPath = join(this.path(id), 'team.json'); await savePrivate(teamPath, store.team);
|
|
352
345
|
const configPath = join(this.path(id), 'peer.json');
|
|
353
346
|
if (!await exists(configPath)) await createMemberConfig({ teamPath, identityPath: state.identity_path, repoPath: state.repo,
|
|
354
|
-
dataDir: join(this.path(id), 'data'), configPath, testArgv: state.test_argv });
|
|
347
|
+
dataDir: join(this.path(id), 'data'), configPath, testArgv: state.test_argv, remoteName: state.remote_name, remoteUrl: state.remote });
|
|
355
348
|
const config = await readPrivateJson(configPath);
|
|
356
349
|
await savePrivate(configPath, { ...config, team_registry_root: state.root_hash, wecom: state.wecom });
|
|
357
350
|
await diagnosePeer(configPath, { testPush: true });
|
|
@@ -367,7 +360,11 @@ export class SetupManager {
|
|
|
367
360
|
if (state.phase === 'ready') {
|
|
368
361
|
await this.refreshAttachment(state, state.existing_config || join(this.path(id), 'peer.json'));
|
|
369
362
|
await savePrivate(join(this.path(id), 'binding.json'), { prompt: bindingPrompt(state.agent_id, state.repo) });
|
|
370
|
-
|
|
363
|
+
const configPath = state.existing_config || join(this.path(id), 'peer.json');
|
|
364
|
+
const { config } = await readPeerConfig(configPath);
|
|
365
|
+
const intent = await readRunnerIntent(config.data_dir);
|
|
366
|
+
if (intent?.enabled) await this.runCli('recover', configPath);
|
|
367
|
+
else if (!intent && !wasReady) { await writeRunnerIntent(config.data_dir, true); await this.runCli('start', configPath); }
|
|
371
368
|
}
|
|
372
369
|
state.last_error = null; await this.save(state);
|
|
373
370
|
return this.detail(id, { localOnly: true });
|
|
@@ -386,7 +383,7 @@ export class SetupManager {
|
|
|
386
383
|
async inspectBundle(input) {
|
|
387
384
|
const bundle = this.readBundle(input), invite = bundle.invitation;
|
|
388
385
|
return { agent_id: invite.agent_id, display_name: bundle.display_name || invite.agent_id, team_name: bundle.team_name || null,
|
|
389
|
-
remote: bundle.remote, capabilities: invite.capabilities, bot_id: invite.bot_id, suggested_repo: suggestedRepo(bundle.remote, invite.agent_id) };
|
|
386
|
+
remote: bundle.remote, base_branch: teamBaseBranch(validateHistory(bundle.history, bundle.root_hash).latest.team), capabilities: invite.capabilities, bot_id: invite.bot_id, suggested_repo: suggestedRepo(bundle.remote, invite.agent_id) };
|
|
390
387
|
}
|
|
391
388
|
async importBundle(input) {
|
|
392
389
|
const bundle = this.readBundle(input);
|
|
@@ -401,9 +398,9 @@ export class SetupManager {
|
|
|
401
398
|
}
|
|
402
399
|
async importAssignment(value, repo, ownAdminId) {
|
|
403
400
|
const { bundle, history } = validateBundle(value);
|
|
404
|
-
const selected = await ensureRepo(repo);
|
|
401
|
+
const selected = await ensureRepo(repo, { expectedRemote: bundle.remote, remoteOnly: true });
|
|
405
402
|
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
406
|
-
if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('
|
|
403
|
+
if (remoteIdentity(selected.remote) !== remoteIdentity(bundle.remote)) throw new SetupError('该目录的远程与邀请仓库不一致,请选择正确仓库;工具不会改写远程配置。');
|
|
407
404
|
let recovered;
|
|
408
405
|
for (const profile of (await this.overview()).profiles) {
|
|
409
406
|
const current = await this.state(profile.id);
|
|
@@ -422,7 +419,7 @@ export class SetupManager {
|
|
|
422
419
|
recovered = current;
|
|
423
420
|
}
|
|
424
421
|
const id = recovered?.id || randomUUID(); const identity = await getOrCreateIdentityFiles(bundle.invitation.agent_id, join(this.home, 'identities'));
|
|
425
|
-
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, display_name: bundle.display_name || bundle.invitation.agent_id, team_name: bundle.team_name || null, repo: selected.repo, remote: selected.remote, identity_path: identity.private_key_file,
|
|
422
|
+
const state = { id, role: 'member', agent_id: bundle.invitation.agent_id, display_name: bundle.display_name || bundle.invitation.agent_id, team_name: bundle.team_name || null, repo: selected.repo, remote: selected.remote, remote_name: selected.remote_name, identity_path: identity.private_key_file,
|
|
426
423
|
bootstrap_team: history.latest.team, root_hash: bundle.root_hash, bundle, test_argv: bundle.test_argv, phase: 'imported',
|
|
427
424
|
...(ownAdminId ? { own_admin_id: ownAdminId } : {}) };
|
|
428
425
|
await this.save(state); return this.detail(id, { localOnly: true });
|
|
@@ -618,7 +615,10 @@ export class SetupManager {
|
|
|
618
615
|
async createOwnAgent(input) {
|
|
619
616
|
const state = await this.state(input.id);
|
|
620
617
|
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('请从已接入的管理员项目添加自己的机器人。');
|
|
621
|
-
|
|
618
|
+
if (!idPattern.test(input.agent_id?.trim() || '')) throw new SetupError('请填写新 Agent 名称。');
|
|
619
|
+
const repo = input.repo_mode === 'clone' ? await cloneRepository({ remote: state.remote, destination: input.destination || suggestedRepo(state.remote, input.agent_id.trim()), privateHome: this.home }) : input.repo;
|
|
620
|
+
const team = (await this.profileStore(state)).team;
|
|
621
|
+
const selected = await ensureRepo(repo, { expectedRemote: state.remote, baseBranch: teamBaseBranch(team) });
|
|
622
622
|
if (inside(selected.repo, this.home)) throw new SetupError('本机配置目录必须在业务仓库之外。');
|
|
623
623
|
if (remoteIdentity(selected.remote) !== remoteIdentity(state.remote)) throw new SetupError('新 Agent 必须选择同一业务仓库的另一个 clone。');
|
|
624
624
|
const files = await readdir(join(this.path(input.id), 'exports')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
@@ -661,6 +661,32 @@ export class SetupManager {
|
|
|
661
661
|
await savePrivate(config.team_file, store.team);
|
|
662
662
|
return this.detail(id);
|
|
663
663
|
}
|
|
664
|
+
async repositorySettings({ id }) {
|
|
665
|
+
const state = await this.state(id);
|
|
666
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置基准分支。');
|
|
667
|
+
const store = await this.profileStore(state);
|
|
668
|
+
const selected = await ensureRepo(state.repo, { remoteName: store.remoteName, expectedRemote: state.remote, allowSelection: true });
|
|
669
|
+
return { branches: selected.branches, base_branch: teamBaseBranch(store.team), default_branch: selected.base_branch, version: store.registry.latest.version };
|
|
670
|
+
}
|
|
671
|
+
async setRepositoryPolicy({ id, expected_version, base_branch }) {
|
|
672
|
+
const state = await this.state(id);
|
|
673
|
+
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置基准分支。');
|
|
674
|
+
if (!Number.isSafeInteger(expected_version) || expected_version < 1 || typeof base_branch !== 'string' || !base_branch) throw new SetupError('请重新读取团队基准分支后再保存。');
|
|
675
|
+
const store = await this.profileStore(state); const identity = await readPrivateJson(state.identity_path);
|
|
676
|
+
await ensureRepo(state.repo, { remoteName: store.remoteName, expectedRemote: state.remote, baseBranch: base_branch });
|
|
677
|
+
await store.registryTransaction(async (history) => {
|
|
678
|
+
if (history.latest.team.agents[state.agent_id]?.role !== 'admin') throw new SetupError('需要管理员权限。');
|
|
679
|
+
if (history.latest.version !== expected_version) throw new SetupError('团队规则已更新,请重新读取基准分支后再保存。');
|
|
680
|
+
const team = { ...structuredClone(history.latest.team), base_branch };
|
|
681
|
+
validateTeam(team);
|
|
682
|
+
if (canonical(team) === canonical(history.latest.team)) return { value: history.latest.version };
|
|
683
|
+
const version = policyVersion(team, history.latest, state.agent_id, identity.private_key);
|
|
684
|
+
return { files: [[versionFile(version), version]], value: version.version };
|
|
685
|
+
});
|
|
686
|
+
const { config } = await readPeerConfig(state.existing_config || join(this.path(id), 'peer.json'));
|
|
687
|
+
await savePrivate(config.team_file, store.team);
|
|
688
|
+
return this.detail(id);
|
|
689
|
+
}
|
|
664
690
|
async setReviewRules({ id, expected_version, review_rules }) {
|
|
665
691
|
const state = await this.state(id);
|
|
666
692
|
if (state.role !== 'admin' || state.phase !== 'ready') throw new SetupError('只有已接入的管理员可设置审核规则。');
|
|
@@ -684,8 +710,12 @@ export class SetupManager {
|
|
|
684
710
|
const state = await this.state(id); const result = { id, agent_id: state.agent_id, display_name: state.display_name || state.agent_id, team_name: state.team_name || null, role: state.role, repo: state.repo, phase: state.phase, own_agent: !!state.own_admin_id, error: state.last_error || null };
|
|
685
711
|
if (state.phase === 'imported') result.assignment = { capabilities: state.bundle.invitation.capabilities, bot_id: state.bundle.invitation.bot_id,
|
|
686
712
|
expires_at: state.bundle.invitation.expires_at };
|
|
687
|
-
|
|
688
|
-
|
|
713
|
+
let synchronized;
|
|
714
|
+
if (state.phase === 'ready' && !localOnly) {
|
|
715
|
+
synchronized = await this.profileStore(state); result.base_branch = teamBaseBranch(synchronized.team); result.remote_name = synchronized.remoteName;
|
|
716
|
+
}
|
|
717
|
+
if (state.role === 'admin' && synchronized) {
|
|
718
|
+
const store = synchronized; const owners = new Map();
|
|
689
719
|
result.members = Object.entries(store.team.agents).map(([id, agent]) => {
|
|
690
720
|
if (agent.wecom_userid) {
|
|
691
721
|
const group = owners.get(agent.wecom_userid) || []; group.push(id); owners.set(agent.wecom_userid, group);
|
|
@@ -693,7 +723,7 @@ export class SetupManager {
|
|
|
693
723
|
return { id, capabilities: agent.capabilities, role: agent.role, verified_owner: !!agent.wecom_userid };
|
|
694
724
|
});
|
|
695
725
|
result.owners = [...owners].map(([userid, agent_ids]) => ({ agent_ids, is_you: userid === store.team.agents[state.agent_id]?.wecom_userid }));
|
|
696
|
-
result.routes = store.team.routes; result.review_rules = store.team.review_rules || null; result.policy_schema_version = store.team.schema_version; result.version = store.registry.latest.version; result.requests = [];
|
|
726
|
+
result.base_branch = teamBaseBranch(store.team); result.remote_name = store.remoteName; result.routes = store.team.routes; result.review_rules = store.team.review_rules || null; result.policy_schema_version = store.team.schema_version; result.version = store.registry.latest.version; result.requests = [];
|
|
697
727
|
const files = await readdir(join(store.checkout, registryPath, 'requests')).catch((error) => { if (error.code === 'ENOENT') return []; throw error; });
|
|
698
728
|
for (const file of files) {
|
|
699
729
|
try {
|
|
@@ -716,7 +746,8 @@ export class SetupManager {
|
|
|
716
746
|
}
|
|
717
747
|
if (state.phase === 'ready') {
|
|
718
748
|
result.config_path = state.existing_config || join(this.path(id), 'peer.json'); result.binding_prompt = bindingPrompt(state.agent_id, state.repo); result.detailed_binding_prompt = detailedBindingPrompt(state.agent_id, state.repo);
|
|
719
|
-
const config =
|
|
749
|
+
const { config, team: cachedTeam } = await readPeerConfig(result.config_path);
|
|
750
|
+
result.base_branch ??= teamBaseBranch(cachedTeam); result.remote_name ??= config.remote_name || state.remote_name;
|
|
720
751
|
const skillName = config.execution_mode === 'desktop_poll' ? 'team-collab-desktop' : 'team-collab';
|
|
721
752
|
const projectConfig = await readFile(join(state.repo, '.codex', 'config.toml'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
|
|
722
753
|
const installedSkill = await readFile(join(state.repo, '.agents', 'skills', skillName, 'SKILL.md'), 'utf8').catch((error) => error.code === 'ENOENT' ? '' : Promise.reject(error));
|
|
@@ -728,6 +759,7 @@ export class SetupManager {
|
|
|
728
759
|
}
|
|
729
760
|
const desktop = new DesktopExecutor({ agentId: config.agent_id, store: { clock: Date.now } }, config);
|
|
730
761
|
result.desktop = await desktop.status();
|
|
762
|
+
result.connection_enabled = (await readRunnerIntent(config.data_dir))?.enabled ?? result.runner.online;
|
|
731
763
|
result.last_poll_at = result.desktop.last_poll_at || null;
|
|
732
764
|
result.poll_observed = !!result.last_poll_at;
|
|
733
765
|
result.poll_recent = !!result.last_poll_at && Date.now() - Date.parse(result.last_poll_at) < 180000;
|
|
@@ -740,7 +772,10 @@ export class SetupManager {
|
|
|
740
772
|
const state = await this.state(id); if (state.phase !== 'ready' || !['start', 'stop'].includes(command)) throw new SetupError('无效连接操作。');
|
|
741
773
|
try {
|
|
742
774
|
if (command === 'start' && [...this.discovery.sessions.values()].some((session) => session.bot_id === state.wecom?.bot_id && ['connecting', 'authenticated'].includes(session.status))) throw new SetupError('机器人正在验证,请先取消验证,再启动连接。');
|
|
743
|
-
|
|
775
|
+
const configPath = state.existing_config || join(this.path(id), 'peer.json');
|
|
776
|
+
const { config } = await readPeerConfig(configPath);
|
|
777
|
+
await writeRunnerIntent(config.data_dir, command === 'start');
|
|
778
|
+
await this.runCli(command, configPath);
|
|
744
779
|
state.last_error = null; await this.save(state);
|
|
745
780
|
} catch (error) {
|
|
746
781
|
if (error instanceof SetupError) throw error;
|
|
@@ -749,6 +784,32 @@ export class SetupManager {
|
|
|
749
784
|
}
|
|
750
785
|
return this.detail(id, { localOnly: true });
|
|
751
786
|
}
|
|
787
|
+
async diagnostics({ id }) {
|
|
788
|
+
const value = await this.detail(id, { localOnly: true });
|
|
789
|
+
const checks = [];
|
|
790
|
+
const add = (key, label, ok, action) => checks.push({ key, label, ok: !!ok, action: ok ? null : action });
|
|
791
|
+
add('setup', '本机接入', value.phase === 'ready', '先继续完成群身份验证和管理员确认。');
|
|
792
|
+
if (value.phase === 'ready') {
|
|
793
|
+
add('mcp', '项目 MCP', value.project_setup.mcp_configured, '点击修复本机配置,然后在 Codex 中重新加载项目 MCP。');
|
|
794
|
+
add('skill', '接入 Skill', value.project_setup.skill_installed, '点击修复本机配置,恢复工具生成的 Skill。');
|
|
795
|
+
add('runner', '后台机器人进程', value.runner.online, value.connection_enabled ? '连接已启用,后台将尝试恢复;也可以点击启动机器人连接。' : '连接已停止。需要接任务时点击启动机器人连接。');
|
|
796
|
+
add('wecom', '企业微信认证', value.runner.wecom === 'authenticated', '等待认证;若持续失败,请检查网络或通过更新机器人凭据重新验证。');
|
|
797
|
+
add('binding', '目标 Codex 对话', !!value.desktop.binding, '复制启用指令,发送到你选定的 Codex 对话。');
|
|
798
|
+
add('poll', '对话实际轮询', value.poll_recent, '在绑定对话中执行启用指令,并确认该对话的协作自动化已启用。');
|
|
799
|
+
try { await diagnosePeer(value.config_path, { testPush: true }); add('git', 'Git 读取与推送权限', true); }
|
|
800
|
+
catch { add('git', 'Git 读取与推送权限', false, '请完成本机 Git 登录,确认所选远程和团队基准分支可读,并具有协作分支推送权限。'); }
|
|
801
|
+
}
|
|
802
|
+
const startup = await startupStatus(this.home);
|
|
803
|
+
return { id, checks, ready: value.can_receive_tasks === true, startup, heartbeat_confirmed: false,
|
|
804
|
+
note: '检查使用真实进程、认证和轮询记录,不创建测试任务。未来能否自动唤醒仍需在 Codex 中确认自动化。' };
|
|
805
|
+
}
|
|
806
|
+
async repair({ id }) {
|
|
807
|
+
const state = await this.state(id);
|
|
808
|
+
if (state.phase !== 'ready') throw new SetupError('请先完成本机接入。');
|
|
809
|
+
const configPath = state.existing_config || join(this.path(id), 'peer.json');
|
|
810
|
+
await this.refreshAttachment(state, configPath);
|
|
811
|
+
return { id, repaired: true, next_step: '本机生成配置已更新。请在 Codex 重新加载项目 MCP,并在原绑定对话中执行启用指令。' };
|
|
812
|
+
}
|
|
752
813
|
resetPreview(input) { return this.resets.preview(input); }
|
|
753
814
|
resetDiscovery(input) { return this.resets.discover(input); }
|
|
754
815
|
resetCancel(input) { return this.resets.cancel(input); }
|
|
@@ -6,6 +6,7 @@ import { SetupError } from './errors.mjs';
|
|
|
6
6
|
import { readPeerConfig, loadRuntime } from '../peer/runtime.mjs';
|
|
7
7
|
import { PrivateDesktopState } from '../peer/private-state.mjs';
|
|
8
8
|
import { alive, readRunnerLock } from '../peer/process.mjs';
|
|
9
|
+
import { writeRunnerIntent } from '../peer/intent.mjs';
|
|
9
10
|
import { installedProjectConfig } from '../../scripts/peer/refresh-project.mjs';
|
|
10
11
|
import { bindingPrompt } from '../../scripts/peer/setup-desktop-project.mjs';
|
|
11
12
|
import { digest, readPrivateJson } from '../../packages/peer/config.mjs';
|
|
@@ -152,8 +153,9 @@ export class ResetActions {
|
|
|
152
153
|
if (!value || value.id !== input.id || value.kind !== input.kind || value.expires < Date.now() || value.fingerprint !== fingerprint) throw new SetupError('项目配置或绑定已改变,或预览已过期,请重新预览。');
|
|
153
154
|
}
|
|
154
155
|
async stop(context, plan) {
|
|
156
|
+
await writeRunnerIntent(context.dataDir, false);
|
|
155
157
|
if (!plan.lock || !alive(plan.lock.pid)) return;
|
|
156
|
-
try { this.manager.runCli('stop', plan.lock.config); }
|
|
158
|
+
try { await this.manager.runCli('stop', plan.lock.config); }
|
|
157
159
|
catch { throw new SetupError('机器人停止失败,未修改配置;请检查 Runner 后重试。'); }
|
|
158
160
|
const lock = await readRunnerLock(join(context.dataDir, 'runner.pid.json'));
|
|
159
161
|
if (lock && alive(lock.pid)) throw new SetupError('机器人仍在停止,请稍后重试;未修改配置。');
|
|
@@ -41,7 +41,7 @@ export async function startSetupServer({ manager = new SetupManager(), port = 0
|
|
|
41
41
|
const value = await manager.exclusive(() => manager.exportedBundle(url.searchParams.get('id'), url.searchParams.get('invite')));
|
|
42
42
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${value.filename}"` }); res.end(value.content); return;
|
|
43
43
|
}
|
|
44
|
-
const actions = { 'project-check': () => manager.prepareProject(body), 'admin-prepare': () => manager.prepareAdmin(body), 'admin-complete': () => manager.completeAdmin(body), 'admin-retry': () => manager.adminDraft({ id: body.id, reconnect: true }), 'inspect-invite': () => manager.inspectBundle(body), progress: () => manager.progress(body), 'issue-batch': () => manager.issueBatch(body), 'reissue-invite': () => manager.reissueBundle(body), delivery: () => manager.delivery(body), 'reset-cancel': () => manager.resetCancel(body), 'reset-preview': () => manager.resetPreview(body), 'reset-discovery': () => manager.resetDiscovery(body), reset: () => manager.reset(body), 'own-agent': () => manager.createOwnAgent(body), 'own-agent-resume': () => manager.resumeOwnAgent(body), discovery: () => manager.beginDiscovery(body), admin: () => manager.createAdmin(body), import: () => manager.importBundle(body), existing: () => manager.importExisting(body), verify: () => manager.verifyMember(body), resume: () => manager.resume(body.id), issue: () => manager.issueBundle(body), approve: () => manager.approve(body), runner: () => manager.runner(body), 'review-rules': () => manager.setReviewRules(body) };
|
|
44
|
+
const actions = { 'repository-settings': () => manager.repositorySettings(body), 'repository-policy': () => manager.setRepositoryPolicy(body), diagnostics: () => manager.diagnostics(body), repair: () => manager.repair(body), 'project-check': () => manager.prepareProject(body), 'admin-prepare': () => manager.prepareAdmin(body), 'admin-complete': () => manager.completeAdmin(body), 'admin-retry': () => manager.adminDraft({ id: body.id, reconnect: true }), 'inspect-invite': () => manager.inspectBundle(body), progress: () => manager.progress(body), 'issue-batch': () => manager.issueBatch(body), 'reissue-invite': () => manager.reissueBundle(body), delivery: () => manager.delivery(body), 'reset-cancel': () => manager.resetCancel(body), 'reset-preview': () => manager.resetPreview(body), 'reset-discovery': () => manager.resetDiscovery(body), reset: () => manager.reset(body), 'own-agent': () => manager.createOwnAgent(body), 'own-agent-resume': () => manager.resumeOwnAgent(body), discovery: () => manager.beginDiscovery(body), admin: () => manager.createAdmin(body), import: () => manager.importBundle(body), existing: () => manager.importExisting(body), verify: () => manager.verifyMember(body), resume: () => manager.resume(body.id), issue: () => manager.issueBundle(body), approve: () => manager.approve(body), runner: () => manager.runner(body), 'review-rules': () => manager.setReviewRules(body) };
|
|
45
45
|
const key = url.pathname.replace('/api/', ''); const action = Object.hasOwn(actions, key) ? actions[key] : null;
|
|
46
46
|
if (req.method === 'POST' && url.pathname.startsWith('/api/') && action) return json(200, await manager.exclusive(action));
|
|
47
47
|
return json(404, { error: '操作不存在。' });
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { chmod, mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const entry = fileURLToPath(new URL('./main.mjs', import.meta.url));
|
|
10
|
+
const xml = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
11
|
+
const unitArg = (value, expand = true) => {
|
|
12
|
+
let text = String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%');
|
|
13
|
+
if (expand) text = text.replaceAll('$', () => '$$');
|
|
14
|
+
return `"${text}"`;
|
|
15
|
+
};
|
|
16
|
+
export function startupDefinition({ home, platform = process.platform, userHome = homedir(), node = process.execPath, path = process.env.PATH || '/usr/bin:/bin' }) {
|
|
17
|
+
if ([home, userHome, node, path].some((value) => typeof value !== 'string' || /[\x00\r\n]/.test(value))) throw new Error('自启路径不能包含换行或空字符。');
|
|
18
|
+
const suffix = createHash('sha256').update(home).digest('hex').slice(0, 12);
|
|
19
|
+
const label = `com.codex-collab.${suffix}`;
|
|
20
|
+
const log = join(home, 'service.log');
|
|
21
|
+
if (platform === 'darwin') return { kind: 'launchd', label, path: join(userHome, 'Library/LaunchAgents', `${label}.plist`), content: `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array>${[node, entry, '--home', home].map((arg) => `<string>${xml(arg)}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>15</integer><key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(path)}</string></dict><key>Umask</key><integer>63</integer><key>StandardOutPath</key><string>${xml(log)}</string><key>StandardErrorPath</key><string>${xml(log)}</string></dict></plist>\n` };
|
|
22
|
+
if (platform === 'linux') return { kind: 'systemd', label: `${label}.service`, path: join(userHome, '.config/systemd/user', `${label}.service`), content: `[Unit]\nDescription=Codex Collab local workspace\n[Service]\nType=simple\nExecStart=${[node, entry, '--home', home].map((arg) => unitArg(arg)).join(' ')}\nEnvironment=${unitArg(`PATH=${path}`, false)}\nRestart=on-failure\nRestartSec=15\nUMask=0077\n[Install]\nWantedBy=default.target\n` };
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export async function configureStartup({ home, enabled = true, start = false, platform = process.platform, userHome = homedir(), execute = exec } = {}) {
|
|
26
|
+
const definition = startupDefinition({ home, platform, userHome });
|
|
27
|
+
const status = { enabled: false, supported: !!definition, kind: definition?.kind || null };
|
|
28
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
29
|
+
const run = (command, args) => execute(command, args, { timeout: 10000, maxBuffer: 10000 });
|
|
30
|
+
if (definition) {
|
|
31
|
+
try {
|
|
32
|
+
if (enabled) {
|
|
33
|
+
const logPath = join(home, 'service.log');
|
|
34
|
+
const log = await open(logPath, 'a', 0o600); await log.close(); await chmod(logPath, 0o600);
|
|
35
|
+
await mkdir(dirname(definition.path), { recursive: true, mode: 0o700 });
|
|
36
|
+
await writeFile(definition.path, definition.content, { mode: 0o600 });
|
|
37
|
+
if (definition.kind === 'launchd') {
|
|
38
|
+
const domain = `gui/${process.getuid()}`;
|
|
39
|
+
if (start) {
|
|
40
|
+
try { await run('launchctl', ['bootstrap', domain, definition.path]); }
|
|
41
|
+
catch { await run('launchctl', ['kickstart', `${domain}/${definition.label}`]); }
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
await run('systemctl', ['--user', 'daemon-reload']);
|
|
45
|
+
await run('systemctl', ['--user', 'enable', ...(start ? ['--now'] : []), definition.label]);
|
|
46
|
+
}
|
|
47
|
+
status.enabled = true;
|
|
48
|
+
} else {
|
|
49
|
+
if (definition.kind === 'launchd') await run('launchctl', ['bootout', `gui/${process.getuid()}/${definition.label}`]).catch(() => {});
|
|
50
|
+
else await run('systemctl', ['--user', 'disable', definition.label]);
|
|
51
|
+
await rm(definition.path, { force: true });
|
|
52
|
+
}
|
|
53
|
+
} catch { status.error = '登录自启未完成。后台连接仍可使用,可运行 codex-collab autostart on 重试。'; }
|
|
54
|
+
} else if (enabled) status.error = '此系统暂不支持自动配置登录自启;本次后台服务已独立运行。';
|
|
55
|
+
await writeFile(join(home, 'startup.json'), JSON.stringify(status) + '\n', { mode: 0o600 });
|
|
56
|
+
return status;
|
|
57
|
+
}
|
|
58
|
+
export async function startupStatus(home) {
|
|
59
|
+
try { return JSON.parse(await readFile(join(home, 'startup.json'), 'utf8')); }
|
|
60
|
+
catch (error) { if (error.code === 'ENOENT') return { enabled: false, supported: ['darwin', 'linux'].includes(process.platform) }; throw error; }
|
|
61
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { profiles } from './workspace.mjs';
|
|
3
|
+
import { readPeerConfig } from '../peer/runtime.mjs';
|
|
4
|
+
import { alive, readRunnerLock, acquireRunnerControl } from '../peer/process.mjs';
|
|
5
|
+
import { readRunnerIntent, writeRunnerIntent } from '../peer/intent.mjs';
|
|
6
|
+
import { savePrivate } from './manager.mjs';
|
|
7
|
+
|
|
8
|
+
export class RunnerSupervisor {
|
|
9
|
+
constructor(manager, { clock = Date.now } = {}) { this.manager = manager; this.clock = clock; this.failures = new Map(); }
|
|
10
|
+
async tick() {
|
|
11
|
+
for (const profile of await profiles(this.manager.home)) {
|
|
12
|
+
if (profile.phase !== 'ready') continue;
|
|
13
|
+
const retry = this.failures.get(profile.id);
|
|
14
|
+
if (retry?.after > this.clock()) continue;
|
|
15
|
+
try {
|
|
16
|
+
const { config } = await readPeerConfig(profile.config_path);
|
|
17
|
+
const lock = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
|
|
18
|
+
let intent = await readRunnerIntent(config.data_dir);
|
|
19
|
+
// Upgrade only adopts a live connection. Previously stopped installations stay stopped.
|
|
20
|
+
if (!intent) {
|
|
21
|
+
const release = await acquireRunnerControl(config.data_dir, profile.config_path);
|
|
22
|
+
try {
|
|
23
|
+
intent = await readRunnerIntent(config.data_dir);
|
|
24
|
+
if (!intent) {
|
|
25
|
+
const current = await readRunnerLock(join(config.data_dir, 'runner.pid.json'));
|
|
26
|
+
intent = await writeRunnerIntent(config.data_dir, !!current && alive(current.pid));
|
|
27
|
+
}
|
|
28
|
+
} finally { await release(); }
|
|
29
|
+
}
|
|
30
|
+
if (!intent.enabled || lock && alive(lock.pid)) { this.failures.delete(profile.id); continue; }
|
|
31
|
+
if ([...this.manager.discovery.sessions.values()].some((session) => session.bot_id === config.wecom?.bot_id && ['connecting', 'authenticated'].includes(session.status))) continue;
|
|
32
|
+
await this.manager.runCli('recover', profile.config_path);
|
|
33
|
+
this.failures.delete(profile.id);
|
|
34
|
+
} catch {
|
|
35
|
+
const attempts = (retry?.attempts || 0) + 1;
|
|
36
|
+
this.failures.set(profile.id, { attempts, after: this.clock() + Math.min(300000, 15000 * 2 ** Math.min(attempts - 1, 5)) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
await savePrivate(join(this.manager.home, 'supervisor.json'), { checked_at: new Date(this.clock()).toISOString(), retrying: [...this.failures].map(([id, retry]) => ({ id, next_retry_at: new Date(retry.after).toISOString() })) });
|
|
40
|
+
}
|
|
41
|
+
}
|