@app-vitals/agent-runtime 0.0.0-stage → 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.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +40 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- 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
|
+
}
|