@app-vitals/agent-lib 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.
- package/admin-types.ts +4097 -0
- package/claude-timeout.ts +10 -0
- package/claude-timeout.unit.test.ts +8 -0
- package/cli-flags.ts +83 -0
- package/cli-flags.unit.test.ts +137 -0
- package/clone-plan.ts +73 -0
- package/clone-plan.unit.test.ts +69 -0
- package/default-agent-env.ts +15 -0
- package/default-agent-env.unit.test.ts +19 -0
- package/github-login.ts +20 -0
- package/github-login.unit.test.ts +78 -0
- package/graceful-shutdown.ts +143 -0
- package/graceful-shutdown.unit.test.ts +236 -0
- package/org-repo.ts +9 -0
- package/org-repo.unit.test.ts +42 -0
- package/package.json +37 -4
- package/pg-sslmode.ts +66 -0
- package/pg-sslmode.unit.test.ts +74 -0
- package/pool-observability.ts +209 -0
- package/pool-observability.unit.test.ts +233 -0
- package/pricing.ts +98 -0
- package/pricing.unit.test.ts +214 -0
- package/progress-phases.ts +58 -0
- package/progress-phases.unit.test.ts +50 -0
- package/request-context.ts +23 -0
- package/request-context.unit.test.ts +51 -0
- package/secret-env-vars.ts +41 -0
- package/secret-env-vars.unit.test.ts +62 -0
- package/sentry.ts +201 -0
- package/sentry.unit.test.ts +294 -0
- package/task-store-types.ts +3256 -0
- package/task-store-types.unit.test.ts +24 -0
- package/web/toolbar.ts +244 -0
- package/web/toolbar.unit.test.ts +243 -0
- package/README.md +0 -3
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `claude -p` hard ceiling timeout (1hr) — a backstop, not the primary
|
|
3
|
+
* timeout. The idle-reset timer (`SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS`, default
|
|
4
|
+
* 25min, see `agent/src/runtime/config.ts`) is cleared/restarted on every stdout line
|
|
5
|
+
* and is what fires for the vast majority of hung/stuck sessions. This
|
|
6
|
+
* ceiling only fires for a session that stays continuously active (so the
|
|
7
|
+
* idle timer keeps resetting) but never converges — e.g. a runaway loop that
|
|
8
|
+
* keeps emitting output without ever finishing.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_CLAUDE_TIMEOUT_MS = 3_600_000;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_CLAUDE_TIMEOUT_MS } from "./claude-timeout.ts";
|
|
3
|
+
|
|
4
|
+
describe("claude-timeout", () => {
|
|
5
|
+
test("DEFAULT_CLAUDE_TIMEOUT_MS is 3_600_000 (1hr hard ceiling)", () => {
|
|
6
|
+
expect(DEFAULT_CLAUDE_TIMEOUT_MS).toBe(3_600_000);
|
|
7
|
+
});
|
|
8
|
+
});
|
package/cli-flags.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/cli-flags.ts
|
|
3
|
+
* Generalized CLI flag parser: supports both `--flag value` and
|
|
4
|
+
* `--flag=value` syntax. Generalizes `agent/src/runtime/cli-args.ts`'s narrow,
|
|
5
|
+
* hand-rolled `parseCliArgs` (which only supports `--flag value` for exactly
|
|
6
|
+
* three flags) so any script can declare the flags it cares about.
|
|
7
|
+
*
|
|
8
|
+
* Pure argv parsing — no env var fallback. Beyond string flags it supports
|
|
9
|
+
* boolean (presence) flags and repeatable (multi-value) flags. Unknown flags (not declared in
|
|
10
|
+
* `flagSpecs`) are silently ignored, matching `parseCliArgs`'s tolerant
|
|
11
|
+
* behavior. Callers that want an env var fallback (see `parseCliArgs` for
|
|
12
|
+
* that pattern) layer it on top of this helper's result.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Parsed string-flag values, keyed by the exact flag string passed in `flagSpecs` (e.g. `"--agent-id"`). */
|
|
16
|
+
type ParsedFlags<Flags extends readonly string[]> = Record<
|
|
17
|
+
Flags[number],
|
|
18
|
+
string | undefined
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
/** Optional flag kinds beyond the plain `--flag value` string flags. */
|
|
22
|
+
interface FlagKinds<
|
|
23
|
+
Bools extends readonly string[],
|
|
24
|
+
Multi extends readonly string[],
|
|
25
|
+
> {
|
|
26
|
+
/** Presence-only flags: `true` when passed (bare, or `--flag=true`/`--flag=false`), else `false`. Never consume the next token. */
|
|
27
|
+
booleans?: Bools;
|
|
28
|
+
/** Repeatable value flags: every occurrence is collected in order; `[]` when absent. */
|
|
29
|
+
multi?: Multi;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ParsedKinds<
|
|
33
|
+
Bools extends readonly string[],
|
|
34
|
+
Multi extends readonly string[],
|
|
35
|
+
> = Record<Bools[number], boolean> & Record<Multi[number], string[]>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parses `argv` for the flags declared in `flagSpecs`, supporting both
|
|
39
|
+
* `--flag value` and `--flag=value` syntax. Flags not declared in
|
|
40
|
+
* `flagSpecs` are ignored without error. A declared flag absent from `argv`
|
|
41
|
+
* (or a `--flag value` form flag with no following value) resolves to
|
|
42
|
+
* `undefined`.
|
|
43
|
+
*
|
|
44
|
+
* `kinds.booleans` and `kinds.multi` declare additional flag kinds, returned
|
|
45
|
+
* alongside the string flags (see `FlagKinds`).
|
|
46
|
+
*/
|
|
47
|
+
export function parseFlags<
|
|
48
|
+
const Flags extends readonly string[],
|
|
49
|
+
const Bools extends readonly string[] = readonly [],
|
|
50
|
+
const Multi extends readonly string[] = readonly [],
|
|
51
|
+
>(
|
|
52
|
+
argv: readonly string[],
|
|
53
|
+
flagSpecs: Flags,
|
|
54
|
+
kinds: FlagKinds<Bools, Multi> = {},
|
|
55
|
+
): ParsedFlags<Flags> & ParsedKinds<Bools, Multi> {
|
|
56
|
+
const strings = new Set<string>(flagSpecs);
|
|
57
|
+
const booleans = new Set<string>(kinds.booleans ?? []);
|
|
58
|
+
const multi = new Set<string>(kinds.multi ?? []);
|
|
59
|
+
const result: Record<string, string | boolean | string[] | undefined> = {};
|
|
60
|
+
for (const flag of strings) result[flag] = undefined;
|
|
61
|
+
for (const flag of booleans) result[flag] = false;
|
|
62
|
+
for (const flag of multi) result[flag] = [];
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < argv.length; i++) {
|
|
65
|
+
const arg = argv[i];
|
|
66
|
+
|
|
67
|
+
const eqIdx = arg.indexOf("=");
|
|
68
|
+
const flag = eqIdx === -1 ? arg : arg.slice(0, eqIdx);
|
|
69
|
+
const inline = eqIdx === -1 ? undefined : arg.slice(eqIdx + 1);
|
|
70
|
+
|
|
71
|
+
if (booleans.has(flag)) {
|
|
72
|
+
result[flag] = inline === undefined ? true : inline !== "false";
|
|
73
|
+
} else if (strings.has(flag) || multi.has(flag)) {
|
|
74
|
+
const value = inline ?? argv[i + 1];
|
|
75
|
+
if (value === undefined) continue;
|
|
76
|
+
if (inline === undefined) i++;
|
|
77
|
+
if (multi.has(flag)) (result[flag] as string[]).push(value);
|
|
78
|
+
else result[flag] = value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result as ParsedFlags<Flags> & ParsedKinds<Bools, Multi>;
|
|
83
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/cli-flags.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for the generalized CLI flag parser.
|
|
5
|
+
* Pure logic — no I/O, no env side effects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { parseFlags } from "./cli-flags.ts";
|
|
10
|
+
|
|
11
|
+
describe("parseFlags — `--flag value` syntax", () => {
|
|
12
|
+
test("parses a single flag", () => {
|
|
13
|
+
const result = parseFlags(["--agent-id", "agent-123"], ["--agent-id"]);
|
|
14
|
+
expect(result["--agent-id"]).toBe("agent-123");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("parses multiple flags together", () => {
|
|
18
|
+
const result = parseFlags(
|
|
19
|
+
["--agent-id", "agent-abc", "--api-url", "https://api.test.com"],
|
|
20
|
+
["--agent-id", "--api-url", "--api-key"],
|
|
21
|
+
);
|
|
22
|
+
expect(result["--agent-id"]).toBe("agent-abc");
|
|
23
|
+
expect(result["--api-url"]).toBe("https://api.test.com");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("parseFlags — `--flag=value` syntax", () => {
|
|
28
|
+
test("parses a single flag", () => {
|
|
29
|
+
const result = parseFlags(["--db-url=postgres://localhost"], ["--db-url"]);
|
|
30
|
+
expect(result["--db-url"]).toBe("postgres://localhost");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("parses multiple flags together", () => {
|
|
34
|
+
const result = parseFlags(
|
|
35
|
+
["--agent-id=agent-abc", "--api-url=https://api.test.com"],
|
|
36
|
+
["--agent-id", "--api-url"],
|
|
37
|
+
);
|
|
38
|
+
expect(result["--agent-id"]).toBe("agent-abc");
|
|
39
|
+
expect(result["--api-url"]).toBe("https://api.test.com");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("handles an empty value after the equals sign", () => {
|
|
43
|
+
const result = parseFlags(["--db-url="], ["--db-url"]);
|
|
44
|
+
expect(result["--db-url"]).toBe("");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("parseFlags — mixed syntax", () => {
|
|
49
|
+
test("parses one flag in `--flag value` form and another in `--flag=value` form", () => {
|
|
50
|
+
const result = parseFlags(
|
|
51
|
+
["--agent-id", "agent-abc", "--api-url=https://api.test.com"],
|
|
52
|
+
["--agent-id", "--api-url"],
|
|
53
|
+
);
|
|
54
|
+
expect(result["--agent-id"]).toBe("agent-abc");
|
|
55
|
+
expect(result["--api-url"]).toBe("https://api.test.com");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("parseFlags — missing flag", () => {
|
|
60
|
+
test("returns undefined for a declared flag that is not present in argv", () => {
|
|
61
|
+
const result = parseFlags(
|
|
62
|
+
["--agent-id", "agent-abc"],
|
|
63
|
+
["--agent-id", "--api-url"],
|
|
64
|
+
);
|
|
65
|
+
expect(result["--api-url"]).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns undefined for every declared flag when argv is empty", () => {
|
|
69
|
+
const result = parseFlags([], ["--agent-id", "--api-url"]);
|
|
70
|
+
expect(result["--agent-id"]).toBeUndefined();
|
|
71
|
+
expect(result["--api-url"]).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns undefined when a `--flag value` form flag has no following value (end of argv)", () => {
|
|
75
|
+
const result = parseFlags(["--agent-id"], ["--agent-id"]);
|
|
76
|
+
expect(result["--agent-id"]).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("parseFlags — unknown flags are ignored (tolerant behavior)", () => {
|
|
81
|
+
test("skips a flag not declared in flagSpecs", () => {
|
|
82
|
+
const result = parseFlags(
|
|
83
|
+
["--unknown", "value", "--agent-id", "x"],
|
|
84
|
+
["--agent-id"],
|
|
85
|
+
);
|
|
86
|
+
expect(result["--agent-id"]).toBe("x");
|
|
87
|
+
expect(result).not.toHaveProperty("--unknown");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("skips an unknown flag in `--flag=value` form", () => {
|
|
91
|
+
const result = parseFlags(
|
|
92
|
+
["--unknown=value", "--agent-id=x"],
|
|
93
|
+
["--agent-id"],
|
|
94
|
+
);
|
|
95
|
+
expect(result["--agent-id"]).toBe("x");
|
|
96
|
+
expect(result).not.toHaveProperty("--unknown");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("parseFlags — boolean flags", () => {
|
|
101
|
+
test("true when present, false when absent, never consumes next token", () => {
|
|
102
|
+
const r = parseFlags(["--dry-run", "--name", "x"], ["--name"], {
|
|
103
|
+
booleans: ["--dry-run", "--verbose"],
|
|
104
|
+
});
|
|
105
|
+
expect(r["--dry-run"]).toBe(true);
|
|
106
|
+
expect(r["--verbose"]).toBe(false);
|
|
107
|
+
expect(r["--name"]).toBe("x");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("honours --flag=false", () => {
|
|
111
|
+
const r = parseFlags(["--dry-run=false"], [], { booleans: ["--dry-run"] });
|
|
112
|
+
expect(r["--dry-run"]).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("parseFlags — repeatable flags", () => {
|
|
117
|
+
test("collects every occurrence in order, both syntaxes", () => {
|
|
118
|
+
const r = parseFlags(["--repo", "a/b", "--repo=c/d", "--repo", "e/f"], [], {
|
|
119
|
+
multi: ["--repo"],
|
|
120
|
+
});
|
|
121
|
+
expect(r["--repo"]).toEqual(["a/b", "c/d", "e/f"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("empty array when absent; trailing flag without value is skipped", () => {
|
|
125
|
+
expect(parseFlags([], [], { multi: ["--repo"] })["--repo"]).toEqual([]);
|
|
126
|
+
expect(parseFlags(["--repo"], [], { multi: ["--repo"] })["--repo"]).toEqual(
|
|
127
|
+
[],
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("string-only call shape is unchanged", () => {
|
|
132
|
+
expect(parseFlags(["--a", "1"], ["--a", "--b"])).toEqual({
|
|
133
|
+
"--a": "1",
|
|
134
|
+
"--b": undefined,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
package/clone-plan.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/clone-plan.ts
|
|
3
|
+
* Pure planning helper for "which repos still need `gh repo clone`" —
|
|
4
|
+
* shared by scripts/hitl.ts (local dev-loop bootstrap),
|
|
5
|
+
* scripts/agent-workspace-pull.ts (mirrors a real agent's repos locally),
|
|
6
|
+
* and agent/src/runtime/sync-config-clone.ts (the deployed agent's config-sync
|
|
7
|
+
* auto-clone step).
|
|
8
|
+
*
|
|
9
|
+
* Extracted out of hitl.ts (AWP-1.1) so neither script reaches into the
|
|
10
|
+
* other's module — both import this shared helper instead. Lives in `lib/`
|
|
11
|
+
* (not `scripts/`) because the deployed agent imports it too and the
|
|
12
|
+
* runtime stage of agent/Dockerfile copies `lib/` but not `scripts/` — a
|
|
13
|
+
* `scripts/` import would resolve locally and crash-loop in the container.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Given the configured "org/repo" list, the repos dir, and an injectable
|
|
20
|
+
* existence check, reports which repos still need cloning (and their
|
|
21
|
+
* destination path). Repos already present under reposDir are left
|
|
22
|
+
* untouched. Kept side-effect free so it's unit-testable without touching
|
|
23
|
+
* the filesystem or network.
|
|
24
|
+
*/
|
|
25
|
+
export function computeMissingClones(
|
|
26
|
+
repos: string[],
|
|
27
|
+
reposDir: string,
|
|
28
|
+
exists: (path: string) => boolean,
|
|
29
|
+
): { repo: string; dest: string }[] {
|
|
30
|
+
return repos
|
|
31
|
+
.map((repo) => ({
|
|
32
|
+
repo,
|
|
33
|
+
dest: join(reposDir, repo.slice(repo.lastIndexOf("/") + 1)),
|
|
34
|
+
}))
|
|
35
|
+
.filter(({ dest }) => !exists(dest));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RepoNameCollision {
|
|
39
|
+
/** The later-listed repo — the one a sync should skip. */
|
|
40
|
+
repo: string;
|
|
41
|
+
/** The earlier-listed repo that already claimed the `repos/<name>` folder. */
|
|
42
|
+
collidesWith: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function splitRepo(repo: string): { owner: string; name: string } {
|
|
46
|
+
const i = repo.lastIndexOf("/");
|
|
47
|
+
return {
|
|
48
|
+
owner: repo.slice(0, i).toLowerCase(),
|
|
49
|
+
name: repo.slice(i + 1).toLowerCase(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reports repos whose basename matches an earlier-listed repo under a
|
|
55
|
+
* different owner (case-insensitive). `repos/<name>` is keyed by basename
|
|
56
|
+
* alone, so two such repos would share one folder and a clone/push could
|
|
57
|
+
* land on the wrong customer's repo. The later-listed repo is the collider;
|
|
58
|
+
* identical repos (same owner and name) are not collisions.
|
|
59
|
+
*/
|
|
60
|
+
export function findRepoNameCollisions(repos: string[]): RepoNameCollision[] {
|
|
61
|
+
const firstByName = new Map<string, { repo: string; owner: string }>();
|
|
62
|
+
const collisions: RepoNameCollision[] = [];
|
|
63
|
+
for (const repo of repos) {
|
|
64
|
+
const { owner, name } = splitRepo(repo);
|
|
65
|
+
const first = firstByName.get(name);
|
|
66
|
+
if (!first) {
|
|
67
|
+
firstByName.set(name, { repo, owner });
|
|
68
|
+
} else if (first.owner !== owner) {
|
|
69
|
+
collisions.push({ repo, collidesWith: first.repo });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return collisions;
|
|
73
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/clone-plan.unit.test.ts
|
|
3
|
+
* Unit tests for computeMissingClones() — relocated from hitl.unit.test.ts
|
|
4
|
+
* (AWP-1.1) when the function was extracted out of scripts/hitl.ts into this
|
|
5
|
+
* shared module. Same assertions, moved not duplicated.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { computeMissingClones, findRepoNameCollisions } from "./clone-plan.ts";
|
|
10
|
+
|
|
11
|
+
describe("computeMissingClones", () => {
|
|
12
|
+
test("returns [] for an empty repos list", () => {
|
|
13
|
+
expect(computeMissingClones([], "/ws/repos", () => false)).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("skips repos already cloned under reposDir", () => {
|
|
17
|
+
const existing = new Set(["/ws/repos/shipwright"]);
|
|
18
|
+
const missing = computeMissingClones(
|
|
19
|
+
["app-vitals/shipwright"],
|
|
20
|
+
"/ws/repos",
|
|
21
|
+
(path) => existing.has(path),
|
|
22
|
+
);
|
|
23
|
+
expect(missing).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("includes missing repos with the correct dest path", () => {
|
|
27
|
+
const missing = computeMissingClones(
|
|
28
|
+
["app-vitals/shipwright"],
|
|
29
|
+
"/ws/repos",
|
|
30
|
+
() => false,
|
|
31
|
+
);
|
|
32
|
+
expect(missing).toEqual([
|
|
33
|
+
{ repo: "app-vitals/shipwright", dest: "/ws/repos/shipwright" },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("mix of already-cloned and missing repos — only missing ones are returned", () => {
|
|
38
|
+
const existing = new Set(["/ws/repos/shipwright"]);
|
|
39
|
+
const missing = computeMissingClones(
|
|
40
|
+
["app-vitals/shipwright", "some-org/other-repo"],
|
|
41
|
+
"/ws/repos",
|
|
42
|
+
(path) => existing.has(path),
|
|
43
|
+
);
|
|
44
|
+
expect(missing).toEqual([
|
|
45
|
+
{ repo: "some-org/other-repo", dest: "/ws/repos/other-repo" },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("findRepoNameCollisions", () => {
|
|
51
|
+
test("reports the later-listed same-basename repo under a different owner", () => {
|
|
52
|
+
expect(
|
|
53
|
+
findRepoNameCollisions(["acme/api", "other/web", "globex/api"]),
|
|
54
|
+
).toEqual([{ repo: "globex/api", collidesWith: "acme/api" }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("is case-insensitive on owner and name", () => {
|
|
58
|
+
expect(findRepoNameCollisions(["Acme/API", "globex/api"])).toEqual([
|
|
59
|
+
{ repo: "globex/api", collidesWith: "Acme/API" },
|
|
60
|
+
]);
|
|
61
|
+
expect(findRepoNameCollisions(["Acme/api", "acme/API"])).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("ignores identical repos and distinct names", () => {
|
|
65
|
+
expect(findRepoNameCollisions(["acme/api", "acme/api"])).toEqual([]);
|
|
66
|
+
expect(findRepoNameCollisions(["acme/api", "acme/web"])).toEqual([]);
|
|
67
|
+
expect(findRepoNameCollisions([])).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/default-agent-env.ts
|
|
3
|
+
* Non-secret env vars seeded onto every newly created agent so they're visible
|
|
4
|
+
* (and editable) in the agent's env view instead of being an invisible runtime
|
|
5
|
+
* fallback. Secrets (CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN, ...) are never defaulted
|
|
6
|
+
* here — see secret-env-vars.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Model used when an agent has no ANTHROPIC_MODEL set. */
|
|
10
|
+
export const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-5-5";
|
|
11
|
+
|
|
12
|
+
/** Env vars written to a new agent's AgentEnv at creation. Operator-supplied values win. */
|
|
13
|
+
export const DEFAULT_AGENT_ENV: Readonly<Record<string, string>> = {
|
|
14
|
+
ANTHROPIC_MODEL: DEFAULT_ANTHROPIC_MODEL,
|
|
15
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { DEFAULT_AGENT_ENV } from "./default-agent-env.ts";
|
|
3
|
+
import { SECRET_ENV_VARS } from "./secret-env-vars.ts";
|
|
4
|
+
|
|
5
|
+
describe("DEFAULT_AGENT_ENV", () => {
|
|
6
|
+
it("never defaults a secret-shaped env var", () => {
|
|
7
|
+
for (const key of Object.keys(DEFAULT_AGENT_ENV)) {
|
|
8
|
+
expect(SECRET_ENV_VARS as readonly string[]).not.toContain(key);
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("defaults ANTHROPIC_MODEL to a non-empty value", () => {
|
|
13
|
+
expect(DEFAULT_AGENT_ENV.ANTHROPIC_MODEL).toMatch(/^claude-/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("defaults ANTHROPIC_MODEL to claude-sonnet-5-5", () => {
|
|
17
|
+
expect(DEFAULT_AGENT_ENV.ANTHROPIC_MODEL).toBe("claude-sonnet-5-5");
|
|
18
|
+
});
|
|
19
|
+
});
|
package/github-login.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when `s` is a valid GitHub login: alphanumeric characters and
|
|
3
|
+
* single hyphens only, no leading/trailing hyphen, no consecutive hyphens,
|
|
4
|
+
* and a max length of 39 characters (GitHub's own username constraints).
|
|
5
|
+
*
|
|
6
|
+
* Also accepts GitHub App bot logins in the format "app/<slug>", where slug
|
|
7
|
+
* follows the same validation rules as regular logins.
|
|
8
|
+
*/
|
|
9
|
+
export function isGithubLogin(s: string): boolean {
|
|
10
|
+
const LOGIN_PATTERN = /^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/;
|
|
11
|
+
|
|
12
|
+
if (s.startsWith("app/")) {
|
|
13
|
+
const slug = s.slice(4);
|
|
14
|
+
return slug.length > 0 && slug.length <= 39 && LOGIN_PATTERN.test(slug);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
s.length > 0 && s.length <= 39 && LOGIN_PATTERN.test(s)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isGithubLogin } from "./github-login.ts";
|
|
3
|
+
|
|
4
|
+
describe("isGithubLogin — valid strings", () => {
|
|
5
|
+
test("returns true for a simple login", () => {
|
|
6
|
+
expect(isGithubLogin("octocat")).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("returns true for a login with hyphens and numbers", () => {
|
|
10
|
+
expect(isGithubLogin("my-org-1")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns true for a single character login", () => {
|
|
14
|
+
expect(isGithubLogin("a")).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("returns true for a login at the max length of 39 characters", () => {
|
|
18
|
+
expect(isGithubLogin("a".repeat(39))).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("returns true for app/renovate bot login", () => {
|
|
22
|
+
expect(isGithubLogin("app/renovate")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("returns true for app/dependabot bot login", () => {
|
|
26
|
+
expect(isGithubLogin("app/dependabot")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("returns true for app/<slug> with hyphens", () => {
|
|
30
|
+
expect(isGithubLogin("app/my-bot-name")).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("isGithubLogin — invalid strings", () => {
|
|
35
|
+
test("returns false for an empty string", () => {
|
|
36
|
+
expect(isGithubLogin("")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns false for a login over 39 characters", () => {
|
|
40
|
+
expect(isGithubLogin("a".repeat(40))).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns false for a login with a leading hyphen", () => {
|
|
44
|
+
expect(isGithubLogin("-octocat")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns false for a login with a trailing hyphen", () => {
|
|
48
|
+
expect(isGithubLogin("octocat-")).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns false for a login with consecutive hyphens", () => {
|
|
52
|
+
expect(isGithubLogin("octo--cat")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns false for a login containing an underscore", () => {
|
|
56
|
+
expect(isGithubLogin("octo_cat")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns false for a login containing a space", () => {
|
|
60
|
+
expect(isGithubLogin("octo cat")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns false for a login containing a slash", () => {
|
|
64
|
+
expect(isGithubLogin("octo/cat")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns false for app/ with no slug", () => {
|
|
68
|
+
expect(isGithubLogin("app/")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("returns false for app/<slug> with leading hyphen in slug", () => {
|
|
72
|
+
expect(isGithubLogin("app/-bad")).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("returns false for app/<slug> with double slash", () => {
|
|
76
|
+
expect(isGithubLogin("app//x")).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/graceful-shutdown.ts
|
|
3
|
+
*
|
|
4
|
+
* Wires SIGTERM/SIGINT into a coordinated shutdown for a `Bun.serve()`-backed
|
|
5
|
+
* service: stop accepting new connections (letting in-flight requests
|
|
6
|
+
* finish), run ordered cleanup (e.g. `prisma.$disconnect()`), then exit.
|
|
7
|
+
*
|
|
8
|
+
* A hard deadline forces a non-graceful `server.stop(true)` + `exit(1)` so a
|
|
9
|
+
* stuck cleanup fn can never outlive the pod's terminationGracePeriod (30s in
|
|
10
|
+
* the Helm chart — see DBE-1.x/DBE-3.1, out of scope here).
|
|
11
|
+
*
|
|
12
|
+
* Every I/O-adjacent collaborator — the server, the signal source, the exit
|
|
13
|
+
* call, and the deadline timer — is injectable so this is unit-testable with
|
|
14
|
+
* fakes: no real signals sent, no real waiting, no `mock.module()` / global
|
|
15
|
+
* overrides (see CLAUDE.md's test isolation rule).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Below the pod's terminationGracePeriod (30s) so a stuck cleanup can't outlive it. */
|
|
19
|
+
const DEFAULT_HARD_DEADLINE_MS = 20_000;
|
|
20
|
+
|
|
21
|
+
/** Mirrors Bun.serve()'s return value: stop() waits for in-flight requests, stop(true) force-closes. */
|
|
22
|
+
interface ShutdownServer {
|
|
23
|
+
stop(closeActiveConnections?: boolean): unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Run in order on shutdown; a throw is logged and does not block later cleanup fns or the final exit. */
|
|
27
|
+
type CleanupFn = () => Promise<void>;
|
|
28
|
+
|
|
29
|
+
/** Narrowed to the one method this module needs — satisfied by the real `process`. */
|
|
30
|
+
interface SignalSource {
|
|
31
|
+
on(event: "SIGTERM" | "SIGINT", listener: () => void): unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface GracefulShutdownLogger {
|
|
35
|
+
log: (...args: unknown[]) => void;
|
|
36
|
+
error: (...args: unknown[]) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface GracefulShutdownOptions {
|
|
40
|
+
/** The Bun.serve() return value (or anything shaped like it). */
|
|
41
|
+
server: ShutdownServer;
|
|
42
|
+
/** Cleanup fns run sequentially, in order, after the server stops accepting new work. */
|
|
43
|
+
cleanup: CleanupFn[];
|
|
44
|
+
/** Forces stop(true) + exit(1) if the graceful path hasn't finished by then. Default 20s. */
|
|
45
|
+
hardDeadlineMs?: number;
|
|
46
|
+
/** Defaults to the real `process.exit`. */
|
|
47
|
+
exit?: (code: number) => void;
|
|
48
|
+
/** Defaults to the real global `setTimeout`. Injectable so tests can trigger the deadline manually. */
|
|
49
|
+
setTimeoutFn?: (fn: () => void, ms: number) => unknown;
|
|
50
|
+
/** Defaults to the real global `clearTimeout`. */
|
|
51
|
+
clearTimeoutFn?: (handle: unknown) => void;
|
|
52
|
+
/** Defaults to the real `process`. Injectable so tests can fire signals without touching the real process. */
|
|
53
|
+
process?: SignalSource;
|
|
54
|
+
logger?: GracefulShutdownLogger;
|
|
55
|
+
/** Used only to prefix log lines, e.g. "admin", "task-store", "chat". */
|
|
56
|
+
serviceName?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface GracefulShutdown {
|
|
60
|
+
/** True once a shutdown signal has been received — gate readiness probes on this. */
|
|
61
|
+
isShuttingDown: () => boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function registerGracefulShutdown(
|
|
65
|
+
options: GracefulShutdownOptions,
|
|
66
|
+
): GracefulShutdown {
|
|
67
|
+
const {
|
|
68
|
+
server,
|
|
69
|
+
cleanup,
|
|
70
|
+
hardDeadlineMs = DEFAULT_HARD_DEADLINE_MS,
|
|
71
|
+
exit = process.exit.bind(process),
|
|
72
|
+
// Wrapped (rather than assigned the raw global) so the interface can
|
|
73
|
+
// stay in terms of `unknown` for the timer handle without TypeScript
|
|
74
|
+
// narrowing these bindings to the global setTimeout/clearTimeout
|
|
75
|
+
// overloads' own parameter types.
|
|
76
|
+
setTimeoutFn = (fn: () => void, ms: number): unknown => setTimeout(fn, ms),
|
|
77
|
+
clearTimeoutFn = (handle: unknown): void =>
|
|
78
|
+
clearTimeout(handle as Parameters<typeof clearTimeout>[0]),
|
|
79
|
+
process: signalSource = process,
|
|
80
|
+
logger = console,
|
|
81
|
+
serviceName = "service",
|
|
82
|
+
} = options;
|
|
83
|
+
|
|
84
|
+
let shuttingDown = false;
|
|
85
|
+
let settled = false;
|
|
86
|
+
|
|
87
|
+
// Declared via `let` (not inline in the setTimeoutFn call) so a fake
|
|
88
|
+
// setTimeoutFn that invokes its callback synchronously never hits a
|
|
89
|
+
// temporal-dead-zone reference to this variable.
|
|
90
|
+
let deadlineHandle: unknown;
|
|
91
|
+
|
|
92
|
+
const finish = (code: number) => {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
settled = true;
|
|
95
|
+
clearTimeoutFn(deadlineHandle);
|
|
96
|
+
exit(code);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleSignal = (signal: string) => {
|
|
100
|
+
if (shuttingDown) return;
|
|
101
|
+
shuttingDown = true;
|
|
102
|
+
|
|
103
|
+
logger.log(`[${serviceName}] received ${signal}, shutting down gracefully`);
|
|
104
|
+
|
|
105
|
+
deadlineHandle = setTimeoutFn(() => {
|
|
106
|
+
logger.error(
|
|
107
|
+
`[${serviceName}] graceful shutdown exceeded ${hardDeadlineMs}ms — forcing stop`,
|
|
108
|
+
);
|
|
109
|
+
try {
|
|
110
|
+
server.stop(true);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
logger.error(`[${serviceName}] error force-stopping server:`, err);
|
|
113
|
+
}
|
|
114
|
+
finish(1);
|
|
115
|
+
}, hardDeadlineMs);
|
|
116
|
+
|
|
117
|
+
void (async () => {
|
|
118
|
+
try {
|
|
119
|
+
await server.stop();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
logger.error(`[${serviceName}] error stopping server:`, err);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const fn of cleanup) {
|
|
125
|
+
try {
|
|
126
|
+
await fn();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
logger.error(`[${serviceName}] cleanup fn failed:`, err);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
logger.log(`[${serviceName}] graceful shutdown complete`);
|
|
133
|
+
finish(0);
|
|
134
|
+
})();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
signalSource.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
138
|
+
signalSource.on("SIGINT", () => handleSignal("SIGINT"));
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
isShuttingDown: () => shuttingDown,
|
|
142
|
+
};
|
|
143
|
+
}
|