@app-vitals/agent-runtime 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +38 -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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/cleanup-after-days-ref.ts
|
|
3
|
+
*
|
|
4
|
+
* A tiny mutable box holding the agent's most recently synced
|
|
5
|
+
* `cleanupAfterDays` policy field (APM-1.6), so downstream consumers
|
|
6
|
+
* (check-helpers.ts's readCleanupAfterDays()) can read a live view without
|
|
7
|
+
* closing over a single syncConfig() tick. Kept pure and zero-I/O so it's
|
|
8
|
+
* unit-testable in isolation — mirrors allow-self-review-ref.ts's style
|
|
9
|
+
* exactly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface CleanupAfterDaysRef {
|
|
13
|
+
/**
|
|
14
|
+
* Returns the most recently synced `cleanupAfterDays` value, or `14` if
|
|
15
|
+
* set() was never called. The underlying Agent row field defaults to `14`
|
|
16
|
+
* in the database (admin/prisma/schema/), so once synced this
|
|
17
|
+
* always reflects a real DB value — callers that need to distinguish
|
|
18
|
+
* "never synced" (config-bundle fetch has never succeeded) from "synced
|
|
19
|
+
* to a DB value of 14" must check hasSynced() rather than inferring it
|
|
20
|
+
* from this return value, since both states return 14.
|
|
21
|
+
*/
|
|
22
|
+
get(): number;
|
|
23
|
+
/**
|
|
24
|
+
* True once set() has been called at least once. readCleanupAfterDays()
|
|
25
|
+
* returns its hardcoded default while this is false (config-sync has
|
|
26
|
+
* never succeeded, e.g. the fetch keeps failing).
|
|
27
|
+
*/
|
|
28
|
+
hasSynced(): boolean;
|
|
29
|
+
/** Replaces the current cleanupAfterDays value. */
|
|
30
|
+
set(cleanupAfterDays: number): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Creates a new, independent cleanup-after-days ref defaulting to unsynced/14. */
|
|
34
|
+
export function createCleanupAfterDaysRef(): CleanupAfterDaysRef {
|
|
35
|
+
let cleanupAfterDays = 14;
|
|
36
|
+
let synced = false;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
get(): number {
|
|
40
|
+
return cleanupAfterDays;
|
|
41
|
+
},
|
|
42
|
+
hasSynced(): boolean {
|
|
43
|
+
return synced;
|
|
44
|
+
},
|
|
45
|
+
set(next: number): void {
|
|
46
|
+
cleanupAfterDays = next;
|
|
47
|
+
synced = true;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The process-wide cleanup-after-days ref. agent/src/index.ts's syncConfig()
|
|
54
|
+
* writes into this on every successful config-sync tick; check-helpers.ts's
|
|
55
|
+
* readCleanupAfterDays() reads it with a hardcoded default when unsynced, so a DB-side change (via PATCH /agents/:id) takes
|
|
56
|
+
* effect on the very next config-sync tick without requiring an agent
|
|
57
|
+
* restart. If the agent's config bundle never becomes available,
|
|
58
|
+
* hasSynced() stays false for the process lifetime — readers must
|
|
59
|
+
* return their hardcoded default in that case.
|
|
60
|
+
*/
|
|
61
|
+
export const cleanupAfterDaysRef: CleanupAfterDaysRef =
|
|
62
|
+
createCleanupAfterDaysRef();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read the `cleanup_after_days` policy field: the synced DB value from
|
|
66
|
+
* `configRef` (default: the process-wide `cleanupAfterDaysRef`), else the
|
|
67
|
+
* hardcoded default `14` while the ref has never synced.
|
|
68
|
+
*
|
|
69
|
+
* WTR-1.4: reused as the staleness threshold gating the force-removal of a
|
|
70
|
+
* merged/closed PR's worktree (see `removeWorktree`'s production
|
|
71
|
+
* implementation in pr-state-reconciler.ts) — the same field that governs
|
|
72
|
+
* "remove worktrees older than N days, even if the PR is still open" also
|
|
73
|
+
* governs "don't force-remove a worktree younger than N days even if its PR
|
|
74
|
+
* just merged/closed", so a worktree still being actively worked on by a
|
|
75
|
+
* concurrent dev-task/patch/deploy session isn't yanked out from under it
|
|
76
|
+
* purely on remote GitHub state.
|
|
77
|
+
*/
|
|
78
|
+
export async function readCleanupAfterDays(
|
|
79
|
+
_workspacePath: string,
|
|
80
|
+
configRef: CleanupAfterDaysRef = cleanupAfterDaysRef,
|
|
81
|
+
): Promise<number> {
|
|
82
|
+
return configRef.hasSynced() ? configRef.get() : 14;
|
|
83
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/cleanup-after-days-ref.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for createCleanupAfterDaysRef() — pure logic, no I/O.
|
|
5
|
+
* Mirrors allow-self-review-ref.unit.test.ts's coverage pattern.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately does NOT exercise the process-wide `cleanupAfterDaysRef`
|
|
8
|
+
* singleton's set() here (unlike allow-self-review-ref.unit.test.ts) — once
|
|
9
|
+
* set() is called, hasSynced() can never be un-set, so mutating the shared
|
|
10
|
+
* singleton would permanently poison it for the rest of this bun test
|
|
11
|
+
* process (this repo's test-isolation hard rule: "Bun shares the test
|
|
12
|
+
* process, so leaked globals break sibling suites"). That would silently
|
|
13
|
+
* break check-helpers.unit.test.ts's readCleanupAfterDays() "no injected
|
|
14
|
+
* ref" tests, which rely on the singleton staying unsynced.
|
|
15
|
+
* createCleanupAfterDaysRef() exercises identical get/hasSynced/set logic on
|
|
16
|
+
* an independent instance, so coverage is unaffected.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it, test } from "bun:test";
|
|
20
|
+
import {
|
|
21
|
+
createCleanupAfterDaysRef,
|
|
22
|
+
readCleanupAfterDays,
|
|
23
|
+
} from "./cleanup-after-days-ref.ts";
|
|
24
|
+
|
|
25
|
+
describe("createCleanupAfterDaysRef", () => {
|
|
26
|
+
it("initial get() before any set() returns 14 (matches the DB/file default)", () => {
|
|
27
|
+
const ref = createCleanupAfterDaysRef();
|
|
28
|
+
expect(ref.get()).toBe(14);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("hasSynced() is false before any set() call", () => {
|
|
32
|
+
const ref = createCleanupAfterDaysRef();
|
|
33
|
+
expect(ref.hasSynced()).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("hasSynced() becomes true after the first set(), even if set to the default value", () => {
|
|
37
|
+
const ref = createCleanupAfterDaysRef();
|
|
38
|
+
ref.set(14);
|
|
39
|
+
expect(ref.hasSynced()).toBe(true);
|
|
40
|
+
expect(ref.get()).toBe(14);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("hasSynced() stays true after subsequent set() calls, regardless of value", () => {
|
|
44
|
+
const ref = createCleanupAfterDaysRef();
|
|
45
|
+
ref.set(30);
|
|
46
|
+
expect(ref.hasSynced()).toBe(true);
|
|
47
|
+
|
|
48
|
+
ref.set(7);
|
|
49
|
+
expect(ref.hasSynced()).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns the most recently set value regardless of how many times set() was called", () => {
|
|
53
|
+
const ref = createCleanupAfterDaysRef();
|
|
54
|
+
ref.set(30);
|
|
55
|
+
expect(ref.get()).toBe(30);
|
|
56
|
+
|
|
57
|
+
ref.set(7);
|
|
58
|
+
expect(ref.get()).toBe(7);
|
|
59
|
+
|
|
60
|
+
ref.set(21);
|
|
61
|
+
expect(ref.get()).toBe(21);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("multiple independent ref instances don't share state", () => {
|
|
65
|
+
const refA = createCleanupAfterDaysRef();
|
|
66
|
+
const refB = createCleanupAfterDaysRef();
|
|
67
|
+
|
|
68
|
+
refA.set(30);
|
|
69
|
+
|
|
70
|
+
expect(refA.get()).toBe(30);
|
|
71
|
+
expect(refB.get()).toBe(14);
|
|
72
|
+
expect(refA.hasSynced()).toBe(true);
|
|
73
|
+
expect(refB.hasSynced()).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("readCleanupAfterDays", () => {
|
|
78
|
+
test("never-synced ref (config fetch failing) returns the hardcoded default 14", async () => {
|
|
79
|
+
expect(
|
|
80
|
+
await readCleanupAfterDays("/nonexistent", createCleanupAfterDaysRef()),
|
|
81
|
+
).toBe(14);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("DB-hit: an injected ref that has synced is returned", async () => {
|
|
85
|
+
const configRef = createCleanupAfterDaysRef();
|
|
86
|
+
configRef.set(30);
|
|
87
|
+
expect(await readCleanupAfterDays("/nonexistent", configRef)).toBe(30);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/cleanup-merged-worktrees-ref.ts
|
|
3
|
+
*
|
|
4
|
+
* A tiny mutable box holding the agent's most recently synced
|
|
5
|
+
* `cleanupMergedWorktrees` policy field (APM-1.6), so downstream consumers
|
|
6
|
+
* (check-helpers.ts's readCleanupMergedWorktrees()) can read a live view
|
|
7
|
+
* without closing over a single syncConfig() tick. Kept pure and zero-I/O so
|
|
8
|
+
* it's unit-testable in isolation — mirrors allow-self-review-ref.ts's style
|
|
9
|
+
* exactly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface CleanupMergedWorktreesRef {
|
|
13
|
+
/**
|
|
14
|
+
* Returns the most recently synced `cleanupMergedWorktrees` value, or
|
|
15
|
+
* `true` if set() was never called. The underlying Agent row field
|
|
16
|
+
* defaults to `true` in the database (admin/prisma/schema/), so once
|
|
17
|
+
* synced this always reflects a real DB value — callers that need to
|
|
18
|
+
* distinguish "never synced" (config-bundle fetch has never succeeded)
|
|
19
|
+
* from "synced to a DB value of true" must check hasSynced() rather than
|
|
20
|
+
* inferring it from this return value, since both states return true.
|
|
21
|
+
*/
|
|
22
|
+
get(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* True once set() has been called at least once. readCleanupMergedWorktrees()
|
|
25
|
+
* returns its hardcoded default while this is false (config-sync has
|
|
26
|
+
* never succeeded, e.g. the fetch keeps failing).
|
|
27
|
+
*/
|
|
28
|
+
hasSynced(): boolean;
|
|
29
|
+
/** Replaces the current cleanupMergedWorktrees value. */
|
|
30
|
+
set(cleanupMergedWorktrees: boolean): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Creates a new, independent cleanup-merged-worktrees ref defaulting to unsynced/true. */
|
|
34
|
+
export function createCleanupMergedWorktreesRef(): CleanupMergedWorktreesRef {
|
|
35
|
+
let cleanupMergedWorktrees = true;
|
|
36
|
+
let synced = false;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
get(): boolean {
|
|
40
|
+
return cleanupMergedWorktrees;
|
|
41
|
+
},
|
|
42
|
+
hasSynced(): boolean {
|
|
43
|
+
return synced;
|
|
44
|
+
},
|
|
45
|
+
set(next: boolean): void {
|
|
46
|
+
cleanupMergedWorktrees = next;
|
|
47
|
+
synced = true;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The process-wide cleanup-merged-worktrees ref. agent/src/index.ts's
|
|
54
|
+
* syncConfig() writes into this on every successful config-sync tick;
|
|
55
|
+
* check-helpers.ts's readCleanupMergedWorktrees() reads it with a hardcoded default when unsynced, so a DB-side change (via
|
|
56
|
+
* PATCH /agents/:id) takes effect on the very next config-sync tick without
|
|
57
|
+
* requiring an agent restart. If the agent's config bundle never becomes
|
|
58
|
+
* available, hasSynced() stays false for the process lifetime — readers
|
|
59
|
+
* readers must return their hardcoded default in that case.
|
|
60
|
+
*/
|
|
61
|
+
export const cleanupMergedWorktreesRef: CleanupMergedWorktreesRef =
|
|
62
|
+
createCleanupMergedWorktreesRef();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read the `cleanup_merged_worktrees` policy field (WTR-1.1): the synced DB
|
|
66
|
+
* value from `configRef` (default: the process-wide
|
|
67
|
+
* `cleanupMergedWorktreesRef`), else the hardcoded default `true` while the
|
|
68
|
+
* ref has never synced. Same shape as `readAllowSelfReview()`.
|
|
69
|
+
*/
|
|
70
|
+
export async function readCleanupMergedWorktrees(
|
|
71
|
+
_workspacePath: string,
|
|
72
|
+
configRef: CleanupMergedWorktreesRef = cleanupMergedWorktreesRef,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
return configRef.hasSynced() ? configRef.get() : true;
|
|
75
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/cleanup-merged-worktrees-ref.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for createCleanupMergedWorktreesRef() — pure logic, no I/O.
|
|
5
|
+
* Mirrors allow-self-review-ref.unit.test.ts's coverage pattern.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately does NOT exercise the process-wide `cleanupMergedWorktreesRef`
|
|
8
|
+
* singleton's set() here (unlike allow-self-review-ref.unit.test.ts) — once
|
|
9
|
+
* set() is called, hasSynced() can never be un-set, so mutating the shared
|
|
10
|
+
* singleton would permanently poison it for the rest of this bun test
|
|
11
|
+
* process (this repo's test-isolation hard rule: "Bun shares the test
|
|
12
|
+
* process, so leaked globals break sibling suites"). That would silently
|
|
13
|
+
* break check-helpers.unit.test.ts's readCleanupMergedWorktrees()
|
|
14
|
+
* "no injected ref" tests, which rely on the singleton staying unsynced.
|
|
15
|
+
* createCleanupMergedWorktreesRef() exercises identical get/hasSynced/set
|
|
16
|
+
* logic on an independent instance, so coverage is unaffected.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it } from "bun:test";
|
|
20
|
+
import { createCleanupMergedWorktreesRef } from "./cleanup-merged-worktrees-ref.ts";
|
|
21
|
+
|
|
22
|
+
describe("createCleanupMergedWorktreesRef", () => {
|
|
23
|
+
it("initial get() before any set() returns true (matches the DB/file default)", () => {
|
|
24
|
+
const ref = createCleanupMergedWorktreesRef();
|
|
25
|
+
expect(ref.get()).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("hasSynced() is false before any set() call", () => {
|
|
29
|
+
const ref = createCleanupMergedWorktreesRef();
|
|
30
|
+
expect(ref.hasSynced()).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("hasSynced() becomes true after the first set(), even if set to false", () => {
|
|
34
|
+
const ref = createCleanupMergedWorktreesRef();
|
|
35
|
+
ref.set(false);
|
|
36
|
+
expect(ref.hasSynced()).toBe(true);
|
|
37
|
+
expect(ref.get()).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("hasSynced() stays true after subsequent set() calls, regardless of value", () => {
|
|
41
|
+
const ref = createCleanupMergedWorktreesRef();
|
|
42
|
+
ref.set(false);
|
|
43
|
+
expect(ref.hasSynced()).toBe(true);
|
|
44
|
+
|
|
45
|
+
ref.set(true);
|
|
46
|
+
expect(ref.hasSynced()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns the most recently set value regardless of how many times set() was called", () => {
|
|
50
|
+
const ref = createCleanupMergedWorktreesRef();
|
|
51
|
+
ref.set(false);
|
|
52
|
+
expect(ref.get()).toBe(false);
|
|
53
|
+
|
|
54
|
+
ref.set(true);
|
|
55
|
+
expect(ref.get()).toBe(true);
|
|
56
|
+
|
|
57
|
+
ref.set(false);
|
|
58
|
+
expect(ref.get()).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("multiple independent ref instances don't share state", () => {
|
|
62
|
+
const refA = createCleanupMergedWorktreesRef();
|
|
63
|
+
const refB = createCleanupMergedWorktreesRef();
|
|
64
|
+
|
|
65
|
+
refA.set(false);
|
|
66
|
+
|
|
67
|
+
expect(refA.get()).toBe(false);
|
|
68
|
+
expect(refB.get()).toBe(true);
|
|
69
|
+
expect(refA.hasSynced()).toBe(true);
|
|
70
|
+
expect(refB.hasSynced()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/cli-args.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/cli-args.ts
|
|
3
|
+
*
|
|
4
|
+
* CLI argument parsing for the agent entrypoint.
|
|
5
|
+
*
|
|
6
|
+
* Parses --agent-id, --api-url, --api-key flags from argv.
|
|
7
|
+
* Falls back to environment variables when flags are absent.
|
|
8
|
+
* Pure — no I/O, no process.env reads at module level.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parseFlags } from "@app-vitals/agent-lib/cli-flags";
|
|
12
|
+
|
|
13
|
+
interface CliArgs {
|
|
14
|
+
agentId: string | undefined;
|
|
15
|
+
apiUrl: string | undefined;
|
|
16
|
+
apiKey: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parses CLI arguments and merges with env var fallbacks.
|
|
21
|
+
*
|
|
22
|
+
* @param argv - argument list (without the node/bun executable prefix, i.e. process.argv.slice(2))
|
|
23
|
+
* @param env - environment variable map (pass process.env or a subset for testing)
|
|
24
|
+
*/
|
|
25
|
+
export function parseCliArgs(
|
|
26
|
+
argv: string[],
|
|
27
|
+
env: Record<string, string | undefined>,
|
|
28
|
+
): CliArgs {
|
|
29
|
+
const flags = parseFlags(argv, ["--agent-id", "--api-url", "--api-key"]);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
agentId: flags["--agent-id"] ?? env.SHIPWRIGHT_AGENT_ID,
|
|
33
|
+
apiUrl: flags["--api-url"] ?? env.SHIPWRIGHT_API_URL,
|
|
34
|
+
apiKey: flags["--api-key"] ?? env.SHIPWRIGHT_AGENT_API_KEY,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/cli-args.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for CLI argument parsing.
|
|
5
|
+
* Pure logic — no I/O, no env side effects.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import { parseCliArgs } from "./cli-args.ts";
|
|
10
|
+
|
|
11
|
+
describe("parseCliArgs — explicit flags", () => {
|
|
12
|
+
it("parses --agent-id", () => {
|
|
13
|
+
const result = parseCliArgs(["--agent-id", "agent-123"], {});
|
|
14
|
+
expect(result.agentId).toBe("agent-123");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("parses --api-url", () => {
|
|
18
|
+
const result = parseCliArgs(["--api-url", "https://api.example.com"], {});
|
|
19
|
+
expect(result.apiUrl).toBe("https://api.example.com");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("parses --api-key", () => {
|
|
23
|
+
const result = parseCliArgs(["--api-key", "secret-key"], {});
|
|
24
|
+
expect(result.apiKey).toBe("secret-key");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("parses all three flags together", () => {
|
|
28
|
+
const result = parseCliArgs(
|
|
29
|
+
[
|
|
30
|
+
"--agent-id",
|
|
31
|
+
"agent-abc",
|
|
32
|
+
"--api-url",
|
|
33
|
+
"https://api.test.com",
|
|
34
|
+
"--api-key",
|
|
35
|
+
"key-xyz",
|
|
36
|
+
],
|
|
37
|
+
{},
|
|
38
|
+
);
|
|
39
|
+
expect(result.agentId).toBe("agent-abc");
|
|
40
|
+
expect(result.apiUrl).toBe("https://api.test.com");
|
|
41
|
+
expect(result.apiKey).toBe("key-xyz");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("supports --flag=value syntax via the shared parser", () => {
|
|
45
|
+
const result = parseCliArgs(
|
|
46
|
+
["--agent-id=eq-agent", "--api-url=https://eq.test"],
|
|
47
|
+
{},
|
|
48
|
+
);
|
|
49
|
+
expect(result.agentId).toBe("eq-agent");
|
|
50
|
+
expect(result.apiUrl).toBe("https://eq.test");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("ignores unknown flags", () => {
|
|
54
|
+
const result = parseCliArgs(["--unknown", "value", "--agent-id", "x"], {});
|
|
55
|
+
expect(result.agentId).toBe("x");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("parseCliArgs — env var fallback", () => {
|
|
60
|
+
it("falls back to SHIPWRIGHT_AGENT_ID when --agent-id not provided", () => {
|
|
61
|
+
const result = parseCliArgs([], {
|
|
62
|
+
SHIPWRIGHT_AGENT_ID: "env-agent-id",
|
|
63
|
+
});
|
|
64
|
+
expect(result.agentId).toBe("env-agent-id");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("falls back to SHIPWRIGHT_API_URL when --api-url not provided", () => {
|
|
68
|
+
const result = parseCliArgs([], {
|
|
69
|
+
SHIPWRIGHT_API_URL: "https://env.api.com",
|
|
70
|
+
});
|
|
71
|
+
expect(result.apiUrl).toBe("https://env.api.com");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("falls back to SHIPWRIGHT_AGENT_API_KEY when --api-key not provided", () => {
|
|
75
|
+
const result = parseCliArgs([], {
|
|
76
|
+
SHIPWRIGHT_AGENT_API_KEY: "env-key",
|
|
77
|
+
});
|
|
78
|
+
expect(result.apiKey).toBe("env-key");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("CLI flags take precedence over env vars", () => {
|
|
82
|
+
const result = parseCliArgs(["--agent-id", "cli-agent"], {
|
|
83
|
+
SHIPWRIGHT_AGENT_ID: "env-agent",
|
|
84
|
+
});
|
|
85
|
+
expect(result.agentId).toBe("cli-agent");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns undefined when neither flag nor env var is set", () => {
|
|
89
|
+
const result = parseCliArgs([], {});
|
|
90
|
+
expect(result.agentId).toBeUndefined();
|
|
91
|
+
expect(result.apiUrl).toBeUndefined();
|
|
92
|
+
expect(result.apiKey).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
});
|
package/clock.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/clock.ts
|
|
3
|
+
* Clock interface and production implementation for injectable time.
|
|
4
|
+
*
|
|
5
|
+
* Any code that needs the current time should accept a `Clock` via dependency
|
|
6
|
+
* injection rather than calling `new Date()` or `Date.now()` directly.
|
|
7
|
+
* This makes time-dependent logic trivially testable and deterministic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ─── Interface ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface Clock {
|
|
13
|
+
/** Returns the current time as a Date. */
|
|
14
|
+
now(): Date;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── SystemClock ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Production clock — delegates to the real system time.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const clock = SystemClock();
|
|
24
|
+
* clock.now(); // → current wall-clock time
|
|
25
|
+
*/
|
|
26
|
+
export function SystemClock(): Clock {
|
|
27
|
+
return {
|
|
28
|
+
now(): Date {
|
|
29
|
+
return new Date();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fixed-time clock for deterministic testing.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const clock = FixedClock(new Date("2024-01-01T00:00:00Z"));
|
|
39
|
+
* clock.now(); // → always 2024-01-01T00:00:00.000Z
|
|
40
|
+
*/
|
|
41
|
+
export function FixedClock(date: Date): Clock {
|
|
42
|
+
return {
|
|
43
|
+
now(): Date {
|
|
44
|
+
return date;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { DEFAULT_CLAUDE_TIMEOUT_MS } from "@app-vitals/agent-lib/claude-timeout";
|
|
3
|
+
import { DEFAULT_ANTHROPIC_MODEL } from "@app-vitals/agent-lib/default-agent-env";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Idle-reset timeout default (25min) — cleared/restarted on every stdout line
|
|
7
|
+
* from a `claude -p` session (see `agent/src/runtime/claude.ts`'s `_spawn`). This is
|
|
8
|
+
* the primary timeout in practice; calibrated from a 900-session log analysis
|
|
9
|
+
* (p99 inter-line gap 713s, largest legitimate single gap 1303s from an
|
|
10
|
+
* `Agent()` subagent delegation). `DEFAULT_CLAUDE_TIMEOUT_MS` (1hr, in
|
|
11
|
+
* `@app-vitals/agent-lib/claude-timeout`) is the hard ceiling backstop, not primary.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_CLAUDE_IDLE_TIMEOUT_MS = 1_500_000;
|
|
14
|
+
|
|
15
|
+
function optional(key: string): string | undefined {
|
|
16
|
+
return process.env[key];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads a positive-integer millisecond env var, falling back to `fallback`
|
|
21
|
+
* when the value is unset, non-numeric, non-integer, zero, or negative — so a
|
|
22
|
+
* malformed override never silently disables or shortens the timeout.
|
|
23
|
+
*/
|
|
24
|
+
function positiveIntMs(key: string, fallback: number): number {
|
|
25
|
+
const raw = optional(key);
|
|
26
|
+
if (raw === undefined) return fallback;
|
|
27
|
+
const parsed = Number(raw);
|
|
28
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildConfig(agentHome: string) {
|
|
32
|
+
return {
|
|
33
|
+
claude: {
|
|
34
|
+
model: optional("ANTHROPIC_MODEL") ?? DEFAULT_ANTHROPIC_MODEL,
|
|
35
|
+
fallbackModel: optional("ANTHROPIC_FALLBACK_MODEL"),
|
|
36
|
+
effortLevel: optional("ANTHROPIC_EFFORT_LEVEL"),
|
|
37
|
+
timeoutMs: positiveIntMs(
|
|
38
|
+
"SHIPWRIGHT_CLAUDE_TIMEOUT_MS",
|
|
39
|
+
DEFAULT_CLAUDE_TIMEOUT_MS,
|
|
40
|
+
),
|
|
41
|
+
idleTimeoutMs: positiveIntMs(
|
|
42
|
+
"SHIPWRIGHT_CLAUDE_IDLE_TIMEOUT_MS",
|
|
43
|
+
DEFAULT_CLAUDE_IDLE_TIMEOUT_MS,
|
|
44
|
+
),
|
|
45
|
+
anthropicApiKey: optional("ANTHROPIC_API_KEY"),
|
|
46
|
+
oauthToken: optional("CLAUDE_CODE_OAUTH_TOKEN"),
|
|
47
|
+
},
|
|
48
|
+
shipwright: {
|
|
49
|
+
apiUrl: optional("SHIPWRIGHT_API_URL"),
|
|
50
|
+
apiKey: optional("SHIPWRIGHT_AGENT_API_KEY"),
|
|
51
|
+
agentId: optional("SHIPWRIGHT_AGENT_ID"),
|
|
52
|
+
},
|
|
53
|
+
slack: {
|
|
54
|
+
botToken: optional("SLACK_BOT_TOKEN"),
|
|
55
|
+
appToken: optional("SLACK_APP_TOKEN"),
|
|
56
|
+
signingSecret: optional("SLACK_SIGNING_SECRET"),
|
|
57
|
+
adminToken: optional("SLACK_ADMIN_TOKEN"),
|
|
58
|
+
},
|
|
59
|
+
alerts: {
|
|
60
|
+
channel: optional("SLACK_ALERT_CHANNEL"),
|
|
61
|
+
},
|
|
62
|
+
owner: {
|
|
63
|
+
user: optional("SLACK_OWNER_USER"),
|
|
64
|
+
},
|
|
65
|
+
voice: {
|
|
66
|
+
groqApiKey: optional("GROQ_API_KEY"),
|
|
67
|
+
elevenLabsApiKey: optional("ELEVENLABS_API_KEY"),
|
|
68
|
+
voiceId: optional("ELEVENLABS_VOICE_ID"),
|
|
69
|
+
piperVoice: optional("PIPER_VOICE"),
|
|
70
|
+
whisperServiceUrl: optional("WHISPER_SERVICE_URL"),
|
|
71
|
+
},
|
|
72
|
+
chat: {
|
|
73
|
+
serviceUrl: optional("SHIPWRIGHT_CHAT_SERVICE_URL"),
|
|
74
|
+
serviceToken: optional("SHIPWRIGHT_CHAT_SERVICE_TOKEN"),
|
|
75
|
+
pollIntervalMs: optional("SHIPWRIGHT_CHAT_POLL_INTERVAL_MS")
|
|
76
|
+
? Number(optional("SHIPWRIGHT_CHAT_POLL_INTERVAL_MS"))
|
|
77
|
+
: undefined,
|
|
78
|
+
},
|
|
79
|
+
paths: {
|
|
80
|
+
home: agentHome,
|
|
81
|
+
workspace: join(agentHome, "workspace"),
|
|
82
|
+
sessions: join(agentHome, "sessions.json"),
|
|
83
|
+
chatSessions: join(agentHome, "chat-sessions.json"),
|
|
84
|
+
},
|
|
85
|
+
} as const;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Creates config from a given agent home directory.
|
|
90
|
+
* Reads env vars at call time — no module-level side effects.
|
|
91
|
+
*
|
|
92
|
+
* AGENT_HOME defaults to ~/.shipwright-agent/ at the call site (index.ts).
|
|
93
|
+
* Not defaulted here to avoid module-level env reads.
|
|
94
|
+
*/
|
|
95
|
+
export function createConfig(agentHome: string) {
|
|
96
|
+
return {
|
|
97
|
+
config: buildConfig(agentHome),
|
|
98
|
+
};
|
|
99
|
+
}
|