@app-vitals/agent-runtime 0.0.1 → 0.1.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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +38 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
package/spawn-env.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * agent/src/runtime/spawn-env.ts
3
+ *
4
+ * Builds the extraEnv for a Claude spawn, adding the SHIPWRIGHT_GH_OWNER hint
5
+ * the `gh` wrapper uses to pick the matching App installation token (GOR-1.3).
6
+ *
7
+ * - "loop": owner of the dispatched task or PR's repo
8
+ * - "cron": the default org (pinned installation, else lowest installation id)
9
+ * - "dm": deliberately NO hint — a Slack DM is not tied to an org
10
+ *
11
+ * Observability: a loop or cron spawn that should carry the hint but has no
12
+ * resolvable owner logs ONE warn line. Spawns with an owner, and DM spawns,
13
+ * log nothing.
14
+ */
15
+
16
+ import type { InstallationsSnapshot } from "./github-installations.ts";
17
+
18
+ export type SpawnKind = "loop" | "cron" | "dm";
19
+
20
+ interface BuildSpawnEnvOptions {
21
+ kind: SpawnKind;
22
+ /** Task id (loop) or cron job id (cron); named in the warn line. */
23
+ id?: string;
24
+ /** The resolved owner, if any. Ignored for "dm". */
25
+ owner?: string | null;
26
+ /** Existing env (e.g. SLACK_CHANNEL_ID) — preserved, never overwritten by an absent hint. */
27
+ extraEnv?: Record<string, string>;
28
+ warn?: (message: string) => void;
29
+ }
30
+
31
+ /**
32
+ * Returns extraEnv merged with SHIPWRIGHT_GH_OWNER when an owner is known.
33
+ * Returns the input extraEnv untouched (possibly undefined) otherwise.
34
+ */
35
+ export function buildSpawnEnv(
36
+ opts: BuildSpawnEnvOptions,
37
+ ): Record<string, string> | undefined {
38
+ const { kind, id, owner, extraEnv, warn = console.warn } = opts;
39
+ if (kind === "dm") return extraEnv;
40
+ if (!owner) {
41
+ warn(
42
+ `[spawn-env] ${kind} spawn${id ? ` ${id}` : ""} has no resolvable GitHub owner — running without SHIPWRIGHT_GH_OWNER`,
43
+ );
44
+ return extraEnv;
45
+ }
46
+ return { ...extraEnv, SHIPWRIGHT_GH_OWNER: owner };
47
+ }
48
+
49
+ /** The default installation's owner login, or null when none is known. */
50
+ export function defaultOwnerOf(
51
+ snapshot: InstallationsSnapshot | null | undefined,
52
+ ): string | null {
53
+ if (!snapshot || snapshot.defaultId === null) return null;
54
+ return (
55
+ snapshot.installations.find((i) => i.id === snapshot.defaultId)?.owner ??
56
+ null
57
+ );
58
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildSpawnEnv, defaultOwnerOf } from "./spawn-env.ts";
3
+
4
+ function collector() {
5
+ const lines: string[] = [];
6
+ return { lines, warn: (m: string) => lines.push(m) };
7
+ }
8
+
9
+ describe("buildSpawnEnv", () => {
10
+ test("loop-dispatched item carries its owner and preserves extraEnv", () => {
11
+ const c = collector();
12
+ const env = buildSpawnEnv({
13
+ kind: "loop",
14
+ id: "GOR-1.3",
15
+ owner: "acme",
16
+ extraEnv: { SLACK_CHANNEL_ID: "C1" },
17
+ warn: c.warn,
18
+ });
19
+ expect(env).toEqual({
20
+ SLACK_CHANNEL_ID: "C1",
21
+ SHIPWRIGHT_GH_OWNER: "acme",
22
+ });
23
+ expect(c.lines).toEqual([]);
24
+ });
25
+
26
+ test("cron without a repo carries the default org", () => {
27
+ const c = collector();
28
+ expect(
29
+ buildSpawnEnv({
30
+ kind: "cron",
31
+ id: "nightly",
32
+ owner: "default-org",
33
+ warn: c.warn,
34
+ }),
35
+ ).toEqual({ SHIPWRIGHT_GH_OWNER: "default-org" });
36
+ expect(c.lines).toEqual([]);
37
+ });
38
+
39
+ test("DM spawn carries no hint and logs nothing, even if an owner is passed", () => {
40
+ const c = collector();
41
+ expect(buildSpawnEnv({ kind: "dm", warn: c.warn })).toBeUndefined();
42
+ expect(
43
+ buildSpawnEnv({
44
+ kind: "dm",
45
+ owner: "acme",
46
+ extraEnv: { A: "b" },
47
+ warn: c.warn,
48
+ }),
49
+ ).toEqual({ A: "b" });
50
+ expect(c.lines).toEqual([]);
51
+ });
52
+
53
+ test("loop or cron spawn with no owner warns exactly once, naming kind and id", () => {
54
+ const c = collector();
55
+ const env = buildSpawnEnv({
56
+ kind: "loop",
57
+ id: "GOR-9.9",
58
+ extraEnv: { SLACK_CHANNEL_ID: "C1" },
59
+ warn: c.warn,
60
+ });
61
+ expect(env).toEqual({ SLACK_CHANNEL_ID: "C1" });
62
+ expect(c.lines).toHaveLength(1);
63
+ expect(c.lines[0]).toContain("loop");
64
+ expect(c.lines[0]).toContain("GOR-9.9");
65
+
66
+ buildSpawnEnv({ kind: "cron", id: "nightly", owner: null, warn: c.warn });
67
+ expect(c.lines).toHaveLength(2);
68
+ expect(c.lines[1]).toContain("cron");
69
+ expect(c.lines[1]).toContain("nightly");
70
+ });
71
+ });
72
+
73
+ describe("defaultOwnerOf", () => {
74
+ const inst = (id: number, owner: string | null) => ({
75
+ id,
76
+ owner,
77
+ pinned: false,
78
+ health: "ok" as const,
79
+ reason: null,
80
+ });
81
+ test("returns the default installation's owner", () => {
82
+ expect(
83
+ defaultOwnerOf({
84
+ installations: [inst(1, "a"), inst(2, "b")],
85
+ defaultId: 2,
86
+ }),
87
+ ).toBe("b");
88
+ });
89
+ test("null when there is no default or no snapshot", () => {
90
+ expect(defaultOwnerOf({ installations: [], defaultId: null })).toBeNull();
91
+ expect(defaultOwnerOf(null)).toBeNull();
92
+ expect(
93
+ defaultOwnerOf({ installations: [inst(1, null)], defaultId: 1 }),
94
+ ).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Integration tests for agent/src/runtime/startup-dm.ts
3
+ *
4
+ * Strategy: inject a typed mock WebClient — no mock.module(), no global overrides.
5
+ * Covers all three branches:
6
+ * 1. Happy path — conversations.open resolves with a valid channel.id → postMessage called
7
+ * 2. No channel-id — channel.id undefined → postMessage NOT called, no error
8
+ * 3. Slack API throws — caught, console.warn called, no crash
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
12
+ import type { WebClient } from "@slack/web-api";
13
+ import { sendBackOnlineDm } from "./startup-dm.ts";
14
+
15
+ // ─── Mock WebClient factory ───────────────────────────────────────────────────
16
+
17
+ function makeMockSlack(
18
+ overrides: {
19
+ conversationsOpenResult?: Awaited<
20
+ ReturnType<WebClient["conversations"]["open"]>
21
+ >;
22
+ conversationsOpenError?: Error;
23
+ postMessageResult?: Awaited<ReturnType<WebClient["chat"]["postMessage"]>>;
24
+ } = {},
25
+ ) {
26
+ const conversationsOpen = overrides.conversationsOpenError
27
+ ? mock(async (_args: unknown) => {
28
+ throw overrides.conversationsOpenError;
29
+ })
30
+ : mock(
31
+ async (_args: unknown) =>
32
+ overrides.conversationsOpenResult ?? {
33
+ ok: true,
34
+ channel: { id: "DM_CHAN_1" },
35
+ },
36
+ );
37
+
38
+ const postMessage = mock(async (_args: unknown) => ({
39
+ ok: true,
40
+ ts: "123.456",
41
+ }));
42
+
43
+ return {
44
+ conversations: { open: conversationsOpen },
45
+ chat: { postMessage },
46
+ } as unknown as WebClient;
47
+ }
48
+
49
+ // ─── Capture console.warn ─────────────────────────────────────────────────────
50
+
51
+ let warnMessages: string[] = [];
52
+ const originalWarn = console.warn.bind(console);
53
+
54
+ beforeEach(() => {
55
+ warnMessages = [];
56
+ console.warn = (...args: unknown[]) => {
57
+ warnMessages.push(args.map(String).join(" "));
58
+ };
59
+ });
60
+
61
+ afterEach(() => {
62
+ console.warn = originalWarn;
63
+ });
64
+
65
+ // ─── Tests ────────────────────────────────────────────────────────────────────
66
+
67
+ describe("sendBackOnlineDm — happy path", () => {
68
+ test("opens a DM and posts 'back online' when channel.id is present", async () => {
69
+ const slack = makeMockSlack();
70
+ await sendBackOnlineDm(slack, "U12345");
71
+
72
+ expect(slack.conversations.open).toHaveBeenCalledTimes(1);
73
+ expect(slack.conversations.open).toHaveBeenCalledWith({ users: "U12345" });
74
+
75
+ expect(slack.chat.postMessage).toHaveBeenCalledTimes(1);
76
+ expect(slack.chat.postMessage).toHaveBeenCalledWith({
77
+ channel: "DM_CHAN_1",
78
+ text: "back online",
79
+ });
80
+
81
+ expect(warnMessages.length).toBe(0);
82
+ });
83
+ });
84
+
85
+ describe("sendBackOnlineDm — no-channel-id branch", () => {
86
+ test("skips postMessage when conversations.open returns no channel.id", async () => {
87
+ const slack = makeMockSlack({
88
+ conversationsOpenResult: { ok: true, channel: { id: undefined } },
89
+ });
90
+ await sendBackOnlineDm(slack, "U12345");
91
+
92
+ expect(slack.conversations.open).toHaveBeenCalledTimes(1);
93
+ expect(slack.chat.postMessage).not.toHaveBeenCalled();
94
+ expect(warnMessages.length).toBe(0);
95
+ });
96
+
97
+ test("skips postMessage when conversations.open returns null channel", async () => {
98
+ const slack = makeMockSlack({
99
+ // biome-ignore lint/suspicious/noExplicitAny: testing undefined channel path
100
+ conversationsOpenResult: { ok: true, channel: null } as any,
101
+ });
102
+ await sendBackOnlineDm(slack, "U12345");
103
+
104
+ expect(slack.chat.postMessage).not.toHaveBeenCalled();
105
+ expect(warnMessages.length).toBe(0);
106
+ });
107
+
108
+ test("does nothing when ownerUser is undefined", async () => {
109
+ const slack = makeMockSlack();
110
+ await sendBackOnlineDm(slack, undefined);
111
+
112
+ expect(slack.conversations.open).not.toHaveBeenCalled();
113
+ expect(slack.chat.postMessage).not.toHaveBeenCalled();
114
+ expect(warnMessages.length).toBe(0);
115
+ });
116
+
117
+ test("does nothing when ownerUser is empty string", async () => {
118
+ const slack = makeMockSlack();
119
+ await sendBackOnlineDm(slack, "");
120
+
121
+ expect(slack.conversations.open).not.toHaveBeenCalled();
122
+ expect(slack.chat.postMessage).not.toHaveBeenCalled();
123
+ expect(warnMessages.length).toBe(0);
124
+ });
125
+ });
126
+
127
+ describe("sendBackOnlineDm — error-caught branch", () => {
128
+ test("catches Slack API error, warns, and does not crash", async () => {
129
+ const slack = makeMockSlack({
130
+ conversationsOpenError: new Error("channel_not_found"),
131
+ });
132
+
133
+ await expect(sendBackOnlineDm(slack, "U12345")).resolves.toBeUndefined();
134
+
135
+ expect(slack.chat.postMessage).not.toHaveBeenCalled();
136
+ expect(warnMessages.length).toBe(1);
137
+ expect(warnMessages[0]).toContain("back-online DM failed");
138
+ expect(warnMessages[0]).toContain("channel_not_found");
139
+ });
140
+
141
+ test("warns with non-Error thrown value as string", async () => {
142
+ const slack = {
143
+ conversations: {
144
+ open: mock(async (_args: unknown) => {
145
+ throw "string-error";
146
+ }),
147
+ },
148
+ chat: { postMessage: mock(async (_args: unknown) => ({})) },
149
+ } as unknown as WebClient;
150
+
151
+ await expect(sendBackOnlineDm(slack, "U12345")).resolves.toBeUndefined();
152
+
153
+ expect(warnMessages.length).toBe(1);
154
+ expect(warnMessages[0]).toContain("string-error");
155
+ });
156
+ });
package/startup-dm.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Sends a "back online" DM to the agent owner on startup.
3
+ *
4
+ * Non-fatal: silently skips if owner is not configured, channel-id is missing,
5
+ * or the Slack API throws. Extracted for testability.
6
+ */
7
+
8
+ import type { WebClient } from "@slack/web-api";
9
+
10
+ export async function sendBackOnlineDm(
11
+ slack: WebClient,
12
+ ownerUser: string | undefined,
13
+ ): Promise<void> {
14
+ if (!ownerUser) return;
15
+ try {
16
+ const dm = await slack.conversations.open({ users: ownerUser });
17
+ if (dm.channel?.id) {
18
+ await slack.chat.postMessage({
19
+ channel: dm.channel.id,
20
+ text: "back online",
21
+ });
22
+ }
23
+ } catch (err) {
24
+ console.warn(
25
+ "[agent] back-online DM failed (non-fatal):",
26
+ err instanceof Error ? err.message : String(err),
27
+ );
28
+ }
29
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * agent/src/runtime/sync-config-clone.ts
3
+ *
4
+ * Auto-clones newly-configured repos on config sync (RSF-4.1).
5
+ *
6
+ * index.ts's syncConfig() tick (runs every 60s) fetches the agent's config
7
+ * bundle and calls agentReposRef.set(bundle.repos) to update the in-memory
8
+ * scope — but historically did nothing to actually fetch a newly-added repo
9
+ * onto disk, leaving a human to clone it manually before any dev-task/review/
10
+ * patch/deploy work against it could proceed.
11
+ *
12
+ * This module reuses lib/clone-plan.ts's computeMissingClones() — the same
13
+ * pure, already-unit-tested planner already used by the manual
14
+ * agent-workspace-pull CLI and hitl.ts — against the real repos/ dir, and
15
+ * clones anything missing via `gh repo clone`. The planner lives in `lib/`
16
+ * (not `scripts/`) precisely because this module imports it: agent/Dockerfile's
17
+ * runtime stage copies `lib/` but not `scripts/`, so a `scripts/` import would
18
+ * resolve locally and then crash-loop the deployed pod.
19
+ *
20
+ * Kept deliberately simple (explicit product decision): the clone step runs
21
+ * within syncConfig()'s own tick, not backgrounded or queued — no new
22
+ * interval, no fire-and-forget dispatch. The `gh` child process itself is
23
+ * spawned *asynchronously* (`Bun.spawn` + `await proc.exited`, matching
24
+ * claude.ts's convention for long-running children) rather than with
25
+ * `Bun.spawnSync`: this runs inside the long-lived server process that also
26
+ * serves the liveness/readiness probes, and a synchronous clone would block
27
+ * that process's event loop for the full duration of the clone — long enough
28
+ * for a probe to fail and restart the pod mid-clone.
29
+ *
30
+ * Because the spawn is async, two syncConfig() ticks can now overlap (index.ts
31
+ * schedules them with a bare `setInterval`, which does not serialize), so an
32
+ * in-flight set guards against two ticks racing the same destination.
33
+ *
34
+ * A single repo's clone failure (auth hiccup, rate limit, invalid repo name,
35
+ * ...) is caught and logged per-repo — never thrown — so one bad repo can't
36
+ * block the rest of the plan or crash the tick; the partially-written
37
+ * destination is removed so the next tick's computeMissingClones() sees it as
38
+ * missing again and retries it, with no explicit retry/backoff logic needed.
39
+ */
40
+
41
+ import { existsSync, rmSync } from "node:fs";
42
+ import { join } from "node:path";
43
+ import {
44
+ computeMissingClones,
45
+ findRepoNameCollisions,
46
+ } from "@app-vitals/agent-lib/clone-plan";
47
+ import { ownerOfRepo } from "./gh-helpers.ts";
48
+
49
+ export interface SyncConfigCloneDeps {
50
+ /**
51
+ * The agent's currently-configured repo scope ("org/repo" strings) — read
52
+ * fresh on every call (mirrors worktree-reaper.ts's own getScopedRepos
53
+ * doc comment) so a repo added between two ticks is picked up on the very
54
+ * next call without rebuilding deps.
55
+ */
56
+ getScopedRepos: () => string[];
57
+ /** Absolute path to the repos/ dir clones land in. */
58
+ reposDir: string;
59
+ /** Existence check for the clone-skip plan — injectable for tests. */
60
+ exists: (path: string) => boolean;
61
+ /** Clones a single repo ("org/repo") to `dest` via `gh repo clone`. */
62
+ cloneRepo: (repo: string, dest: string) => Promise<void>;
63
+ /**
64
+ * Deletes a destination directory left behind by a failed clone, so the
65
+ * next tick's computeMissingClones() plans it again instead of treating the
66
+ * half-written directory as an already-cloned repo.
67
+ */
68
+ removePartialClone: (dest: string) => void;
69
+ /**
70
+ * Returns the owner of an existing clone's `origin` remote, or null when it
71
+ * can't be determined. Optional — when omitted, no owner check is made.
72
+ */
73
+ getRemoteOwner?: (dest: string) => string | null;
74
+ }
75
+
76
+ /**
77
+ * Destinations with a clone in flight right now. Module-scoped (not per-deps)
78
+ * because the hazard it guards is process-wide: index.ts drives this with
79
+ * `setInterval(() => void syncConfig(), 60_000)`, which never waits for the
80
+ * previous tick, so a clone slower than 60s would otherwise be started a
81
+ * second time by the next tick before `gh` has created the destination dir.
82
+ */
83
+ const inFlightClones = new Set<string>();
84
+
85
+ /**
86
+ * Clones every configured-but-missing repo onto disk via computeMissingClones()'s
87
+ * plan. Already-present repos are left untouched — this reuses
88
+ * computeMissingClones()'s own skip-if-exists logic rather than
89
+ * re-implementing it.
90
+ *
91
+ * A single repo's cloneRepo() failure is caught and logged; it never prevents
92
+ * the remaining repos in the plan from being attempted, and it never throws
93
+ * out of this function — callers (index.ts's syncConfig()) can call this
94
+ * every tick without a wrapping try/catch of their own being load-bearing.
95
+ */
96
+ export async function syncClonedRepos(
97
+ deps: SyncConfigCloneDeps,
98
+ ): Promise<void> {
99
+ const repos = deps.getScopedRepos();
100
+
101
+ // repos/<name> is keyed by basename only: skip the later-listed repo of any
102
+ // cross-owner name collision so it can never share (or push from) the
103
+ // earlier repo's folder.
104
+ const skipped = new Set<string>();
105
+ for (const { repo, collidesWith } of findRepoNameCollisions(repos)) {
106
+ skipped.add(repo);
107
+ console.error(
108
+ `[config-sync] repo name collision: ${repo} and ${collidesWith} share the folder name — skipping ${repo}`,
109
+ );
110
+ }
111
+
112
+ // An existing folder whose remote belongs to a different owner is the same
113
+ // hazard from the other direction (e.g. repos/<name> cloned for another org).
114
+ if (deps.getRemoteOwner) {
115
+ for (const repo of repos) {
116
+ if (skipped.has(repo)) continue;
117
+ const dest = join(deps.reposDir, repo.slice(repo.lastIndexOf("/") + 1));
118
+ if (!deps.exists(dest)) continue;
119
+ const actual = deps.getRemoteOwner(dest);
120
+ const expected = repo.slice(0, repo.lastIndexOf("/"));
121
+ if (actual && actual.toLowerCase() !== expected.toLowerCase()) {
122
+ skipped.add(repo);
123
+ console.error(
124
+ `[config-sync] repo name collision: ${dest} has a remote owned by ${actual}, not ${expected} — skipping ${repo}`,
125
+ );
126
+ }
127
+ }
128
+ }
129
+
130
+ const plan = computeMissingClones(
131
+ repos.filter((r) => !skipped.has(r)),
132
+ deps.reposDir,
133
+ deps.exists,
134
+ );
135
+
136
+ for (const { repo, dest } of plan) {
137
+ // An overlapping tick is already cloning this destination — skip rather
138
+ // than racing a second `gh repo clone` into the same directory.
139
+ if (inFlightClones.has(dest)) continue;
140
+ inFlightClones.add(dest);
141
+ try {
142
+ await deps.cloneRepo(repo, dest);
143
+ } catch (err) {
144
+ console.error(
145
+ `[config-sync] clone failed for ${repo}: ${
146
+ err instanceof Error ? err.message : String(err)
147
+ }`,
148
+ );
149
+ // Clear whatever the failed clone left on disk: a half-written
150
+ // directory still satisfies the planner's existence check and would
151
+ // otherwise wedge this repo as "cloned" forever.
152
+ try {
153
+ deps.removePartialClone(dest);
154
+ } catch (rmErr) {
155
+ console.error(
156
+ `[config-sync] failed to clean up partial clone at ${dest}: ${
157
+ rmErr instanceof Error ? rmErr.message : String(rmErr)
158
+ }`,
159
+ );
160
+ }
161
+ } finally {
162
+ inFlightClones.delete(dest);
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Builds the real `gh repo clone` shell-out. Async by design — see the module
169
+ * header: `Bun.spawnSync` here would block the event loop of the process that
170
+ * serves the health probes for the whole duration of the clone.
171
+ *
172
+ * The spawner is injectable so unit tests can assert the command, the options
173
+ * and the exit-code handling without a real `gh` invocation (mirrors
174
+ * claude.ts's `spawner: typeof Bun.spawn = Bun.spawn` parameter).
175
+ */
176
+ export function makeCloneRepo(
177
+ spawn: typeof Bun.spawn = Bun.spawn,
178
+ ): (repo: string, dest: string) => Promise<void> {
179
+ return async (repo: string, dest: string): Promise<void> => {
180
+ const proc = spawn(["gh", "repo", "clone", repo, dest], {
181
+ // env: process.env is required — Bun.spawn otherwise snapshots env at
182
+ // Bun startup and misses runtime mutations. This clone runs inside
183
+ // syncConfig()'s tick, right after Object.assign(process.env,
184
+ // bundle.env), so without this a freshly-synced/rotated GH_TOKEN never
185
+ // reaches `gh`. Mirrors setup.ts's defaultExec and cron-handler.ts,
186
+ // which document the same gotcha. SHIPWRIGHT_GH_OWNER (GOR-1.3) is the
187
+ // repo scope entry's org, layered over a fresh copy of process.env.
188
+ env: { ...process.env, SHIPWRIGHT_GH_OWNER: ownerOfRepo(repo) },
189
+ stdout: "inherit",
190
+ stderr: "inherit",
191
+ });
192
+ const exitCode = await proc.exited;
193
+ if (exitCode !== 0) {
194
+ throw new Error(`gh repo clone failed for ${repo} (exit ${exitCode})`);
195
+ }
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Production deps for syncClonedRepos(): real existsSync, real async `gh repo
201
+ * clone` shell-out, real recursive delete for partial clones. `reposDir` is
202
+ * passed in by the caller (index.ts, via join(config.paths.workspace,
203
+ * "repos") — the repos dir convention confirmed by setup.ts/
204
+ * worktree-reaper.ts/pr-state-reconciler.ts/check-helpers.ts) rather than
205
+ * re-resolved here, so this module stays free of any direct
206
+ * process.env/AGENT_HOME dependency of its own.
207
+ */
208
+ export function buildProductionDeps(opts: {
209
+ getScopedRepos: () => string[];
210
+ reposDir: string;
211
+ }): SyncConfigCloneDeps {
212
+ return {
213
+ getScopedRepos: opts.getScopedRepos,
214
+ reposDir: opts.reposDir,
215
+ exists: existsSync,
216
+ cloneRepo: makeCloneRepo(),
217
+ removePartialClone: (dest: string) =>
218
+ rmSync(dest, { recursive: true, force: true }),
219
+ getRemoteOwner: readRemoteOwner,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Parses the owner out of a clone's `origin` URL (https or ssh form); null
225
+ * when the folder has no readable origin.
226
+ */
227
+ export function readRemoteOwner(
228
+ dest: string,
229
+ spawnSync: typeof Bun.spawnSync = Bun.spawnSync,
230
+ ): string | null {
231
+ const proc = spawnSync(["git", "-C", dest, "remote", "get-url", "origin"]);
232
+ if (proc.exitCode !== 0) return null;
233
+ const m = proc.stdout
234
+ .toString()
235
+ .trim()
236
+ .match(/[:/]([^/:]+)\/[^/]+?(?:\.git)?$/);
237
+ return m?.[1] ?? null;
238
+ }