@app-vitals/agent-admin 0.16.1 → 1.0.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/e2e/agents-page.e2e.ts +2 -2
- package/openapi.json +3 -3
- package/openapi.platform.json +7 -7
- package/package.json +3 -5
- package/prisma/client/internal/class.ts +1 -1
- package/prisma/migrations/20261030100000_set_agent_specialty_default_specialist/migration.sql +4 -0
- package/prisma/schema/schema.prisma +1 -1
- package/src/account-agent-create.ts +1 -1
- package/src/admin-boot.ts +12 -6
- package/src/admin-extension.ts +51 -5
- package/src/admin-extension.unit.test.ts +61 -0
- package/src/admin-local-agent.smoke.test.ts +8 -5
- package/src/admin-ui-pages.ts +4 -4
- package/src/admin-ui.smoke.test.ts +54 -34
- package/src/admin-ui.ts +12 -12
- package/src/agent-chat-tokens.smoke.test.ts +2 -2
- package/src/agent-cron-jobs.integration.test.ts +23 -21
- package/src/agent-cron-jobs.ts +30 -40
- package/src/agent-cron-jobs.unit.test.ts +51 -39
- package/src/agent-cron-run-stats.smoke.test.ts +2 -2
- package/src/agent-manifest.ts +2 -6
- package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
- package/src/agent-specialty-backfill.integration.test.ts +30 -0
- package/src/agents-api.integration.test.ts +29 -40
- package/src/agents-api.sentry.smoke.test.ts +2 -2
- package/src/agents-api.smoke.test.ts +17 -30
- package/src/agents-api.ts +13 -13
- package/src/agents.integration.test.ts +23 -33
- package/src/agents.ts +35 -34
- package/src/agents.unit.test.ts +35 -55
- package/src/api.smoke.test.ts +2 -2
- package/src/chat-service-provisioning-client.integration.test.ts +1 -1
- package/src/openapi-schemas.ts +4 -4
- package/src/server.smoke.test.ts +2 -2
- package/src/specialty-defaults.ts +4 -4
- package/src/specialty-types.ts +92 -0
- package/src/specialty-types.unit.test.ts +67 -0
- package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
- package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
- package/src/coding-task-store-provisioning-client.ts +0 -156
- package/src/fixtures/task-store-provisioning-cassette.json +0 -85
- package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
- package/src/specialty-manifest-loader.ts +0 -245
- package/src/specialty-manifest-loader.unit.test.ts +0 -299
- package/src/specialty-registry.schema.unit.test.ts +0 -35
- package/src/specialty-registry.ts +0 -236
- package/src/specialty-registry.unit.test.ts +0 -272
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* admin/src/coding-task-store-provisioning-client.ts
|
|
3
|
-
*
|
|
4
|
-
* Thin interface + implementations for minting/revoking per-agent task-store
|
|
5
|
-
* tokens during agent provisioning.
|
|
6
|
-
*
|
|
7
|
-
* The interface is the DI boundary. Tests inject a stub; production wires in
|
|
8
|
-
* HttpTaskStoreProvisioningClient (or skips task-store wiring entirely by
|
|
9
|
-
* omitting the client from KubernetesAgentProvisionerConfig).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { components } from "@app-vitals/agent-lib/task-store-types";
|
|
13
|
-
|
|
14
|
-
// ─── Interface ───────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
export interface TaskStoreProvisioningClient {
|
|
17
|
-
/**
|
|
18
|
-
* Mint a new task-store token. Returns the token `id` (needed to revoke it
|
|
19
|
-
* on rollback) and the `rawToken` (stored in the agent Secret and later used
|
|
20
|
-
* by the agent to authenticate with the task store).
|
|
21
|
-
*
|
|
22
|
-
* Pass `agentId` to create a scoped agent token — the task store will scope
|
|
23
|
-
* all reads and writes to tasks assigned to this agent. Omit for admin tokens.
|
|
24
|
-
*/
|
|
25
|
-
mintToken(
|
|
26
|
-
label: string,
|
|
27
|
-
agentId?: string,
|
|
28
|
-
): Promise<{ id: string; rawToken: string }>;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Revoke a previously-minted task-store token. Called as part of the rollback
|
|
32
|
-
* path when Deployment creation fails after the token was minted, and as
|
|
33
|
-
* part of agent-deletion credential cleanup.
|
|
34
|
-
* Should be best-effort — callers do not propagate errors from this call.
|
|
35
|
-
*/
|
|
36
|
-
revokeToken(id: string): Promise<void>;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* List the ids of every task-store token scoped to `agentId`.
|
|
40
|
-
*
|
|
41
|
-
* task-store's GET /tokens has no server-side agentId filter — it returns
|
|
42
|
-
* every token in the store — so this fetches the full list and filters
|
|
43
|
-
* client-side. Intended to be paired with `revokeToken` by the caller (e.g.
|
|
44
|
-
* agent-deletion cleanup): list, then revoke each match.
|
|
45
|
-
*/
|
|
46
|
-
listTokensForAgent(agentId: string): Promise<{ id: string }[]>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ─── HTTP implementation ──────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Real HTTP client for the task-store token API.
|
|
53
|
-
*
|
|
54
|
-
* POST /tokens → { id, rawToken, ... }
|
|
55
|
-
* DELETE /tokens/:id
|
|
56
|
-
*
|
|
57
|
-
* The admin token is passed via Bearer auth and is never stored in the Secret
|
|
58
|
-
* or handed to the agent — only the per-agent rawToken is.
|
|
59
|
-
*/
|
|
60
|
-
export class HttpTaskStoreProvisioningClient
|
|
61
|
-
implements TaskStoreProvisioningClient
|
|
62
|
-
{
|
|
63
|
-
private readonly fetchFn: typeof fetch;
|
|
64
|
-
|
|
65
|
-
constructor(
|
|
66
|
-
private readonly baseUrl: string,
|
|
67
|
-
private readonly adminToken: string,
|
|
68
|
-
opts?: { fetchFn?: typeof fetch },
|
|
69
|
-
) {
|
|
70
|
-
this.fetchFn = opts?.fetchFn ?? fetch;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async mintToken(
|
|
74
|
-
label: string,
|
|
75
|
-
agentId?: string,
|
|
76
|
-
): Promise<{ id: string; rawToken: string }> {
|
|
77
|
-
const tokenBody: components["schemas"]["TokenBody"] = {
|
|
78
|
-
label,
|
|
79
|
-
...(agentId ? { agentId } : {}),
|
|
80
|
-
};
|
|
81
|
-
const res = await this.fetchFn(`${this.baseUrl}/tokens`, {
|
|
82
|
-
method: "POST",
|
|
83
|
-
headers: {
|
|
84
|
-
"Content-Type": "application/json",
|
|
85
|
-
Authorization: `Bearer ${this.adminToken}`,
|
|
86
|
-
},
|
|
87
|
-
body: JSON.stringify(tokenBody),
|
|
88
|
-
});
|
|
89
|
-
if (!res.ok) {
|
|
90
|
-
throw new Error(
|
|
91
|
-
`task-store POST /tokens failed: ${res.status} ${res.statusText}`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
const body =
|
|
95
|
-
(await res.json()) as components["schemas"]["TaskTokenWithRaw"];
|
|
96
|
-
return { id: body.id, rawToken: body.rawToken };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async revokeToken(id: string): Promise<void> {
|
|
100
|
-
const res = await this.fetchFn(`${this.baseUrl}/tokens/${id}`, {
|
|
101
|
-
method: "DELETE",
|
|
102
|
-
headers: {
|
|
103
|
-
Authorization: `Bearer ${this.adminToken}`,
|
|
104
|
-
},
|
|
105
|
-
});
|
|
106
|
-
if (!res.ok && res.status !== 404) {
|
|
107
|
-
throw new Error(
|
|
108
|
-
`task-store DELETE /tokens/${id} failed: ${res.status} ${res.statusText}`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async listTokensForAgent(agentId: string): Promise<{ id: string }[]> {
|
|
114
|
-
const res = await this.fetchFn(`${this.baseUrl}/tokens`, {
|
|
115
|
-
method: "GET",
|
|
116
|
-
headers: {
|
|
117
|
-
Authorization: `Bearer ${this.adminToken}`,
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
if (!res.ok) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
`task-store GET /tokens failed: ${res.status} ${res.statusText}`,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
const tokens = (await res.json()) as components["schemas"]["TaskToken"][];
|
|
126
|
-
return tokens
|
|
127
|
-
.filter((token) => token.agentId === agentId)
|
|
128
|
-
.map((token) => ({ id: token.id }));
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ─── No-op implementation ─────────────────────────────────────────────────────
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* No-op client used when SHIPWRIGHT_TASK_STORE_URL / SHIPWRIGHT_TASK_STORE_ADMIN_TOKEN
|
|
136
|
-
* are not configured. mintToken returns empty strings so callers can gate on
|
|
137
|
-
* the configured path without null-checking the return.
|
|
138
|
-
*/
|
|
139
|
-
export class NoopTaskStoreProvisioningClient
|
|
140
|
-
implements TaskStoreProvisioningClient
|
|
141
|
-
{
|
|
142
|
-
async mintToken(
|
|
143
|
-
_label: string,
|
|
144
|
-
_agentId?: string,
|
|
145
|
-
): Promise<{ id: string; rawToken: string }> {
|
|
146
|
-
return { id: "", rawToken: "" };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async revokeToken(_id: string): Promise<void> {
|
|
150
|
-
// intentionally a no-op
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async listTokensForAgent(_agentId: string): Promise<{ id: string }[]> {
|
|
154
|
-
return [];
|
|
155
|
-
}
|
|
156
|
-
}
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"mintToken_success": {
|
|
3
|
-
"status": 201,
|
|
4
|
-
"body": { "id": "tok_abc123", "rawToken": "sts_raw_test_token_value" }
|
|
5
|
-
},
|
|
6
|
-
"mintToken_success_with_agentId": {
|
|
7
|
-
"status": 201,
|
|
8
|
-
"body": { "id": "tok_def456", "rawToken": "sts_raw_test_token_agent_scoped" }
|
|
9
|
-
},
|
|
10
|
-
"mintToken_500": {
|
|
11
|
-
"status": 500,
|
|
12
|
-
"body": { "error": "internal server error" }
|
|
13
|
-
},
|
|
14
|
-
"revokeToken_success": {
|
|
15
|
-
"status": 204,
|
|
16
|
-
"body": {}
|
|
17
|
-
},
|
|
18
|
-
"revokeToken_404": {
|
|
19
|
-
"status": 404,
|
|
20
|
-
"body": { "error": "not found" }
|
|
21
|
-
},
|
|
22
|
-
"revokeToken_500": {
|
|
23
|
-
"status": 500,
|
|
24
|
-
"body": { "error": "internal server error" }
|
|
25
|
-
},
|
|
26
|
-
"listTokens_mixedAgents": {
|
|
27
|
-
"status": 200,
|
|
28
|
-
"body": [
|
|
29
|
-
{
|
|
30
|
-
"id": "tok_agent1_a",
|
|
31
|
-
"label": "agent-1 token a",
|
|
32
|
-
"agentId": "agent-1",
|
|
33
|
-
"createdAt": "2026-01-01T00:00:00.000Z"
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
"id": "tok_agent2_a",
|
|
37
|
-
"label": "agent-2 token",
|
|
38
|
-
"agentId": "agent-2",
|
|
39
|
-
"createdAt": "2026-01-01T00:00:00.000Z"
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"id": "tok_agent1_b",
|
|
43
|
-
"label": "agent-1 token b",
|
|
44
|
-
"agentId": "agent-1",
|
|
45
|
-
"createdAt": "2026-01-02T00:00:00.000Z"
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
"id": "tok_admin_noagent",
|
|
49
|
-
"label": "admin token",
|
|
50
|
-
"createdAt": "2026-01-01T00:00:00.000Z"
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
"id": "tok_agent1_revoked",
|
|
54
|
-
"label": "agent-1 already revoked",
|
|
55
|
-
"agentId": "agent-1",
|
|
56
|
-
"createdAt": "2026-01-01T00:00:00.000Z",
|
|
57
|
-
"revokedAt": "2026-01-03T00:00:00.000Z"
|
|
58
|
-
}
|
|
59
|
-
]
|
|
60
|
-
},
|
|
61
|
-
"listTokens_noMatches": {
|
|
62
|
-
"status": 200,
|
|
63
|
-
"body": [
|
|
64
|
-
{
|
|
65
|
-
"id": "tok_agent2_a",
|
|
66
|
-
"label": "agent-2 token",
|
|
67
|
-
"agentId": "agent-2",
|
|
68
|
-
"createdAt": "2026-01-01T00:00:00.000Z"
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
"id": "tok_admin_noagent",
|
|
72
|
-
"label": "admin token",
|
|
73
|
-
"createdAt": "2026-01-01T00:00:00.000Z"
|
|
74
|
-
}
|
|
75
|
-
]
|
|
76
|
-
},
|
|
77
|
-
"listTokens_empty": {
|
|
78
|
-
"status": 200,
|
|
79
|
-
"body": []
|
|
80
|
-
},
|
|
81
|
-
"listTokens_500": {
|
|
82
|
-
"status": 500,
|
|
83
|
-
"body": { "error": "internal server error" }
|
|
84
|
-
}
|
|
85
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AGR-1.9 — SpecialtyRegistry against the platform's fixture specialty.
|
|
3
|
-
*
|
|
4
|
-
* Real filesystem reads, no injected reader. Nothing here depends on a product
|
|
5
|
-
* specialty, so it runs in the extracted tree as well as the monorepo.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, expect, it } from "bun:test";
|
|
9
|
-
import { existsSync } from "node:fs";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
|
|
12
|
-
|
|
13
|
-
// agent/src/fixtures in the monorepo, runtime/fixtures in the extracted layout.
|
|
14
|
-
const FIXTURE_SPECIALTIES_DIR = [
|
|
15
|
-
join(import.meta.dir, "..", "..", "agent", "src", "fixtures", "specialties"),
|
|
16
|
-
join(import.meta.dir, "..", "..", "runtime", "fixtures", "specialties"),
|
|
17
|
-
].find((dir) => existsSync(dir)) as string;
|
|
18
|
-
|
|
19
|
-
function fixtureRegistry(warn: (m: string) => void = () => {}) {
|
|
20
|
-
return new SpecialtyRegistry({
|
|
21
|
-
typesDir: FIXTURE_SPECIALTIES_DIR,
|
|
22
|
-
defaultTypeName: "fixture",
|
|
23
|
-
warn,
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe("SpecialtyRegistry with the fixture specialty", () => {
|
|
28
|
-
it("loads the fixture manifest from disk", () => {
|
|
29
|
-
const manifest = fixtureRegistry().getManifest("fixture");
|
|
30
|
-
expect(manifest.metadata.name).toBe("fixture");
|
|
31
|
-
expect(manifest.identity.templatesDir).toBe("templates/");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("lists the fixture in the type picker", () => {
|
|
35
|
-
expect(fixtureRegistry().listTypes()).toEqual([
|
|
36
|
-
{ name: "fixture", displayName: "Fixture Agent" },
|
|
37
|
-
]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("falls back to the fixture default for an unknown type, with a warning", () => {
|
|
41
|
-
const warnings: string[] = [];
|
|
42
|
-
const manifest = fixtureRegistry((m) => warnings.push(m)).getManifest(
|
|
43
|
-
"renamed-type",
|
|
44
|
-
);
|
|
45
|
-
expect(manifest.metadata.name).toBe("fixture");
|
|
46
|
-
expect(warnings).toHaveLength(1);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("tryGetManifest does not fall back", () => {
|
|
50
|
-
expect(fixtureRegistry().tryGetManifest("renamed-type")).toBeUndefined();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("warns and returns an empty-crons manifest when the default specialty is not on disk", () => {
|
|
54
|
-
const warnings: string[] = [];
|
|
55
|
-
const registry = new SpecialtyRegistry({
|
|
56
|
-
typesDir: FIXTURE_SPECIALTIES_DIR,
|
|
57
|
-
defaultTypeName: "not-there",
|
|
58
|
-
warn: (m) => warnings.push(m),
|
|
59
|
-
});
|
|
60
|
-
const manifest = registry.getManifest("fixture-or-anything");
|
|
61
|
-
expect(manifest.crons).toEqual([]);
|
|
62
|
-
expect(manifest.plugins).toEqual([]);
|
|
63
|
-
expect(warnings.join("\n")).toMatch(
|
|
64
|
-
/SHIPWRIGHT_SPECIALTIES_DIR.*SHIPWRIGHT_DEFAULT_AGENT_TYPE/s,
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* admin/src/specialty-manifest-loader.ts
|
|
3
|
-
*
|
|
4
|
-
* SpecialtyRegistry — resolves an agent's stored `specialty` to its parsed
|
|
5
|
-
* Specialty manifest by loading `{typesDir}/{specialty}/manifest.yaml` from
|
|
6
|
-
* disk (typesDir defaults to the shipped agent-types/) and validating it via
|
|
7
|
-
* parseSpecialtyManifest().
|
|
8
|
-
*
|
|
9
|
-
* This is the runtime source of truth for an agent's default system crons
|
|
10
|
-
* (and, in time, its default plugins/tools/env contract). It replaces the
|
|
11
|
-
* former hardcoded SYSTEM_CRONS array in admin/src/system-crons.ts.
|
|
12
|
-
*
|
|
13
|
-
* Defaults: the default type is `specialist` and the type directory is the
|
|
14
|
-
* shipped `agent-types/` (both resolved by @app-vitals/agent-lib/default-specialty,
|
|
15
|
-
* the single shared helper; SHIPWRIGHT_DEFAULT_AGENT_TYPE /
|
|
16
|
-
* SHIPWRIGHT_SPECIALTIES_DIR override them).
|
|
17
|
-
*
|
|
18
|
-
* Fallback contract: getManifest() never throws. An unknown `specialty` (no
|
|
19
|
-
* matching directory under the type dir) resolves to the default type's
|
|
20
|
-
* manifest with a logged warning. If even the default cannot be loaded (bad
|
|
21
|
-
* env override, missing/invalid manifest) it warns and returns a built-in
|
|
22
|
-
* minimal manifest with no crons/plugins, so the boot-path
|
|
23
|
-
* POST /agents/:id/crons/reconcile route never 5xxs over configuration.
|
|
24
|
-
*
|
|
25
|
-
* The class takes an injectable reader/warner so unit tests can drive it
|
|
26
|
-
* without touching the filesystem (see agent-cron-jobs.unit.test.ts).
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
30
|
-
import { join } from "node:path";
|
|
31
|
-
import {
|
|
32
|
-
DEFAULT_SPECIALTY_NAME,
|
|
33
|
-
resolveDefaultSpecialtyName,
|
|
34
|
-
resolveSpecialtiesDir,
|
|
35
|
-
} from "@app-vitals/agent-lib/default-specialty";
|
|
36
|
-
import {
|
|
37
|
-
parseSpecialtyManifest,
|
|
38
|
-
type SpecialtyManifest,
|
|
39
|
-
} from "./specialty-registry.ts";
|
|
40
|
-
|
|
41
|
-
export {
|
|
42
|
-
DEFAULT_SPECIALTY_NAME,
|
|
43
|
-
resolveDefaultSpecialtyName,
|
|
44
|
-
resolveSpecialtiesDir,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Built-in last-resort manifest: used only when the default type itself cannot
|
|
49
|
-
* be loaded. Declares nothing (no crons, plugins, tools) so it never seeds
|
|
50
|
-
* anything unexpected.
|
|
51
|
-
*/
|
|
52
|
-
const BUILT_IN_FALLBACK_MANIFEST_YAML = `apiVersion: shipwright.dev/v1alpha1
|
|
53
|
-
kind: Specialty
|
|
54
|
-
metadata:
|
|
55
|
-
name: ${DEFAULT_SPECIALTY_NAME}
|
|
56
|
-
displayName: Specialist Agent
|
|
57
|
-
description: Built-in fallback used when the default specialty manifest cannot be loaded.
|
|
58
|
-
version: 0.0.0
|
|
59
|
-
skills: []
|
|
60
|
-
identity:
|
|
61
|
-
templatesDir: workspace/
|
|
62
|
-
crons: []
|
|
63
|
-
plugins: []
|
|
64
|
-
tools: []
|
|
65
|
-
env:
|
|
66
|
-
required: []
|
|
67
|
-
optional: []
|
|
68
|
-
members: []
|
|
69
|
-
repos: []
|
|
70
|
-
chat: true
|
|
71
|
-
voice: true
|
|
72
|
-
`;
|
|
73
|
-
|
|
74
|
-
/** Reads a manifest's raw YAML text for a type; throws if it does not exist. */
|
|
75
|
-
type ManifestReader = (specialty: string) => string;
|
|
76
|
-
|
|
77
|
-
/** Sink for the unknown-specialty warning (defaults to console.warn). */
|
|
78
|
-
type WarnFn = (message: string) => void;
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Lists the name of every discoverable specialty directory (e.g. the
|
|
82
|
-
* names of the subdirectories under specialties/). Used by listTypes() to
|
|
83
|
-
* discover candidates before resolving each one's manifest via `read`.
|
|
84
|
-
*/
|
|
85
|
-
type TypeDirLister = () => string[];
|
|
86
|
-
|
|
87
|
-
/** A discovered specialty, as surfaced to the new-specialty picker. */
|
|
88
|
-
export interface SpecialtyOption {
|
|
89
|
-
name: string;
|
|
90
|
-
displayName: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Default reader: loads {typesDir}/{specialty}/manifest.yaml from disk. */
|
|
94
|
-
export function defaultManifestReader(
|
|
95
|
-
specialty: string,
|
|
96
|
-
typesDir: string = resolveSpecialtiesDir(),
|
|
97
|
-
): string {
|
|
98
|
-
return readFileSync(join(typesDir, specialty, "manifest.yaml"), "utf-8");
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Default lister: the directory names under the specialties dir on disk. */
|
|
102
|
-
export function defaultTypeDirLister(
|
|
103
|
-
typesDir: string = resolveSpecialtiesDir(),
|
|
104
|
-
): string[] {
|
|
105
|
-
return readdirSync(typesDir, { withFileTypes: true })
|
|
106
|
-
.filter((entry) => entry.isDirectory())
|
|
107
|
-
.map((entry) => entry.name);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Interface the reconcile path depends on — kept narrow so tests can supply a
|
|
112
|
-
* trivial double without constructing the real disk-backed registry.
|
|
113
|
-
*/
|
|
114
|
-
export interface SpecialtyManifestResolver {
|
|
115
|
-
/**
|
|
116
|
-
* Resolve `specialty` to its parsed manifest. Falls back to the default
|
|
117
|
-
* type's manifest (with a warning) when the type is unknown, and to a
|
|
118
|
-
* built-in empty-crons manifest (with a warning) if the default cannot load
|
|
119
|
-
* either. Never throws.
|
|
120
|
-
*/
|
|
121
|
-
getManifest(specialty: string): SpecialtyManifest;
|
|
122
|
-
/**
|
|
123
|
-
* Resolve `specialty` to its parsed manifest WITHOUT falling back to
|
|
124
|
-
* the default type — returns undefined for an unknown type instead. No warning is
|
|
125
|
-
* logged (an unknown type here is an expected, callers-handle-it outcome,
|
|
126
|
-
* not a degraded-boot condition). Used by POST /admin/agents to reject an
|
|
127
|
-
* unknown requested `type` with 400 rather than silently seeding a
|
|
128
|
-
* default-type agent under a different label.
|
|
129
|
-
*/
|
|
130
|
-
tryGetManifest(specialty: string): SpecialtyManifest | undefined;
|
|
131
|
-
/**
|
|
132
|
-
* Discover every specialty directory (via the injected `list` fn) and
|
|
133
|
-
* resolve each to its {name, displayName} for populating the new-agent
|
|
134
|
-
* type picker. A directory whose manifest fails to load or parse is
|
|
135
|
-
* skipped (not thrown) — the picker still renders every other valid type
|
|
136
|
-
* rather than 500ing the whole /admin/agents/new page over one bad
|
|
137
|
-
* manifest. Order matches the underlying `list` fn's order.
|
|
138
|
-
*/
|
|
139
|
-
listTypes(): SpecialtyOption[];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
143
|
-
private readonly read: ManifestReader;
|
|
144
|
-
private readonly warn: WarnFn;
|
|
145
|
-
private readonly list: TypeDirLister;
|
|
146
|
-
private readonly defaultTypeName: string;
|
|
147
|
-
private readonly typesDir: string;
|
|
148
|
-
private readonly cache = new Map<string, SpecialtyManifest>();
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* `typesDir` and `defaultTypeName` default to SHIPWRIGHT_SPECIALTIES_DIR /
|
|
152
|
-
* SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to the shipped agent-types/
|
|
153
|
-
* and "specialist". An injected `read`/`list` takes precedence over `typesDir`.
|
|
154
|
-
*/
|
|
155
|
-
constructor(
|
|
156
|
-
opts: {
|
|
157
|
-
read?: ManifestReader;
|
|
158
|
-
warn?: WarnFn;
|
|
159
|
-
list?: TypeDirLister;
|
|
160
|
-
typesDir?: string;
|
|
161
|
-
defaultTypeName?: string;
|
|
162
|
-
} = {},
|
|
163
|
-
) {
|
|
164
|
-
const typesDir = opts.typesDir ?? resolveSpecialtiesDir();
|
|
165
|
-
this.typesDir = typesDir;
|
|
166
|
-
this.read = opts.read ?? ((name) => defaultManifestReader(name, typesDir));
|
|
167
|
-
this.warn = opts.warn ?? ((m) => console.warn(m));
|
|
168
|
-
this.list = opts.list ?? (() => defaultTypeDirLister(typesDir));
|
|
169
|
-
this.defaultTypeName =
|
|
170
|
-
opts.defaultTypeName ?? resolveDefaultSpecialtyName();
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
getManifest(specialty: string): SpecialtyManifest {
|
|
174
|
-
const resolved = this.tryLoadSafe(specialty);
|
|
175
|
-
if (resolved) return resolved;
|
|
176
|
-
|
|
177
|
-
this.warn(
|
|
178
|
-
`SpecialtyRegistry: unknown agent specialty "${specialty}" — ` +
|
|
179
|
-
`falling back to "${this.defaultTypeName}" manifest`,
|
|
180
|
-
);
|
|
181
|
-
if (specialty !== this.defaultTypeName) {
|
|
182
|
-
const fallback = this.tryLoadSafe(this.defaultTypeName);
|
|
183
|
-
if (fallback) return fallback;
|
|
184
|
-
}
|
|
185
|
-
// Even the default is unloadable (bad SHIPWRIGHT_SPECIALTIES_DIR /
|
|
186
|
-
// SHIPWRIGHT_DEFAULT_AGENT_TYPE override). Degrade to an empty manifest
|
|
187
|
-
// rather than failing boot; the warning names the knobs to fix.
|
|
188
|
-
this.warn(
|
|
189
|
-
`SpecialtyRegistry: the default specialty "${this.defaultTypeName}" could not be loaded from "${this.typesDir}" — ` +
|
|
190
|
-
"using a built-in manifest with no crons. Check SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE.",
|
|
191
|
-
);
|
|
192
|
-
return parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Resolve `specialty` without falling back to the default type — see the interface
|
|
197
|
-
* doc comment. Thin public wrapper around the existing tryLoad() used
|
|
198
|
-
* internally by getManifest().
|
|
199
|
-
*/
|
|
200
|
-
tryGetManifest(specialty: string): SpecialtyManifest | undefined {
|
|
201
|
-
return this.tryLoad(specialty);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
listTypes(): SpecialtyOption[] {
|
|
205
|
-
const specialties = this.list();
|
|
206
|
-
const options: SpecialtyOption[] = [];
|
|
207
|
-
for (const specialty of specialties) {
|
|
208
|
-
const manifest = this.tryLoad(specialty);
|
|
209
|
-
if (!manifest) continue;
|
|
210
|
-
options.push({
|
|
211
|
-
name: manifest.metadata.name,
|
|
212
|
-
displayName: manifest.metadata.displayName,
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
return options;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** tryLoad, but an invalid (unparseable) manifest also yields undefined. */
|
|
219
|
-
private tryLoadSafe(specialty: string): SpecialtyManifest | undefined {
|
|
220
|
-
try {
|
|
221
|
-
return this.tryLoad(specialty);
|
|
222
|
-
} catch {
|
|
223
|
-
return undefined;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Load + cache a manifest, returning undefined only if the file is missing. */
|
|
228
|
-
private tryLoad(specialty: string): SpecialtyManifest | undefined {
|
|
229
|
-
const cached = this.cache.get(specialty);
|
|
230
|
-
if (cached) return cached;
|
|
231
|
-
let raw: string;
|
|
232
|
-
try {
|
|
233
|
-
raw = this.read(specialty);
|
|
234
|
-
} catch {
|
|
235
|
-
return undefined;
|
|
236
|
-
}
|
|
237
|
-
return this.parseAndCache(specialty, raw);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private parseAndCache(specialty: string, raw: string): SpecialtyManifest {
|
|
241
|
-
const manifest = parseSpecialtyManifest(raw);
|
|
242
|
-
this.cache.set(specialty, manifest);
|
|
243
|
-
return manifest;
|
|
244
|
-
}
|
|
245
|
-
}
|