@app-vitals/agent-lib 0.0.1 → 0.1.1
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/chat-notify.ts +41 -0
- package/chat-notify.unit.test.ts +88 -0
- package/claim-ttl.ts +12 -0
- package/claim-ttl.unit.test.ts +26 -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-account.ts +5 -0
- package/default-account.unit.test.ts +8 -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/http.ts +14 -0
- package/http.unit.test.ts +42 -0
- package/org-repo.ts +9 -0
- package/org-repo.unit.test.ts +42 -0
- package/package-exports.unit.test.ts +21 -0
- package/package.json +36 -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
package/chat-notify.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/chat-notify.ts
|
|
3
|
+
* Shared type for the chat service's outbound reply-notification webhook
|
|
4
|
+
* (CFB-4.3) — imported by both the chat service (builds/sends the event) and,
|
|
5
|
+
* potentially, the admin service (receives it at POST /admin/push/notify).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately text-free: no message body/preview field. The payload is just
|
|
8
|
+
* enough for the admin side to look up subscriptions and render a generic
|
|
9
|
+
* "your agent replied" notification — the opt-in preview text (if any) is
|
|
10
|
+
* fetched separately by admin from the message itself, not carried here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Event fired once per newly-persisted agent reply. */
|
|
14
|
+
export interface ReplyNotificationEvent {
|
|
15
|
+
threadId: string;
|
|
16
|
+
agentId: string;
|
|
17
|
+
/** Thread title, or null when the thread has none. Never coerced to "". */
|
|
18
|
+
title: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parses `v` as a ReplyNotificationEvent, returning null on any shape
|
|
23
|
+
* mismatch (missing/wrong-typed fields). Used by callers that receive this
|
|
24
|
+
* payload over the wire and need to validate it before trusting the shape.
|
|
25
|
+
*/
|
|
26
|
+
export function parseReplyNotificationEvent(
|
|
27
|
+
v: unknown,
|
|
28
|
+
): ReplyNotificationEvent | null {
|
|
29
|
+
if (typeof v !== "object" || v === null) return null;
|
|
30
|
+
const obj = v as Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
if (typeof obj.threadId !== "string") return null;
|
|
33
|
+
if (typeof obj.agentId !== "string") return null;
|
|
34
|
+
if (typeof obj.title !== "string" && obj.title !== null) return null;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
threadId: obj.threadId,
|
|
38
|
+
agentId: obj.agentId,
|
|
39
|
+
title: obj.title,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type ReplyNotificationEvent,
|
|
4
|
+
parseReplyNotificationEvent,
|
|
5
|
+
} from "./chat-notify.ts";
|
|
6
|
+
|
|
7
|
+
describe("parseReplyNotificationEvent — valid shapes", () => {
|
|
8
|
+
test("parses a full event with a string title", () => {
|
|
9
|
+
const input = { threadId: "thread-1", agentId: "agent-1", title: "Hi" };
|
|
10
|
+
const result = parseReplyNotificationEvent(input);
|
|
11
|
+
expect(result).toEqual({
|
|
12
|
+
threadId: "thread-1",
|
|
13
|
+
agentId: "agent-1",
|
|
14
|
+
title: "Hi",
|
|
15
|
+
} satisfies ReplyNotificationEvent);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("parses an event with a null title", () => {
|
|
19
|
+
const input = { threadId: "thread-1", agentId: "agent-1", title: null };
|
|
20
|
+
const result = parseReplyNotificationEvent(input);
|
|
21
|
+
expect(result).toEqual({
|
|
22
|
+
threadId: "thread-1",
|
|
23
|
+
agentId: "agent-1",
|
|
24
|
+
title: null,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("parseReplyNotificationEvent — invalid shapes", () => {
|
|
30
|
+
test("rejects null", () => {
|
|
31
|
+
expect(parseReplyNotificationEvent(null)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("rejects a non-object", () => {
|
|
35
|
+
expect(parseReplyNotificationEvent("not an object")).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("rejects a missing threadId", () => {
|
|
39
|
+
expect(
|
|
40
|
+
parseReplyNotificationEvent({ agentId: "agent-1", title: null }),
|
|
41
|
+
).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("rejects a missing agentId", () => {
|
|
45
|
+
expect(
|
|
46
|
+
parseReplyNotificationEvent({ threadId: "thread-1", title: null }),
|
|
47
|
+
).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rejects a non-string threadId", () => {
|
|
51
|
+
expect(
|
|
52
|
+
parseReplyNotificationEvent({
|
|
53
|
+
threadId: 123,
|
|
54
|
+
agentId: "agent-1",
|
|
55
|
+
title: null,
|
|
56
|
+
}),
|
|
57
|
+
).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("rejects a non-string agentId", () => {
|
|
61
|
+
expect(
|
|
62
|
+
parseReplyNotificationEvent({
|
|
63
|
+
threadId: "thread-1",
|
|
64
|
+
agentId: 123,
|
|
65
|
+
title: null,
|
|
66
|
+
}),
|
|
67
|
+
).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("rejects a non-string, non-null title", () => {
|
|
71
|
+
expect(
|
|
72
|
+
parseReplyNotificationEvent({
|
|
73
|
+
threadId: "thread-1",
|
|
74
|
+
agentId: "agent-1",
|
|
75
|
+
title: 42,
|
|
76
|
+
}),
|
|
77
|
+
).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("rejects a missing title field", () => {
|
|
81
|
+
expect(
|
|
82
|
+
parseReplyNotificationEvent({
|
|
83
|
+
threadId: "thread-1",
|
|
84
|
+
agentId: "agent-1",
|
|
85
|
+
}),
|
|
86
|
+
).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
package/claim-ttl.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { DEFAULT_CLAUDE_TIMEOUT_MS } from "./claude-timeout.ts";
|
|
2
|
+
|
|
3
|
+
/** Extra headroom added on top of the session timeout (5min). */
|
|
4
|
+
export const CLAIM_TTL_BUFFER_MS = 300_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default claim TTL: how long a claim (Task or PR review/patch/deploy) remains
|
|
8
|
+
* valid without a heartbeat before it's eligible for reaping. Must exceed
|
|
9
|
+
* DEFAULT_CLAUDE_TIMEOUT_MS so a claim isn't reaped mid-session.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_CLAIM_TTL_MS =
|
|
12
|
+
DEFAULT_CLAUDE_TIMEOUT_MS + CLAIM_TTL_BUFFER_MS;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CLAIM_TTL_BUFFER_MS, DEFAULT_CLAIM_TTL_MS } from "./claim-ttl.ts";
|
|
3
|
+
import { DEFAULT_CLAUDE_TIMEOUT_MS } from "./claude-timeout.ts";
|
|
4
|
+
|
|
5
|
+
describe("claim-ttl constants", () => {
|
|
6
|
+
test("CLAIM_TTL_BUFFER_MS is 300_000 (5min)", () => {
|
|
7
|
+
expect(CLAIM_TTL_BUFFER_MS).toBe(300_000);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("DEFAULT_CLAIM_TTL_MS is 3_900_000 (65min)", () => {
|
|
11
|
+
expect(DEFAULT_CLAIM_TTL_MS).toBe(3_900_000);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("DEFAULT_CLAIM_TTL_MS is computed as the sum of the other two constants", () => {
|
|
15
|
+
expect(DEFAULT_CLAIM_TTL_MS).toBe(
|
|
16
|
+
DEFAULT_CLAUDE_TIMEOUT_MS + CLAIM_TTL_BUFFER_MS,
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Relocated from claude-timeout.unit.test.ts (AEP-4.1): claim-ttl is the
|
|
21
|
+
// task-store side, so the cross-module invariant belongs here and the
|
|
22
|
+
// platform-set lib test no longer imports it.
|
|
23
|
+
test("DEFAULT_CLAIM_TTL_MS exceeds DEFAULT_CLAUDE_TIMEOUT_MS", () => {
|
|
24
|
+
expect(DEFAULT_CLAIM_TTL_MS).toBeGreaterThan(DEFAULT_CLAUDE_TIMEOUT_MS);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -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,8 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "./default-account.ts";
|
|
3
|
+
|
|
4
|
+
describe("DEFAULT_ACCOUNT_ID", () => {
|
|
5
|
+
it("is the literal 'default' matching the migration's column default", () => {
|
|
6
|
+
expect(DEFAULT_ACCOUNT_ID).toBe("default");
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -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
|
+
}
|