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.
Files changed (44) hide show
  1. package/README.md +23 -2
  2. package/apps/onboarding/bundle.mjs +1 -6
  3. package/apps/onboarding/commands.mjs +46 -0
  4. package/apps/onboarding/guided.mjs +3 -2
  5. package/apps/onboarding/main.mjs +72 -19
  6. package/apps/onboarding/manager.mjs +116 -55
  7. package/apps/onboarding/reset.mjs +3 -1
  8. package/apps/onboarding/server.mjs +1 -1
  9. package/apps/onboarding/startup.mjs +61 -0
  10. package/apps/onboarding/supervisor.mjs +41 -0
  11. package/apps/onboarding/web/app.js +78 -13
  12. package/apps/onboarding/web/index.html +17 -17
  13. package/apps/onboarding/web/style.css +3 -0
  14. package/apps/onboarding/workspace.mjs +36 -0
  15. package/apps/peer/desktop.mjs +4 -3
  16. package/apps/peer/intent.mjs +19 -0
  17. package/apps/peer/mcp.mjs +1 -1
  18. package/apps/peer/process.mjs +13 -0
  19. package/apps/peer/runner.mjs +4 -3
  20. package/apps/peer/runtime.mjs +6 -1
  21. package/apps/peer/wecom.mjs +1 -1
  22. package/bin/codex-collab.mjs +21 -9
  23. package/docs/desktop-poll.md +2 -2
  24. package/docs/onboarding-audit.md +12 -2
  25. package/docs/onboarding.md +37 -9
  26. package/docs/peer.md +4 -2
  27. package/docs/releases/0.5.0.md +14 -0
  28. package/docs/team-registry.md +7 -1
  29. package/docs/terminal-onboarding.md +3 -3
  30. package/package.json +1 -1
  31. package/packages/codex-kit/templates/.agents/skills/team-collab/SKILL.md +2 -2
  32. package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +2 -2
  33. package/packages/peer/config.mjs +2 -0
  34. package/packages/peer/git.mjs +14 -0
  35. package/packages/peer/protocol.mjs +4 -2
  36. package/packages/peer/repository.mjs +67 -0
  37. package/packages/peer/service.mjs +11 -8
  38. package/packages/peer/store.mjs +16 -16
  39. package/schemas/peer-task.schema.json +15 -13
  40. package/schemas/team-policy.schema.json +12 -1
  41. package/scripts/peer/cli.mjs +14 -8
  42. package/scripts/peer/init-agent.mjs +6 -15
  43. package/scripts/peer/onboarding.mjs +26 -20
  44. 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 { spawnSync } from 'node:child_process';
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 无法读取远程仓库,请检查 origin 地址、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
- let remote;
85
- try { remote = await git(repo, 'remote', 'get-url', 'origin'); }
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 = join(homedir(), '.config', 'codex-collab', 'workspace'), discovery = new BotDiscovery(), runCli } = {}) {
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
- const result = spawnSync(process.execPath, [join(packageRoot, 'bin/codex-collab.mjs'), command, config], { encoding: 'utf8', timeout: 35000, maxBuffer: 200000 });
118
- if (result.status !== 0) throw new Error(`本机 ${command} 操作未完成,请检查项目 Git 权限或 Runner 日志。`);
119
- return result.stdout.trim();
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
- return { profiles, removed_profiles, drafts };
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
- const draft = { id: randomUUID(), input: { ...fields, repo: selected.repo, test_argv: argv, team_name: input.team_name?.trim() || null,
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: await git(config.repo, 'remote', 'get-url', 'origin'), identity_path: identityPath, bootstrap_team: team, root_hash: rootHash,
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
- this.runCli('start', state.existing_config || join(this.path(id), 'peer.json'));
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('该目录的 origin 与邀请仓库不一致,请选择正确仓库;工具不会改写 origin。');
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
- const selected = await ensureRepo(input.repo);
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
- if (state.role === 'admin' && state.phase === 'ready' && !localOnly) {
688
- const store = await this.profileStore(state); const owners = new Map();
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 = (await readPeerConfig(result.config_path)).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
- this.runCli(command, state.existing_config || join(this.path(id), 'peer.json'));
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
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
+ }