@intentius/chant-lexicon-fountain 0.33.0 → 0.34.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/README.md +3 -3
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/op/activities/fountain-apply.d.ts +34 -31
- package/dist/op/activities/fountain-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-fountain-secrets.md +1 -1
- package/dist/skills/chant-fountain.md +1 -1
- package/package.json +2 -2
- package/src/codegen/docs.ts +12 -11
- package/src/op/activities/fountain-apply.test.ts +220 -59
- package/src/op/activities/fountain-apply.ts +106 -104
- package/src/op/activities/index.ts +3 -4
- package/src/plugin.ts +2 -2
- package/src/serializer.test.ts +16 -26
- package/src/serializer.ts +9 -12
- package/src/skills/chant-fountain-secrets.md +1 -1
- package/src/skills/chant-fountain.md +1 -1
- package/src/spec/fetch.ts +1 -1
- package/src/generated/.gitkeep +0 -0
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ resources: `Environment` (sandbox baseline), `Vault` (env-var overrides),
|
|
|
9
9
|
start them with the `fountainRun` op.
|
|
10
10
|
|
|
11
11
|
`chant build` serializes to fountain's own manifest YAML (`fountain apply -f`
|
|
12
|
-
accepts it verbatim)
|
|
13
|
-
|
|
12
|
+
accepts it verbatim) — the same YAML `fountainApply` sends to fountain's bulk
|
|
13
|
+
`POST /api/apply` endpoint in one request.
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
16
|
import { Environment, Agent } from "@intentius/chant-lexicon-fountain";
|
|
@@ -52,7 +52,7 @@ added, plus the request schemas deliberately left unmodeled.
|
|
|
52
52
|
## Project structure
|
|
53
53
|
|
|
54
54
|
- `src/plugin.ts` — LexiconPlugin with all lifecycle methods
|
|
55
|
-
- `src/serializer.ts` — manifest YAML
|
|
55
|
+
- `src/serializer.ts` — manifest YAML output
|
|
56
56
|
- `src/codegen/`, `src/spec/` — generation pipeline and spec fetch/parse
|
|
57
57
|
- `src/lint/rules/`, `src/lint/post-synth/` — FTN001 (AST) and FTN010–017
|
|
58
58
|
- `src/op/activities/` — `fountainApply` (reconciler) and `fountainRun`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;
|
|
1
|
+
{"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAkNH;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDjF"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "1f8298cb243abce8502bb115152fd11c88547289737f0c2fa0df7b8129639f53",
|
|
5
5
|
"meta.json": "6666b7a77db9210a329219c6a5e107e5ae8794a34542f7310ce16d9bf95c64f5",
|
|
6
6
|
"types/index.d.ts": "1dfdcba184fffcf464dac7d1d71ef7abed2ae02a8d638c0de50e0b0317b7eb01",
|
|
7
7
|
"rules/ftn001-no-secret-literals.ts": "897a4ce1ec790b3c1540d32892603bd33ff4bf30eb6ba4cd2565dee356d4962e",
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"rules/ftn015-mcp-secret-env-substitution.ts": "88dc87c8f403a2fe48ee43bc4a4678d95273dba810135c8e74750f1bd62d2564",
|
|
14
14
|
"rules/ftn016-runtime-model-valid.ts": "b4aa86267a411678fe6a8ae3556fd9b34cf1c15aaecfe99790216d529a494f44",
|
|
15
15
|
"rules/ftn017-unique-resource-names.ts": "866f3f385083f96b6e8a18fa6f5624bab43752c5d22e7291e95835e62745d26c",
|
|
16
|
-
"skills/chant-fountain.md": "
|
|
17
|
-
"skills/chant-fountain-secrets.md": "
|
|
16
|
+
"skills/chant-fountain.md": "06dd02d19394fa777c7ad91dc54f684d273a36456a8660d41dc9701e48d955b3",
|
|
17
|
+
"skills/chant-fountain-secrets.md": "27e349a91589510a92e518c7d7824a4a322cab5ef242cf5799373a55cbfcd1cd",
|
|
18
18
|
"skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
|
|
19
19
|
},
|
|
20
|
-
"composite": "
|
|
20
|
+
"composite": "3321e342144da2914f44f7085c4bccfc85055c16cf8435288283c28019b4149b"
|
|
21
21
|
}
|
package/dist/manifest.json
CHANGED
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* fountain native applier.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `fountain apply`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Compiles the serializer's manifest YAML — the same `apiVersion:
|
|
5
|
+
* fountain.dev/v1` documents `fountain apply -f` accepts — into fountain's
|
|
6
|
+
* `POST /api/apply` bulk-apply request and sends it in one call. That
|
|
7
|
+
* endpoint (BinaryBourbon/fountain#151) does the reconciliation server-side:
|
|
8
|
+
* upsert by name, environments -> vaults -> agents, an agent's
|
|
9
|
+
* `spec.environment` name resolved against the manifest or the tenant's
|
|
10
|
+
* existing environments, secrets upserted through the encrypted envelope
|
|
11
|
+
* path. Best-effort per resource — every result is collected before this
|
|
12
|
+
* throws, so one bad resource doesn't hide failures in the rest of the
|
|
13
|
+
* manifest, and a partial apply is never silently reported as clean.
|
|
9
14
|
*
|
|
10
|
-
*
|
|
11
|
-
* environment by
|
|
12
|
-
*
|
|
15
|
+
* No id resolution happens here anymore: since the server resolves an
|
|
16
|
+
* agent's `environment` reference by name itself, the manifest's `spec`
|
|
17
|
+
* passes through unmodified except for one shape adjustment — chant's
|
|
18
|
+
* authored `secrets` is an ordered `{key, value}[]`, the wire format wants
|
|
19
|
+
* `{KEY: value}` (see `toApplyPayload`).
|
|
13
20
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* be detected, only overwritten. (BinaryBourbon/fountain#148's reference
|
|
18
|
-
* model would make this a plain diff; until then, upsert-always is the
|
|
19
|
-
* honest semantic.)
|
|
21
|
+
* Prune (opt-in, chant-owned only) isn't part of bulk apply, so it still
|
|
22
|
+
* lists each kind's live state and deletes what's absent from the
|
|
23
|
+
* manifest, same as before.
|
|
20
24
|
*
|
|
21
25
|
* Endpoint/token resolution: explicit args win, then FOUNTAIN_ENDPOINT /
|
|
22
26
|
* FOUNTAIN_TOKEN, then the hosted default endpoint.
|
|
23
27
|
*/
|
|
24
|
-
export declare const DEFAULT_FOUNTAIN_BASE_URL = "https://
|
|
28
|
+
export declare const DEFAULT_FOUNTAIN_BASE_URL = "https://fountain.inevitable.fyi";
|
|
25
29
|
/** Ownership marker checked by the owned-only prune. */
|
|
26
30
|
export declare const OWNERSHIP_KEY = "managed-by";
|
|
27
31
|
export declare const OWNERSHIP_VALUE = "chant";
|
|
28
|
-
export interface
|
|
32
|
+
export interface ManifestResource {
|
|
29
33
|
kind: string;
|
|
34
|
+
name: string;
|
|
30
35
|
spec: Record<string, unknown>;
|
|
31
36
|
}
|
|
32
|
-
export type FountainPlan = Record<string, PlanEntry>;
|
|
33
37
|
export interface FountainHttp {
|
|
34
38
|
(method: string, path: string, body?: unknown): Promise<{
|
|
35
39
|
status: number;
|
|
@@ -37,13 +41,13 @@ export interface FountainHttp {
|
|
|
37
41
|
}>;
|
|
38
42
|
}
|
|
39
43
|
export interface FountainApplyArgs {
|
|
40
|
-
/** Path to the serializer's fountain
|
|
41
|
-
|
|
42
|
-
/** Inline
|
|
43
|
-
|
|
44
|
+
/** Path to the serializer's compiled fountain manifest YAML. */
|
|
45
|
+
manifestPath?: string;
|
|
46
|
+
/** Inline manifest YAML content (takes precedence over manifestPath). */
|
|
47
|
+
manifestContent?: string;
|
|
44
48
|
endpoint?: string;
|
|
45
49
|
token?: string;
|
|
46
|
-
/** Delete chant-owned resources absent from the
|
|
50
|
+
/** Delete chant-owned resources absent from the manifest. Off by default. */
|
|
47
51
|
prune?: boolean;
|
|
48
52
|
}
|
|
49
53
|
export interface FountainApplySummary {
|
|
@@ -58,15 +62,14 @@ export declare function resolveEndpoint(args?: {
|
|
|
58
62
|
export declare function resolveToken(args?: {
|
|
59
63
|
token?: string;
|
|
60
64
|
}, env?: NodeJS.ProcessEnv): string;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
65
|
+
/** Parse the serializer's multi-document manifest YAML into apply resources. Pure. */
|
|
66
|
+
export declare function parseManifest(content: string): ManifestResource[];
|
|
67
|
+
/**
|
|
68
|
+
* Fountain's bulk-apply spec takes `secrets` as a `{KEY: value}` map;
|
|
69
|
+
* chant's authored shape is an ordered `{key, value}[]`. Convert only that
|
|
70
|
+
* one field — the rest of spec passes through untouched. Pure.
|
|
71
|
+
*/
|
|
72
|
+
export declare function toApplyPayload(spec: Record<string, unknown>): Record<string, unknown>;
|
|
70
73
|
/** Is a live resource chant-owned (by its metadata marker)? Pure. */
|
|
71
74
|
export declare function isChantOwned(resource: {
|
|
72
75
|
metadata?: unknown;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fountain-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/fountain-apply.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"fountain-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/fountain-apply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,eAAO,MAAM,yBAAyB,oCAAoC,CAAC;AAE3E,wDAAwD;AACxD,eAAO,MAAM,aAAa,eAAe,CAAC;AAC1C,eAAO,MAAM,eAAe,UAAU,CAAC;AAavC,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CAC5F;AAED,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;CACzB;AAID,wBAAgB,eAAe,CAC7B,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,EAChC,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,MAAM,CAGR;AAED,wBAAgB,YAAY,CAC1B,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,EAC7B,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,MAAM,CAIR;AAMD,sFAAsF;AACtF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAajE;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUrF;AAED,qEAAqE;AACrE,wBAAgB,YAAY,CAAC,QAAQ,EAAE;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAItE;AAID,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,YAAY,CAkBjF;AA+BD,wBAAsB,aAAa,CACjC,IAAI,EAAE,iBAAiB,EACvB,IAAI,CAAC,EAAE,YAAY,GAClB,OAAO,CAAC,oBAAoB,CAAC,CAyD/B"}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* the native applier (`fountainApply` — direct REST against fountain's
|
|
5
5
|
* API, no CLI, no state file) and the conversation runner (`fountainRun`).
|
|
6
6
|
*/
|
|
7
|
-
export { fountainApply, resolveEndpoint, resolveToken,
|
|
8
|
-
export type { FountainApplyArgs, FountainApplySummary,
|
|
7
|
+
export { fountainApply, resolveEndpoint, resolveToken, parseManifest, toApplyPayload, isChantOwned, defaultFountainHttp, DEFAULT_FOUNTAIN_BASE_URL, OWNERSHIP_KEY, OWNERSHIP_VALUE, } from "./fountain-apply.js";
|
|
8
|
+
export type { FountainApplyArgs, FountainApplySummary, ManifestResource, FountainHttp, } from "./fountain-apply.js";
|
|
9
9
|
export { fountainRun, resolveAgentId, TERMINAL_STATUSES } from "./fountain-run.js";
|
|
10
10
|
export type { FountainRunArgs, FountainRunResult } from "./fountain-run.js";
|
|
11
11
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,YAAY,EACZ,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,YAAY,EACZ,aAAa,EACb,cAAc,EACd,YAAY,EACZ,mBAAmB,EACnB,yBAAyB,EACzB,aAAa,EACb,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,GACb,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAChF,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/serializer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,
|
|
1
|
+
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,MAAM,kBAAkB,CAAC;AA2B/D,eAAO,MAAM,kBAAkB,EAAE,UAgDhC,CAAC"}
|
|
@@ -11,7 +11,7 @@ user-invocable: true
|
|
|
11
11
|
Everything materialized into a fountain sandbox must be presumed exfiltrated once untrusted agent code runs. Order of preference:
|
|
12
12
|
|
|
13
13
|
1. **`${VAR}` substitution references** in agent config (MCP server env, system prompts). Resolved at spawn from the merged environment + vault sets. Never a value in source.
|
|
14
|
-
2. **Environment secrets** (`spec.secrets`) — encrypted at rest, write-only over the API (values are never returned once stored). `fountainApply` upserts them through the
|
|
14
|
+
2. **Environment secrets** (`spec.secrets`) — encrypted at rest, write-only over the API (values are never returned once stored). `fountainApply` sends them inline with the rest of the resource in the bulk apply request, and the server upserts them through the encrypted envelope path; a changed value cannot be detected, only overwritten.
|
|
15
15
|
3. **`env_vars`** — plaintext config only. FTN012 errors on credential-shaped keys or values here.
|
|
16
16
|
|
|
17
17
|
Never put a literal credential anywhere in a declaration: FTN001 catches known shapes (AWS keys, GitHub/Slack tokens, `sk-`/`ftn_` keys, private key material) at the AST; FTN015 errors on secret-shaped MCP env keys that are not `${VAR}` references.
|
|
@@ -10,7 +10,7 @@ user-invocable: true
|
|
|
10
10
|
|
|
11
11
|
[fountain](https://github.com/BinaryBourbon/fountain) runs coding agents in sandboxed VMs. Its workload layer is three kinds — `Environment` (sandbox baseline), `Vault` (env-var overrides), `Agent` (a runnable agent config) — and this lexicon declares them as typed chant resources. Conversations are runs, not resources: start them with the `fountainRun` op, never declare them.
|
|
12
12
|
|
|
13
|
-
The source of truth is the TypeScript in `src/`. `chant build` serializes it to fountain's own manifest YAML (ejectable — `fountain apply -f` accepts it verbatim)
|
|
13
|
+
The source of truth is the TypeScript in `src/`. `chant build` serializes it to fountain's own manifest YAML (ejectable — `fountain apply -f` accepts it verbatim). `fountainApply` sends that same YAML to fountain's bulk `POST /api/apply` endpoint in one request: create-if-new, update-by-name, opt-in owned-only prune keyed on the `managed-by: chant` metadata marker.
|
|
14
14
|
|
|
15
15
|
## Authoring
|
|
16
16
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-fountain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"bundle": "tsx src/package-cli.ts"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@intentius/chant": "^0.
|
|
53
|
+
"@intentius/chant": "^0.34.0",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
package/src/codegen/docs.ts
CHANGED
|
@@ -55,7 +55,7 @@ export const helper = new Agent({
|
|
|
55
55
|
|
|
56
56
|
\`FOUNTAIN_ENDPOINT\` (defaults to the hosted instance) and \`FOUNTAIN_TOKEN\`. Mint a token via \`POST /api/auth/token\` with email and password, or from the account UI. The same code applies to a local \`mix phx.server\` fountain by pointing \`FOUNTAIN_ENDPOINT\` at it — registration and token mint work headless, so CI needs no browser.`;
|
|
57
57
|
|
|
58
|
-
const outputFormat = `The fountain lexicon serializes to fountain's own manifest YAML
|
|
58
|
+
const outputFormat = `The fountain lexicon serializes to fountain's own manifest YAML — nothing else. \`fountainApply\` reads that same YAML, so there is no sidecar to keep in sync with it.
|
|
59
59
|
|
|
60
60
|
## Manifests
|
|
61
61
|
|
|
@@ -75,17 +75,17 @@ spec:
|
|
|
75
75
|
|
|
76
76
|
The output is ejectable — \`fountain apply -f\` accepts it verbatim, so adopting chant here does not trap the manifests behind chant.
|
|
77
77
|
|
|
78
|
-
##
|
|
78
|
+
## Applying the manifest
|
|
79
79
|
|
|
80
|
-
\`
|
|
80
|
+
\`fountainApply\` parses this same YAML and sends it to fountain's bulk \`POST /api/apply\` endpoint in one request — the server reconciles by name, Environment → Vault → Agent, and resolves an agent's \`environment\` reference itself, against the manifest or the tenant's existing environments. See the Ops page for the activity's own behavior (prune, secrets, failure reporting).
|
|
81
81
|
|
|
82
82
|
## Ownership
|
|
83
83
|
|
|
84
|
-
Resources carrying \`metadata."managed-by": chant\` are chant-owned. That marker gates the opt-in prune (\`fountainApply\` deletes only owned resources absent from the
|
|
84
|
+
Resources carrying \`metadata."managed-by": chant\` are chant-owned. That marker gates the opt-in prune (\`fountainApply\` deletes only owned resources absent from the manifest) and the \`--owned\` filter on drift and live export. Set it on every declaration you want reconciled.
|
|
85
85
|
|
|
86
86
|
## Secrets
|
|
87
87
|
|
|
88
|
-
\`spec.secrets\`
|
|
88
|
+
\`spec.secrets\` is authored as an ordered \`{key, value}[]\`, same as any other typed prop. \`fountainApply\` converts it to the \`{KEY: value}\` map fountain's bulk apply expects on the wire; the server upserts it through the encrypted envelope path. Values are write-only upstream, so this is upsert-always — a changed value cannot be detected, only overwritten.`;
|
|
89
89
|
|
|
90
90
|
const resourcesPage = `The lexicon types the three kinds \`fountain apply\` reconciles — the workload
|
|
91
91
|
layer of [fountain](https://github.com/BinaryBourbon/fountain). Types are
|
|
@@ -166,17 +166,18 @@ const opsPage = `Two op activities ship with the lexicon, resolvable by name via
|
|
|
166
166
|
|
|
167
167
|
## fountainApply
|
|
168
168
|
|
|
169
|
-
The native applier:
|
|
169
|
+
The native applier: compiles the serializer's manifest YAML into fountain's bulk \`POST /api/apply\` request and sends it in one call.
|
|
170
170
|
|
|
171
171
|
| Behavior | Detail |
|
|
172
172
|
|---|---|
|
|
173
|
-
| Create / update | By name.
|
|
174
|
-
| Order | Environment → Vault → Agent,
|
|
175
|
-
| Prune | Off by default. With \`prune: true\`, chant-owned resources absent from the
|
|
176
|
-
| Secrets |
|
|
173
|
+
| Create / update | By name, reconciled server-side. An agent's \`environment\` reference resolves by name — against the manifest or the tenant's existing environments — without a client-side id lookup. |
|
|
174
|
+
| Order | Environment → Vault → Agent, fixed server-side regardless of manifest order. |
|
|
175
|
+
| Prune | Off by default. With \`prune: true\`, chant-owned resources absent from the manifest are deleted, in reverse order — the one thing bulk apply doesn't cover, so this still lists live state per kind. |
|
|
176
|
+
| Secrets | \`spec.secrets\` converts from chant's authored \`{key, value}[]\` to the wire's \`{KEY: value}\` map; the server upserts them through the encrypted envelope path in the same request. Upsert-always — values are write-only upstream. |
|
|
177
|
+
| Failure | Best-effort per resource. Every result is collected before this throws, so one bad resource doesn't hide failures elsewhere in the manifest. |
|
|
177
178
|
|
|
178
179
|
\`\`\`typescript
|
|
179
|
-
await fountainApply({
|
|
180
|
+
await fountainApply({ manifestPath: "build/fountain.yaml", prune: true });
|
|
180
181
|
\`\`\`
|
|
181
182
|
|
|
182
183
|
Endpoint and token resolution: explicit args win, then \`FOUNTAIN_ENDPOINT\` / \`FOUNTAIN_TOKEN\`, then the hosted default.
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
fountainApply,
|
|
4
|
-
|
|
4
|
+
parseManifest,
|
|
5
|
+
toApplyPayload,
|
|
5
6
|
isChantOwned,
|
|
6
7
|
resolveEndpoint,
|
|
7
|
-
parsePlan,
|
|
8
8
|
type FountainHttp,
|
|
9
|
-
type FountainPlan,
|
|
10
9
|
} from "./fountain-apply";
|
|
11
10
|
import { fountainRun } from "./fountain-run";
|
|
12
11
|
|
|
@@ -32,26 +31,65 @@ function fakeHttp(routes: Record<string, { status: number; json?: unknown }>): {
|
|
|
32
31
|
return { http, calls };
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
const MANIFEST = `apiVersion: fountain.dev/v1
|
|
35
|
+
kind: Environment
|
|
36
|
+
metadata:
|
|
37
|
+
name: concierge-env
|
|
38
|
+
spec:
|
|
39
|
+
networking_type: limited
|
|
40
|
+
---
|
|
41
|
+
apiVersion: fountain.dev/v1
|
|
42
|
+
kind: Agent
|
|
43
|
+
metadata:
|
|
44
|
+
name: researcher
|
|
45
|
+
spec:
|
|
46
|
+
model: a/m
|
|
47
|
+
runtime: claude
|
|
48
|
+
environment: concierge-env
|
|
49
|
+
`;
|
|
40
50
|
|
|
41
51
|
describe("pure helpers", () => {
|
|
42
52
|
it("resolveEndpoint precedence: arg > env > default", () => {
|
|
43
53
|
expect(resolveEndpoint({ endpoint: "http://x/" }, {})).toBe("http://x");
|
|
44
54
|
expect(resolveEndpoint({}, { FOUNTAIN_ENDPOINT: "http://env" })).toBe("http://env");
|
|
45
|
-
expect(resolveEndpoint({}, {})).toBe("https://
|
|
55
|
+
expect(resolveEndpoint({}, {})).toBe("https://fountain.inevitable.fyi");
|
|
46
56
|
});
|
|
47
57
|
|
|
48
|
-
it("
|
|
49
|
-
const
|
|
58
|
+
it("parseManifest reads kind/name/spec from each YAML document", () => {
|
|
59
|
+
const resources = parseManifest(MANIFEST);
|
|
60
|
+
expect(resources).toEqual([
|
|
61
|
+
{ kind: "Environment", name: "concierge-env", spec: { networking_type: "limited" } },
|
|
62
|
+
{
|
|
63
|
+
kind: "Agent",
|
|
64
|
+
name: "researcher",
|
|
65
|
+
spec: { model: "a/m", runtime: "claude", environment: "concierge-env" },
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("parseManifest skips documents with an unknown or missing kind", () => {
|
|
71
|
+
const resources = parseManifest(
|
|
72
|
+
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: n\nspec: {}\n",
|
|
73
|
+
);
|
|
74
|
+
expect(resources).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("parseManifest ignores blank documents", () => {
|
|
78
|
+
expect(parseManifest("")).toEqual([]);
|
|
79
|
+
expect(parseManifest("\n---\n\n")).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("toApplyPayload converts the authored secrets array into a key/value map", () => {
|
|
83
|
+
const payload = toApplyPayload({
|
|
50
84
|
name: "e",
|
|
51
85
|
secrets: [{ key: "K", value: "v" }, { bogus: true }],
|
|
52
86
|
});
|
|
53
|
-
expect(
|
|
54
|
-
|
|
87
|
+
expect(payload).toEqual({ name: "e", secrets: { K: "v" } });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("toApplyPayload passes spec through unchanged when there is no secrets array", () => {
|
|
91
|
+
const spec = { name: "e", networking_type: "limited" };
|
|
92
|
+
expect(toApplyPayload(spec)).toEqual(spec);
|
|
55
93
|
});
|
|
56
94
|
|
|
57
95
|
it("isChantOwned keys on the metadata marker", () => {
|
|
@@ -62,67 +100,170 @@ describe("pure helpers", () => {
|
|
|
62
100
|
});
|
|
63
101
|
|
|
64
102
|
describe("fountainApply", () => {
|
|
65
|
-
it("
|
|
66
|
-
const plan: FountainPlan = {
|
|
67
|
-
researcher: {
|
|
68
|
-
kind: "Agent",
|
|
69
|
-
spec: { name: "researcher", model: "a/m", runtime: "claude", environment: "conciergeEnv" },
|
|
70
|
-
},
|
|
71
|
-
conciergeEnv: { kind: "Environment", spec: { name: "concierge-env", networking_type: "limited" } },
|
|
72
|
-
};
|
|
73
|
-
|
|
103
|
+
it("sends the whole manifest in one POST /api/apply call", async () => {
|
|
74
104
|
const { http, calls } = fakeHttp({
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
105
|
+
"POST /api/apply": {
|
|
106
|
+
status: 200,
|
|
107
|
+
json: {
|
|
108
|
+
data: {
|
|
109
|
+
results: [
|
|
110
|
+
{ kind: "Environment", name: "concierge-env", action: "created", errors: null, secrets: [] },
|
|
111
|
+
{ kind: "Agent", name: "researcher", action: "created", errors: null, secrets: [] },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
78
116
|
});
|
|
79
117
|
|
|
80
|
-
const summary = await fountainApply({
|
|
118
|
+
const summary = await fountainApply({ manifestContent: MANIFEST }, http);
|
|
81
119
|
|
|
82
|
-
|
|
83
|
-
expect(
|
|
84
|
-
const
|
|
85
|
-
expect(
|
|
86
|
-
|
|
120
|
+
expect(calls).toHaveLength(1);
|
|
121
|
+
expect(calls[0]).toMatchObject({ method: "POST", path: "/api/apply" });
|
|
122
|
+
const body = calls[0].body as { resources: Array<{ kind: string; name: string; spec: unknown }> };
|
|
123
|
+
expect(body.resources).toEqual([
|
|
124
|
+
{ kind: "Environment", name: "concierge-env", spec: { networking_type: "limited" } },
|
|
125
|
+
{ kind: "Agent", name: "researcher", spec: { model: "a/m", runtime: "claude", environment: "concierge-env" } },
|
|
126
|
+
]);
|
|
87
127
|
expect(summary.created).toEqual(["Environment/concierge-env", "Agent/researcher"]);
|
|
88
128
|
});
|
|
89
129
|
|
|
90
|
-
it("
|
|
91
|
-
const plan: FountainPlan = {
|
|
92
|
-
v: { kind: "Vault", spec: { name: "staging-creds", description: "d" } },
|
|
93
|
-
};
|
|
130
|
+
it("does not resolve the agent's environment reference client-side — the server does that now", async () => {
|
|
94
131
|
const { http, calls } = fakeHttp({
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
132
|
+
"POST /api/apply": {
|
|
133
|
+
status: 200,
|
|
134
|
+
json: { data: { results: [{ kind: "Agent", name: "researcher", action: "created", errors: null, secrets: [] }] } },
|
|
135
|
+
},
|
|
98
136
|
});
|
|
99
137
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
expect(
|
|
138
|
+
await fountainApply({ manifestContent: MANIFEST }, http);
|
|
139
|
+
const body = calls[0].body as { resources: Array<{ spec: Record<string, unknown> }> };
|
|
140
|
+
expect(body.resources[1].spec.environment).toBe("concierge-env");
|
|
141
|
+
expect(body.resources[1].spec.environment_id).toBeUndefined();
|
|
103
142
|
});
|
|
104
143
|
|
|
105
|
-
it("
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
144
|
+
it("reports updated actions and converts spec.secrets to a map on the wire", async () => {
|
|
145
|
+
const manifest = `apiVersion: fountain.dev/v1
|
|
146
|
+
kind: Environment
|
|
147
|
+
metadata:
|
|
148
|
+
name: e
|
|
149
|
+
spec:
|
|
150
|
+
secrets:
|
|
151
|
+
- key: OPENAI_API_KEY
|
|
152
|
+
value: sk-abc
|
|
153
|
+
`;
|
|
112
154
|
const { http, calls } = fakeHttp({
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
155
|
+
"POST /api/apply": {
|
|
156
|
+
status: 200,
|
|
157
|
+
json: {
|
|
158
|
+
data: {
|
|
159
|
+
results: [
|
|
160
|
+
{
|
|
161
|
+
kind: "Environment",
|
|
162
|
+
name: "e",
|
|
163
|
+
action: "updated",
|
|
164
|
+
errors: null,
|
|
165
|
+
secrets: [{ key: "OPENAI_API_KEY", action: "upserted", errors: null }],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
116
171
|
});
|
|
117
172
|
|
|
118
|
-
const summary = await fountainApply({
|
|
173
|
+
const summary = await fountainApply({ manifestContent: manifest }, http);
|
|
174
|
+
expect(summary.updated).toEqual(["Environment/e"]);
|
|
119
175
|
expect(summary.secretsUpserted).toBe(1);
|
|
120
|
-
const
|
|
121
|
-
expect(
|
|
176
|
+
const body = calls[0].body as { resources: Array<{ spec: Record<string, unknown> }> };
|
|
177
|
+
expect(body.resources[0].spec.secrets).toEqual({ OPENAI_API_KEY: "sk-abc" });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("throws with every failure once all results are in, not just the first", async () => {
|
|
181
|
+
const manifest = `apiVersion: fountain.dev/v1
|
|
182
|
+
kind: Environment
|
|
183
|
+
metadata:
|
|
184
|
+
name: bad-env
|
|
185
|
+
spec: {}
|
|
186
|
+
---
|
|
187
|
+
apiVersion: fountain.dev/v1
|
|
188
|
+
kind: Vault
|
|
189
|
+
metadata:
|
|
190
|
+
name: bad-vault
|
|
191
|
+
spec: {}
|
|
192
|
+
`;
|
|
193
|
+
const { http } = fakeHttp({
|
|
194
|
+
"POST /api/apply": {
|
|
195
|
+
status: 200,
|
|
196
|
+
json: {
|
|
197
|
+
data: {
|
|
198
|
+
results: [
|
|
199
|
+
{
|
|
200
|
+
kind: "Environment",
|
|
201
|
+
name: "bad-env",
|
|
202
|
+
action: "error",
|
|
203
|
+
errors: { name: ["has already been taken"] },
|
|
204
|
+
secrets: [],
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
kind: "Vault",
|
|
208
|
+
name: "bad-vault",
|
|
209
|
+
action: "error",
|
|
210
|
+
errors: { name: ["has already been taken"] },
|
|
211
|
+
secrets: [],
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await expect(fountainApply({ manifestContent: manifest }, http)).rejects.toThrow(/2 failure/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("throws on a failed secret upsert", async () => {
|
|
223
|
+
const manifest = `apiVersion: fountain.dev/v1
|
|
224
|
+
kind: Vault
|
|
225
|
+
metadata:
|
|
226
|
+
name: v
|
|
227
|
+
spec:
|
|
228
|
+
secrets:
|
|
229
|
+
- key: BAD
|
|
230
|
+
value: x
|
|
231
|
+
`;
|
|
232
|
+
const { http } = fakeHttp({
|
|
233
|
+
"POST /api/apply": {
|
|
234
|
+
status: 200,
|
|
235
|
+
json: {
|
|
236
|
+
data: {
|
|
237
|
+
results: [
|
|
238
|
+
{
|
|
239
|
+
kind: "Vault",
|
|
240
|
+
name: "v",
|
|
241
|
+
action: "created",
|
|
242
|
+
errors: null,
|
|
243
|
+
secrets: [{ key: "BAD", action: "error", errors: { value: ["must be a string"] } }],
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await expect(fountainApply({ manifestContent: manifest }, http)).rejects.toThrow(/BAD/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("throws when the server rejects the request outright", async () => {
|
|
255
|
+
const { http } = fakeHttp({ "POST /api/apply": { status: 500 } });
|
|
256
|
+
await expect(fountainApply({ manifestContent: MANIFEST }, http)).rejects.toThrow(/500/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("skips the POST entirely for an empty manifest", async () => {
|
|
260
|
+
const { http, calls } = fakeHttp({});
|
|
261
|
+
const summary = await fountainApply({ manifestContent: "" }, http);
|
|
262
|
+
expect(calls).toHaveLength(0);
|
|
263
|
+
expect(summary).toEqual({ created: [], updated: [], pruned: [], secretsUpserted: 0 });
|
|
122
264
|
});
|
|
123
265
|
|
|
124
266
|
it("prunes only chant-owned resources, in reverse kind order", async () => {
|
|
125
|
-
const plan: FountainPlan = {};
|
|
126
267
|
const { http, calls } = fakeHttp({
|
|
127
268
|
"GET /api/environments": {
|
|
128
269
|
status: 200,
|
|
@@ -142,15 +283,35 @@ describe("fountainApply", () => {
|
|
|
142
283
|
"DELETE /api/environments/e-1": { status: 204 },
|
|
143
284
|
});
|
|
144
285
|
|
|
145
|
-
const summary = await fountainApply({
|
|
286
|
+
const summary = await fountainApply({ manifestContent: "", prune: true }, http);
|
|
146
287
|
const deletes = calls.filter((c) => c.method === "DELETE").map((c) => c.path);
|
|
147
288
|
expect(deletes).toEqual(["/api/agents/a-1", "/api/environments/e-1"]);
|
|
148
289
|
expect(summary.pruned).toEqual(["Agent/owned-agent", "Environment/owned-env"]);
|
|
149
290
|
});
|
|
150
291
|
|
|
151
|
-
it("
|
|
152
|
-
const
|
|
153
|
-
|
|
292
|
+
it("does not prune a resource still present in the manifest", async () => {
|
|
293
|
+
const manifest = `apiVersion: fountain.dev/v1
|
|
294
|
+
kind: Environment
|
|
295
|
+
metadata:
|
|
296
|
+
name: kept-env
|
|
297
|
+
spec: {}
|
|
298
|
+
`;
|
|
299
|
+
const { http, calls } = fakeHttp({
|
|
300
|
+
"POST /api/apply": {
|
|
301
|
+
status: 200,
|
|
302
|
+
json: { data: { results: [{ kind: "Environment", name: "kept-env", action: "updated", errors: null, secrets: [] }] } },
|
|
303
|
+
},
|
|
304
|
+
"GET /api/environments": {
|
|
305
|
+
status: 200,
|
|
306
|
+
json: { data: [{ id: "e-1", name: "kept-env", metadata: { "managed-by": "chant" } }] },
|
|
307
|
+
},
|
|
308
|
+
"GET /api/vaults": { status: 200, json: { data: [] } },
|
|
309
|
+
"GET /api/agents": { status: 200, json: { data: [] } },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const summary = await fountainApply({ manifestContent: manifest, prune: true }, http);
|
|
313
|
+
expect(calls.some((c) => c.method === "DELETE")).toBe(false);
|
|
314
|
+
expect(summary.pruned).toEqual([]);
|
|
154
315
|
});
|
|
155
316
|
});
|
|
156
317
|
|
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* fountain native applier.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `fountain apply`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Compiles the serializer's manifest YAML — the same `apiVersion:
|
|
5
|
+
* fountain.dev/v1` documents `fountain apply -f` accepts — into fountain's
|
|
6
|
+
* `POST /api/apply` bulk-apply request and sends it in one call. That
|
|
7
|
+
* endpoint (BinaryBourbon/fountain#151) does the reconciliation server-side:
|
|
8
|
+
* upsert by name, environments -> vaults -> agents, an agent's
|
|
9
|
+
* `spec.environment` name resolved against the manifest or the tenant's
|
|
10
|
+
* existing environments, secrets upserted through the encrypted envelope
|
|
11
|
+
* path. Best-effort per resource — every result is collected before this
|
|
12
|
+
* throws, so one bad resource doesn't hide failures in the rest of the
|
|
13
|
+
* manifest, and a partial apply is never silently reported as clean.
|
|
9
14
|
*
|
|
10
|
-
*
|
|
11
|
-
* environment by
|
|
12
|
-
*
|
|
15
|
+
* No id resolution happens here anymore: since the server resolves an
|
|
16
|
+
* agent's `environment` reference by name itself, the manifest's `spec`
|
|
17
|
+
* passes through unmodified except for one shape adjustment — chant's
|
|
18
|
+
* authored `secrets` is an ordered `{key, value}[]`, the wire format wants
|
|
19
|
+
* `{KEY: value}` (see `toApplyPayload`).
|
|
13
20
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* be detected, only overwritten. (BinaryBourbon/fountain#148's reference
|
|
18
|
-
* model would make this a plain diff; until then, upsert-always is the
|
|
19
|
-
* honest semantic.)
|
|
21
|
+
* Prune (opt-in, chant-owned only) isn't part of bulk apply, so it still
|
|
22
|
+
* lists each kind's live state and deletes what's absent from the
|
|
23
|
+
* manifest, same as before.
|
|
20
24
|
*
|
|
21
25
|
* Endpoint/token resolution: explicit args win, then FOUNTAIN_ENDPOINT /
|
|
22
26
|
* FOUNTAIN_TOKEN, then the hosted default endpoint.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
import { readFileSync } from "node:fs";
|
|
30
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
26
31
|
|
|
27
|
-
export const DEFAULT_FOUNTAIN_BASE_URL = "https://
|
|
32
|
+
export const DEFAULT_FOUNTAIN_BASE_URL = "https://fountain.inevitable.fyi";
|
|
28
33
|
|
|
29
34
|
/** Ownership marker checked by the owned-only prune. */
|
|
30
35
|
export const OWNERSHIP_KEY = "managed-by";
|
|
@@ -36,28 +41,29 @@ const KIND_PATHS: Record<string, string> = {
|
|
|
36
41
|
Agent: "agents",
|
|
37
42
|
};
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
const KINDS = new Set(Object.keys(KIND_PATHS));
|
|
45
|
+
|
|
46
|
+
/** Prune-only ordering now — bulk apply reconciles create/update order itself. */
|
|
40
47
|
const APPLY_ORDER = ["Environment", "Vault", "Agent"] as const;
|
|
41
48
|
|
|
42
|
-
export interface
|
|
49
|
+
export interface ManifestResource {
|
|
43
50
|
kind: string;
|
|
51
|
+
name: string;
|
|
44
52
|
spec: Record<string, unknown>;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
export type FountainPlan = Record<string, PlanEntry>;
|
|
48
|
-
|
|
49
55
|
export interface FountainHttp {
|
|
50
56
|
(method: string, path: string, body?: unknown): Promise<{ status: number; json: unknown }>;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
export interface FountainApplyArgs {
|
|
54
|
-
/** Path to the serializer's fountain
|
|
55
|
-
|
|
56
|
-
/** Inline
|
|
57
|
-
|
|
60
|
+
/** Path to the serializer's compiled fountain manifest YAML. */
|
|
61
|
+
manifestPath?: string;
|
|
62
|
+
/** Inline manifest YAML content (takes precedence over manifestPath). */
|
|
63
|
+
manifestContent?: string;
|
|
58
64
|
endpoint?: string;
|
|
59
65
|
token?: string;
|
|
60
|
-
/** Delete chant-owned resources absent from the
|
|
66
|
+
/** Delete chant-owned resources absent from the manifest. Off by default. */
|
|
61
67
|
prune?: boolean;
|
|
62
68
|
}
|
|
63
69
|
|
|
@@ -87,22 +93,41 @@ export function resolveToken(
|
|
|
87
93
|
return token;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
|
|
91
|
-
return
|
|
96
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
97
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse the serializer's multi-document manifest YAML into apply resources. Pure. */
|
|
101
|
+
export function parseManifest(content: string): ManifestResource[] {
|
|
102
|
+
const resources: ManifestResource[] = [];
|
|
103
|
+
for (const docText of content.split(/^---\s*$/m)) {
|
|
104
|
+
if (!docText.trim()) continue;
|
|
105
|
+
const doc = parseYAML(docText);
|
|
106
|
+
const kind = typeof doc.kind === "string" ? doc.kind : "";
|
|
107
|
+
if (!KINDS.has(kind)) continue;
|
|
108
|
+
const meta = isRecord(doc.metadata) ? doc.metadata : {};
|
|
109
|
+
const name = typeof meta.name === "string" ? meta.name : "";
|
|
110
|
+
const spec = isRecord(doc.spec) ? doc.spec : {};
|
|
111
|
+
resources.push({ kind, name, spec });
|
|
112
|
+
}
|
|
113
|
+
return resources;
|
|
92
114
|
}
|
|
93
115
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Fountain's bulk-apply spec takes `secrets` as a `{KEY: value}` map;
|
|
118
|
+
* chant's authored shape is an ordered `{key, value}[]`. Convert only that
|
|
119
|
+
* one field — the rest of spec passes through untouched. Pure.
|
|
120
|
+
*/
|
|
121
|
+
export function toApplyPayload(spec: Record<string, unknown>): Record<string, unknown> {
|
|
122
|
+
const { secrets, ...rest } = spec;
|
|
123
|
+
if (!Array.isArray(secrets)) return spec;
|
|
124
|
+
const map: Record<string, string> = {};
|
|
125
|
+
for (const entry of secrets) {
|
|
126
|
+
if (entry && typeof entry === "object" && typeof (entry as { key?: unknown }).key === "string") {
|
|
127
|
+
map[(entry as { key: string }).key] = String((entry as { value?: unknown }).value ?? "");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { ...rest, secrets: map };
|
|
106
131
|
}
|
|
107
132
|
|
|
108
133
|
/** Is a live resource chant-owned (by its metadata marker)? Pure. */
|
|
@@ -142,6 +167,20 @@ interface LiveResource {
|
|
|
142
167
|
metadata?: Record<string, unknown>;
|
|
143
168
|
}
|
|
144
169
|
|
|
170
|
+
interface ApplyResultSecret {
|
|
171
|
+
key: string;
|
|
172
|
+
action: string;
|
|
173
|
+
errors?: Record<string, unknown> | null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface ApplyResult {
|
|
177
|
+
kind: string;
|
|
178
|
+
name: string;
|
|
179
|
+
action: string;
|
|
180
|
+
errors?: Record<string, unknown> | null;
|
|
181
|
+
secrets?: ApplyResultSecret[];
|
|
182
|
+
}
|
|
183
|
+
|
|
145
184
|
async function listByName(http: FountainHttp, kind: string): Promise<Map<string, LiveResource>> {
|
|
146
185
|
const { status, json } = await http("GET", `/api/${KIND_PATHS[kind]}`);
|
|
147
186
|
if (status !== 200) throw new Error(`fountainApply: list ${kind} failed (${status})`);
|
|
@@ -153,83 +192,46 @@ export async function fountainApply(
|
|
|
153
192
|
args: FountainApplyArgs,
|
|
154
193
|
http?: FountainHttp,
|
|
155
194
|
): Promise<FountainApplySummary> {
|
|
156
|
-
const content = args.
|
|
157
|
-
const
|
|
195
|
+
const content = args.manifestContent ?? readFileSync(args.manifestPath!, "utf-8");
|
|
196
|
+
const resources = parseManifest(content);
|
|
158
197
|
|
|
159
198
|
const endpoint = resolveEndpoint(args);
|
|
160
199
|
const client = http ?? defaultFountainHttp(endpoint, resolveToken(args));
|
|
161
200
|
|
|
162
201
|
const summary: FountainApplySummary = { created: [], updated: [], pruned: [], secretsUpserted: 0 };
|
|
163
|
-
const idByKindAndName = new Map<string, string>();
|
|
164
|
-
const live = new Map<string, Map<string, LiveResource>>();
|
|
165
|
-
for (const kind of APPLY_ORDER) {
|
|
166
|
-
live.set(kind, await listByName(client, kind));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
for (const kind of APPLY_ORDER) {
|
|
170
|
-
for (const [entityName, entry] of Object.entries(plan)) {
|
|
171
|
-
if (entry.kind !== kind) continue;
|
|
172
|
-
|
|
173
|
-
const { body, secrets } = splitSecrets(entry.spec);
|
|
174
|
-
const payload: Record<string, unknown> = { ...body };
|
|
175
|
-
// Manifest identity: the fountain resource is named after the spec's
|
|
176
|
-
// own `name` when present, else the entity name.
|
|
177
|
-
const resourceName = typeof payload.name === "string" ? (payload.name as string) : entityName;
|
|
178
|
-
payload.name = resourceName;
|
|
179
|
-
|
|
180
|
-
// Agent environment ref: the plan carries the referenced *entity*
|
|
181
|
-
// name; resolve through the id map (created this run) or live state.
|
|
182
|
-
if (kind === "Agent" && typeof payload.environment === "string") {
|
|
183
|
-
const refEntity = payload.environment as string;
|
|
184
|
-
const refPlanEntry = plan[refEntity];
|
|
185
|
-
const refResourceName =
|
|
186
|
-
refPlanEntry && typeof refPlanEntry.spec.name === "string"
|
|
187
|
-
? (refPlanEntry.spec.name as string)
|
|
188
|
-
: refEntity;
|
|
189
|
-
const envId =
|
|
190
|
-
idByKindAndName.get(`Environment/${refEntity}`) ??
|
|
191
|
-
live.get("Environment")?.get(refResourceName)?.id;
|
|
192
|
-
if (!envId) throw new Error(`fountainApply: agent "${entityName}" references unknown environment "${refEntity}"`);
|
|
193
|
-
delete payload.environment;
|
|
194
|
-
payload.environment_id = envId;
|
|
195
|
-
}
|
|
196
202
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const { status } = await client("POST", `/api/${KIND_PATHS[kind]}/${id}/secrets`, secret);
|
|
218
|
-
if (status !== 200 && status !== 201) {
|
|
219
|
-
throw new Error(`fountainApply: secret upsert on ${kind} "${resourceName}" failed (${status})`);
|
|
220
|
-
}
|
|
221
|
-
summary.secretsUpserted += 1;
|
|
222
|
-
}
|
|
203
|
+
if (resources.length > 0) {
|
|
204
|
+
const body = {
|
|
205
|
+
resources: resources.map((r) => ({ kind: r.kind, name: r.name, spec: toApplyPayload(r.spec) })),
|
|
206
|
+
};
|
|
207
|
+
const { status, json } = await client("POST", "/api/apply", body);
|
|
208
|
+
if (status !== 200) {
|
|
209
|
+
throw new Error(`fountainApply: POST /api/apply failed (${status})`);
|
|
210
|
+
}
|
|
211
|
+
const results = (json as { data?: { results?: ApplyResult[] } })?.data?.results ?? [];
|
|
212
|
+
|
|
213
|
+
const failures: string[] = [];
|
|
214
|
+
for (const r of results) {
|
|
215
|
+
const label = `${r.kind}/${r.name}`;
|
|
216
|
+
if (r.action === "created") summary.created.push(label);
|
|
217
|
+
else if (r.action === "updated") summary.updated.push(label);
|
|
218
|
+
else failures.push(`${label}: ${JSON.stringify(r.errors)}`);
|
|
219
|
+
|
|
220
|
+
for (const s of r.secrets ?? []) {
|
|
221
|
+
if (s.action === "upserted") summary.secretsUpserted += 1;
|
|
222
|
+
else failures.push(`${label} secret "${s.key}": ${JSON.stringify(s.errors)}`);
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
|
+
if (failures.length > 0) {
|
|
226
|
+
throw new Error(`fountainApply: ${failures.length} failure(s):\n ${failures.join("\n ")}`);
|
|
227
|
+
}
|
|
225
228
|
}
|
|
226
229
|
|
|
227
230
|
if (args.prune) {
|
|
228
|
-
const planned = new Set
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
planned.add(`${entry.kind}/${resourceName}`);
|
|
231
|
+
const planned = new Set(resources.map((r) => `${r.kind}/${r.name}`));
|
|
232
|
+
const live = new Map<string, Map<string, LiveResource>>();
|
|
233
|
+
for (const kind of APPLY_ORDER) {
|
|
234
|
+
live.set(kind, await listByName(client, kind));
|
|
233
235
|
}
|
|
234
236
|
// Reverse order: agents first, then vaults, then environments.
|
|
235
237
|
for (const kind of [...APPLY_ORDER].reverse()) {
|
|
@@ -8,8 +8,8 @@ export {
|
|
|
8
8
|
fountainApply,
|
|
9
9
|
resolveEndpoint,
|
|
10
10
|
resolveToken,
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
parseManifest,
|
|
12
|
+
toApplyPayload,
|
|
13
13
|
isChantOwned,
|
|
14
14
|
defaultFountainHttp,
|
|
15
15
|
DEFAULT_FOUNTAIN_BASE_URL,
|
|
@@ -19,8 +19,7 @@ export {
|
|
|
19
19
|
export type {
|
|
20
20
|
FountainApplyArgs,
|
|
21
21
|
FountainApplySummary,
|
|
22
|
-
|
|
23
|
-
PlanEntry,
|
|
22
|
+
ManifestResource,
|
|
24
23
|
FountainHttp,
|
|
25
24
|
} from "./fountain-apply";
|
|
26
25
|
|
package/src/plugin.ts
CHANGED
|
@@ -81,7 +81,7 @@ export const fountainPlugin: LexiconPlugin = {
|
|
|
81
81
|
"Declare, lint, and reconcile fountain Environments, Vaults, and Agents from a chant project",
|
|
82
82
|
triggers: [
|
|
83
83
|
{ type: "context" as const, value: "fountain" },
|
|
84
|
-
{ type: "context" as const, value: "
|
|
84
|
+
{ type: "context" as const, value: "fountain.inevitable.fyi" },
|
|
85
85
|
],
|
|
86
86
|
},
|
|
87
87
|
{
|
|
@@ -109,7 +109,7 @@ export const fountainPlugin: LexiconPlugin = {
|
|
|
109
109
|
return [
|
|
110
110
|
createDiffTool(
|
|
111
111
|
fountainSerializer,
|
|
112
|
-
"Compare current fountain build output (fountain
|
|
112
|
+
"Compare current fountain build output (fountain manifest YAML) against previous version",
|
|
113
113
|
"fountain",
|
|
114
114
|
),
|
|
115
115
|
];
|
package/src/serializer.test.ts
CHANGED
|
@@ -2,10 +2,6 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { fountainSerializer } from "./serializer";
|
|
3
3
|
import type { Declarable } from "@intentius/chant";
|
|
4
4
|
|
|
5
|
-
function primary(result: unknown): string {
|
|
6
|
-
return typeof result === "string" ? result : (result as { primary: string }).primary;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
5
|
function entity(entityType: string, props: Record<string, unknown>): Declarable {
|
|
10
6
|
return { entityType, lexicon: "fountain", ...props } as unknown as Declarable;
|
|
11
7
|
}
|
|
@@ -20,13 +16,11 @@ describe("fountain serializer", () => {
|
|
|
20
16
|
expect(fountainSerializer.serialize(new Map())).toBe("");
|
|
21
17
|
});
|
|
22
18
|
|
|
23
|
-
it("emits the
|
|
19
|
+
it("emits only the manifest YAML — no sidecar file", () => {
|
|
24
20
|
const env = entity("Fountain::V1::Environment", { name: "e" });
|
|
25
21
|
const result = fountainSerializer.serialize(new Map([["e", env]]));
|
|
26
|
-
expect(typeof result).toBe("
|
|
27
|
-
|
|
28
|
-
const plan = JSON.parse(files["fountain-plan.json"]);
|
|
29
|
-
expect(plan.e.kind).toBe("Environment");
|
|
22
|
+
expect(typeof result).toBe("string");
|
|
23
|
+
expect(result).toContain("kind: Environment");
|
|
30
24
|
});
|
|
31
25
|
|
|
32
26
|
it("emits a fountain manifest per entity", () => {
|
|
@@ -36,7 +30,7 @@ describe("fountain serializer", () => {
|
|
|
36
30
|
networking_config: { allowed_hosts: ["github.com"] },
|
|
37
31
|
});
|
|
38
32
|
|
|
39
|
-
const out =
|
|
33
|
+
const out = fountainSerializer.serialize(new Map([["conciergeEnv", env]]));
|
|
40
34
|
|
|
41
35
|
expect(out).toContain("apiVersion: fountain.dev/v1");
|
|
42
36
|
expect(out).toContain("kind: Environment");
|
|
@@ -50,13 +44,11 @@ describe("fountain serializer", () => {
|
|
|
50
44
|
const env = entity("Fountain::V1::Environment", { name: "e" });
|
|
51
45
|
const vault = entity("Fountain::V1::Vault", { name: "v" });
|
|
52
46
|
|
|
53
|
-
const out =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
]),
|
|
59
|
-
),
|
|
47
|
+
const out = fountainSerializer.serialize(
|
|
48
|
+
new Map([
|
|
49
|
+
["env", env],
|
|
50
|
+
["vault", vault],
|
|
51
|
+
]),
|
|
60
52
|
);
|
|
61
53
|
|
|
62
54
|
expect(out).toContain("---\n");
|
|
@@ -73,13 +65,11 @@ describe("fountain serializer", () => {
|
|
|
73
65
|
environment: env,
|
|
74
66
|
});
|
|
75
67
|
|
|
76
|
-
const out =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
]),
|
|
82
|
-
),
|
|
68
|
+
const out = fountainSerializer.serialize(
|
|
69
|
+
new Map([
|
|
70
|
+
["conciergeEnv", env],
|
|
71
|
+
["researcher", agent],
|
|
72
|
+
]),
|
|
83
73
|
);
|
|
84
74
|
|
|
85
75
|
expect(out).toContain("environment: conciergeEnv");
|
|
@@ -91,7 +81,7 @@ describe("fountain serializer", () => {
|
|
|
91
81
|
description: "true",
|
|
92
82
|
});
|
|
93
83
|
|
|
94
|
-
const out =
|
|
84
|
+
const out = fountainSerializer.serialize(new Map([["v", vault]]));
|
|
95
85
|
expect(out).toContain('description: "true"');
|
|
96
86
|
});
|
|
97
87
|
|
|
@@ -103,7 +93,7 @@ describe("fountain serializer", () => {
|
|
|
103
93
|
mcp_servers: { github: { env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PAT}" } } },
|
|
104
94
|
});
|
|
105
95
|
|
|
106
|
-
const out =
|
|
96
|
+
const out = fountainSerializer.serialize(new Map([["a", agent]]));
|
|
107
97
|
expect(out).toContain('GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PAT}"');
|
|
108
98
|
});
|
|
109
99
|
});
|
package/src/serializer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Serializer,
|
|
1
|
+
import type { Serializer, Declarable } from "@intentius/chant";
|
|
2
2
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
3
3
|
import { propsOf } from "./entity-props";
|
|
4
4
|
|
|
@@ -8,8 +8,13 @@ import { propsOf } from "./entity-props";
|
|
|
8
8
|
* single multi-document stream. The output stays `fountain apply -f`
|
|
9
9
|
* compatible, so anyone can eject from chant and keep the artifacts.
|
|
10
10
|
*
|
|
11
|
+
* This is also `fountainApply`'s own input now: since fountain's bulk
|
|
12
|
+
* `POST /api/apply` (BinaryBourbon/fountain#151) resolves name references
|
|
13
|
+
* server-side, there's no separate sidecar to keep in sync — the manifest
|
|
14
|
+
* YAML is the only artifact.
|
|
15
|
+
*
|
|
11
16
|
* Cross-resource references (e.g. `agent.environment`) serialize to the
|
|
12
|
-
* referenced entity's name — fountain
|
|
17
|
+
* referenced entity's name — fountain resolves names to ids at apply.
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
const API_VERSION = "fountain.dev/v1";
|
|
@@ -24,7 +29,7 @@ export const fountainSerializer: Serializer = {
|
|
|
24
29
|
name: "fountain",
|
|
25
30
|
rulePrefix: "FTN",
|
|
26
31
|
|
|
27
|
-
serialize(entities: Map<string, Declarable>): string
|
|
32
|
+
serialize(entities: Map<string, Declarable>): string {
|
|
28
33
|
// Reverse map for reference resolution: Declarable instance → name.
|
|
29
34
|
const entityNames = new Map<Declarable, string>();
|
|
30
35
|
for (const [name, entity] of entities) {
|
|
@@ -50,7 +55,6 @@ export const fountainSerializer: Serializer = {
|
|
|
50
55
|
};
|
|
51
56
|
|
|
52
57
|
const docs: string[] = [];
|
|
53
|
-
const plan: Record<string, { kind: string; spec: Record<string, unknown> }> = {};
|
|
54
58
|
for (const [name, entity] of entities) {
|
|
55
59
|
const spec: Record<string, unknown> = {};
|
|
56
60
|
for (const [key, val] of Object.entries(propsOf(entity))) {
|
|
@@ -65,16 +69,9 @@ export const fountainSerializer: Serializer = {
|
|
|
65
69
|
spec,
|
|
66
70
|
};
|
|
67
71
|
docs.push(toYaml(manifest));
|
|
68
|
-
plan[name] = { kind: manifest.kind, spec };
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
if (docs.length === 0) return yaml;
|
|
73
|
-
|
|
74
|
-
// Primary output is the ejectable `fountain apply -f` YAML; the JSON
|
|
75
|
-
// sidecar is the fountainApply op's input (same data, no YAML parser
|
|
76
|
-
// needed on the apply side — the fly plan.json pattern).
|
|
77
|
-
return { primary: yaml, files: { "fountain-plan.json": JSON.stringify(plan, null, 2) } };
|
|
74
|
+
return docs.join("---\n");
|
|
78
75
|
},
|
|
79
76
|
};
|
|
80
77
|
|
|
@@ -11,7 +11,7 @@ user-invocable: true
|
|
|
11
11
|
Everything materialized into a fountain sandbox must be presumed exfiltrated once untrusted agent code runs. Order of preference:
|
|
12
12
|
|
|
13
13
|
1. **`${VAR}` substitution references** in agent config (MCP server env, system prompts). Resolved at spawn from the merged environment + vault sets. Never a value in source.
|
|
14
|
-
2. **Environment secrets** (`spec.secrets`) — encrypted at rest, write-only over the API (values are never returned once stored). `fountainApply` upserts them through the
|
|
14
|
+
2. **Environment secrets** (`spec.secrets`) — encrypted at rest, write-only over the API (values are never returned once stored). `fountainApply` sends them inline with the rest of the resource in the bulk apply request, and the server upserts them through the encrypted envelope path; a changed value cannot be detected, only overwritten.
|
|
15
15
|
3. **`env_vars`** — plaintext config only. FTN012 errors on credential-shaped keys or values here.
|
|
16
16
|
|
|
17
17
|
Never put a literal credential anywhere in a declaration: FTN001 catches known shapes (AWS keys, GitHub/Slack tokens, `sk-`/`ftn_` keys, private key material) at the AST; FTN015 errors on secret-shaped MCP env keys that are not `${VAR}` references.
|
|
@@ -10,7 +10,7 @@ user-invocable: true
|
|
|
10
10
|
|
|
11
11
|
[fountain](https://github.com/BinaryBourbon/fountain) runs coding agents in sandboxed VMs. Its workload layer is three kinds — `Environment` (sandbox baseline), `Vault` (env-var overrides), `Agent` (a runnable agent config) — and this lexicon declares them as typed chant resources. Conversations are runs, not resources: start them with the `fountainRun` op, never declare them.
|
|
12
12
|
|
|
13
|
-
The source of truth is the TypeScript in `src/`. `chant build` serializes it to fountain's own manifest YAML (ejectable — `fountain apply -f` accepts it verbatim)
|
|
13
|
+
The source of truth is the TypeScript in `src/`. `chant build` serializes it to fountain's own manifest YAML (ejectable — `fountain apply -f` accepts it verbatim). `fountainApply` sends that same YAML to fountain's bulk `POST /api/apply` endpoint in one request: create-if-new, update-by-name, opt-in owned-only prune keyed on the `managed-by: chant` metadata marker.
|
|
14
14
|
|
|
15
15
|
## Authoring
|
|
16
16
|
|
package/src/spec/fetch.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { readFileSync } from "fs";
|
|
|
23
23
|
import { fileURLToPath } from "url";
|
|
24
24
|
import { fetchWithCache } from "@intentius/chant/codegen/fetch";
|
|
25
25
|
|
|
26
|
-
const SCHEMA_URL = "https://
|
|
26
|
+
const SCHEMA_URL = "https://fountain.inevitable.fyi/api/openapi.json";
|
|
27
27
|
|
|
28
28
|
const CACHE_FILE = join(homedir(), ".chant", "fountain-openapi.json");
|
|
29
29
|
|
package/src/generated/.gitkeep
DELETED
|
File without changes
|