@intentius/chant 0.1.14 → 0.1.16
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/package.json +1 -1
- package/src/build.ts +18 -2
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/import-live.test.ts +126 -0
- package/src/cli/commands/import.ts +152 -2
- package/src/cli/commands/migrate.ts +2 -2
- package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +80 -37
- package/src/cli/handlers/{state.ts → lifecycle.ts} +166 -40
- package/src/cli/handlers/misc.ts +31 -2
- package/src/cli/handlers/run.test.ts +98 -0
- package/src/cli/handlers/run.ts +123 -0
- package/src/cli/main.test.ts +14 -0
- package/src/cli/main.ts +49 -15
- package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
- package/src/cli/mcp/op-tools.ts +2 -2
- package/src/cli/mcp/resource-handlers.ts +1 -1
- package/src/cli/mcp/server.test.ts +2 -2
- package/src/cli/mcp/server.ts +1 -1
- package/src/cli/registry.ts +23 -2
- package/src/codegen/fetch.test.ts +103 -2
- package/src/codegen/fetch.ts +62 -10
- package/src/config.ts +41 -0
- package/src/detectLexicon.test.ts +2 -2
- package/src/index.ts +2 -2
- package/src/lexicon-export.test.ts +92 -0
- package/src/lexicon.ts +88 -9
- package/src/lifecycle/change-set.test.ts +151 -0
- package/src/lifecycle/change-set.ts +172 -0
- package/src/{state → lifecycle}/git.test.ts +15 -15
- package/src/{state → lifecycle}/git.ts +14 -14
- package/src/{state → lifecycle}/index.ts +2 -0
- package/src/{state → lifecycle}/snapshot.test.ts +5 -5
- package/src/{state → lifecycle}/snapshot.ts +9 -9
- package/src/{state → lifecycle}/types.ts +1 -1
- package/src/op/activity-registry.test.ts +59 -0
- package/src/op/activity-registry.ts +98 -0
- package/src/op/builders.ts +56 -20
- package/src/op/index.ts +6 -1
- package/src/op/local-executor.test.ts +263 -0
- package/src/op/local-executor.ts +300 -0
- package/src/op/local-output.test.ts +54 -0
- package/src/op/local-output.ts +63 -0
- package/src/op/op.test.ts +41 -4
- package/src/op/types.ts +2 -2
- package/src/ownership.test.ts +109 -0
- package/src/ownership.ts +142 -0
- package/src/serializer.ts +19 -1
- package/src/toml-parse.ts +3 -3
- /package/src/{state → lifecycle}/digest.test.ts +0 -0
- /package/src/{state → lifecycle}/digest.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.ts +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change set: a typed, read-only projection of a live diff.
|
|
3
|
+
*
|
|
4
|
+
* `chant lifecycle diff --live` computes a three-way comparison — declared now /
|
|
5
|
+
* last snapshot / live now — and prints it. `buildChangeSet` promotes that
|
|
6
|
+
* same signal into a classified create/update/delete/adopt/noop set that other
|
|
7
|
+
* tooling (reconcile, apply) can act on.
|
|
8
|
+
*
|
|
9
|
+
* Strictly read-only and pure: no I/O, no mutation. The classification reads
|
|
10
|
+
* ownership from the live marker only (populated downstream); until ownership
|
|
11
|
+
* exists, an undeclared live resource is `adopt`, never `delete`. The snapshot
|
|
12
|
+
* is evidence, never the basis for a mutation decision — it must never become
|
|
13
|
+
* load-bearing.
|
|
14
|
+
*/
|
|
15
|
+
import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the projection proposes for a single resource.
|
|
19
|
+
*
|
|
20
|
+
* - `create` — declared in source, absent from live.
|
|
21
|
+
* - `update` — declared and live, but live config drifted.
|
|
22
|
+
* - `delete` — a chant-owned resource that is live but no longer declared.
|
|
23
|
+
* Only emitted once ownership is known (#121); never inferred from the
|
|
24
|
+
* snapshot.
|
|
25
|
+
* - `adopt` — live but undeclared, ownership not established → a candidate to
|
|
26
|
+
* pull back into source, never an auto-delete.
|
|
27
|
+
* - `noop` — declared and live with no drift, or already reconciled.
|
|
28
|
+
*/
|
|
29
|
+
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Who answers "is this resource chant's?". `unknown` until a live ownership
|
|
33
|
+
* marker is queried (#120). The change set never escalates `unknown` to a
|
|
34
|
+
* delete.
|
|
35
|
+
*/
|
|
36
|
+
export type Ownership = "owned" | "foreign" | "unknown";
|
|
37
|
+
|
|
38
|
+
export interface ChangeSetEntry {
|
|
39
|
+
/** chant entity name. */
|
|
40
|
+
name: string;
|
|
41
|
+
/** Resource type, when known from either side. */
|
|
42
|
+
type?: string;
|
|
43
|
+
action: ChangeAction;
|
|
44
|
+
/** The three-way evidence the classification was derived from. */
|
|
45
|
+
evidence: {
|
|
46
|
+
/** Present in the current build. */
|
|
47
|
+
declared: boolean;
|
|
48
|
+
/** Present in the last snapshot. */
|
|
49
|
+
inSnapshot: boolean;
|
|
50
|
+
/** Observed in the live system right now. */
|
|
51
|
+
live: boolean;
|
|
52
|
+
};
|
|
53
|
+
/** Attribute-level changes, for `update`. */
|
|
54
|
+
deltas?: AttributeChange[];
|
|
55
|
+
/** Live-marker ownership. Defaults to `unknown`. */
|
|
56
|
+
ownership: Ownership;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ChangeSet {
|
|
60
|
+
env: string;
|
|
61
|
+
entries: ChangeSetEntry[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a typed change set from the same inputs `diffLive` consumes.
|
|
66
|
+
*
|
|
67
|
+
* `create`/`update` are precise from declared-vs-live. `delete` is never
|
|
68
|
+
* emitted here — an undeclared live resource classifies as `adopt` until
|
|
69
|
+
* ownership is known.
|
|
70
|
+
*/
|
|
71
|
+
export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
72
|
+
const diff = diffLive(input);
|
|
73
|
+
const { declared, observedNow } = input;
|
|
74
|
+
const observedThen = input.observedThen ?? {};
|
|
75
|
+
|
|
76
|
+
const driftByName = new Map(
|
|
77
|
+
diff.driftedSinceSnapshot.map((d) => [d.name, d.changes] as const),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const names = new Set<string>([
|
|
81
|
+
...declared,
|
|
82
|
+
...Object.keys(observedNow),
|
|
83
|
+
...Object.keys(observedThen),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const entries: ChangeSetEntry[] = [];
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const isDeclared = declared.has(name);
|
|
89
|
+
const live = Object.prototype.hasOwnProperty.call(observedNow, name);
|
|
90
|
+
const inSnapshot = Object.prototype.hasOwnProperty.call(observedThen, name);
|
|
91
|
+
const type = observedNow[name]?.type ?? observedThen[name]?.type;
|
|
92
|
+
const evidence = { declared: isDeclared, inSnapshot, live };
|
|
93
|
+
|
|
94
|
+
// Ownership comes from the LIVE marker only (carried on observedNow), never
|
|
95
|
+
// from the snapshot. This is the invariant that keeps the snapshot from
|
|
96
|
+
// becoming load-bearing: a mutation decision (delete) is never made from a
|
|
97
|
+
// record chant has to host.
|
|
98
|
+
const ownership: Ownership = observedNow[name]?.ownership ?? "unknown";
|
|
99
|
+
|
|
100
|
+
let action: ChangeAction;
|
|
101
|
+
let deltas: AttributeChange[] | undefined;
|
|
102
|
+
|
|
103
|
+
if (isDeclared && !live) {
|
|
104
|
+
// Declared in source, not in the cloud → create.
|
|
105
|
+
action = "create";
|
|
106
|
+
} else if (isDeclared && live) {
|
|
107
|
+
const drift = driftByName.get(name);
|
|
108
|
+
if (drift && drift.length > 0) {
|
|
109
|
+
action = "update";
|
|
110
|
+
deltas = drift;
|
|
111
|
+
} else {
|
|
112
|
+
action = "noop";
|
|
113
|
+
}
|
|
114
|
+
} else if (live) {
|
|
115
|
+
// Live but undeclared. Only a chant-owned orphan is a safe delete; a
|
|
116
|
+
// foreign or unknown orphan can be adopted but never auto-deleted.
|
|
117
|
+
action = ownership === "owned" ? "delete" : "adopt";
|
|
118
|
+
} else {
|
|
119
|
+
// Only in the snapshot: already gone, nothing to reconcile.
|
|
120
|
+
action = "noop";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
entries.push({ name, type, action, evidence, deltas, ownership });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
+
return { env, entries };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop"];
|
|
131
|
+
|
|
132
|
+
/** Count entries per action. */
|
|
133
|
+
export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
134
|
+
const counts: Record<ChangeAction, number> = {
|
|
135
|
+
create: 0,
|
|
136
|
+
update: 0,
|
|
137
|
+
delete: 0,
|
|
138
|
+
adopt: 0,
|
|
139
|
+
noop: 0,
|
|
140
|
+
};
|
|
141
|
+
for (const e of cs.entries) counts[e.action]++;
|
|
142
|
+
return counts;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Human-readable render of a change set. Pure — returns a string. */
|
|
146
|
+
export function renderChangeSet(cs: ChangeSet): string {
|
|
147
|
+
const counts = summarize(cs);
|
|
148
|
+
const header = ACTION_ORDER.map((a) => `${counts[a]} ${a}`).join(", ");
|
|
149
|
+
const lines: string[] = [`Plan for ${cs.env}: ${header}`];
|
|
150
|
+
|
|
151
|
+
for (const action of ACTION_ORDER) {
|
|
152
|
+
const group = cs.entries.filter((e) => e.action === action);
|
|
153
|
+
if (group.length === 0) continue;
|
|
154
|
+
lines.push(`\n${action.toUpperCase()}:`);
|
|
155
|
+
for (const e of group) {
|
|
156
|
+
const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
|
|
157
|
+
lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}`);
|
|
158
|
+
for (const d of e.deltas ?? []) {
|
|
159
|
+
lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function fmt(v: unknown): string {
|
|
168
|
+
if (v === undefined) return "<unset>";
|
|
169
|
+
if (typeof v === "string") return v.length > 60 ? v.slice(0, 57) + "..." : v;
|
|
170
|
+
const json = JSON.stringify(v);
|
|
171
|
+
return json.length > 60 ? json.slice(0, 57) + "..." : json;
|
|
172
|
+
}
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
readEnvironmentSnapshots,
|
|
10
10
|
listSnapshots,
|
|
11
11
|
getHeadCommit,
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
pushLifecycle,
|
|
13
|
+
StaleLifecycleBranchError,
|
|
14
14
|
} from "./git";
|
|
15
15
|
|
|
16
16
|
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
@@ -28,7 +28,7 @@ async function initRepo(dir: string): Promise<void> {
|
|
|
28
28
|
git(["commit", "-q", "-m", "init"], dir);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
describe("
|
|
31
|
+
describe("lifecycle/git", () => {
|
|
32
32
|
test("writeSnapshot creates the orphan branch and writes JSON addressable by readSnapshot", async () => {
|
|
33
33
|
await withTestDir(async (dir) => {
|
|
34
34
|
await initRepo(dir);
|
|
@@ -135,7 +135,7 @@ describe("state/git", () => {
|
|
|
135
135
|
const { clonePath, cleanup } = await setupClonePair();
|
|
136
136
|
try {
|
|
137
137
|
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: clonePath });
|
|
138
|
-
const ok = await
|
|
138
|
+
const ok = await pushLifecycle({ cwd: clonePath });
|
|
139
139
|
expect(ok).toBe(true);
|
|
140
140
|
} finally {
|
|
141
141
|
await cleanup();
|
|
@@ -146,18 +146,18 @@ describe("state/git", () => {
|
|
|
146
146
|
const { clonePath, cleanup } = await setupClonePair();
|
|
147
147
|
try {
|
|
148
148
|
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: clonePath });
|
|
149
|
-
expect(await
|
|
149
|
+
expect(await pushLifecycle({ cwd: clonePath })).toBe(true);
|
|
150
150
|
|
|
151
151
|
// Pull the remote ref into local remote-tracking, then commit + push again
|
|
152
|
-
git(["fetch", "-q", "origin", "+refs/heads/chant/
|
|
152
|
+
git(["fetch", "-q", "origin", "+refs/heads/chant/lifecycle:refs/remotes/origin/chant/lifecycle"], clonePath);
|
|
153
153
|
await writeSnapshot("prod", "aws", JSON.stringify({ a: 2 }), { cwd: clonePath });
|
|
154
|
-
expect(await
|
|
154
|
+
expect(await pushLifecycle({ cwd: clonePath })).toBe(true);
|
|
155
155
|
} finally {
|
|
156
156
|
await cleanup();
|
|
157
157
|
}
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
-
test("concurrent write rejected: second push throws
|
|
160
|
+
test("concurrent write rejected: second push throws StaleLifecycleBranchError", async () => {
|
|
161
161
|
// Simulate two concurrent operators by setting up two clones of the same remote.
|
|
162
162
|
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
163
163
|
const cloneB = join(import.meta.dirname ?? "/tmp", `chant-state-clone-b-${Date.now()}-${Math.random()}`);
|
|
@@ -168,13 +168,13 @@ describe("state/git", () => {
|
|
|
168
168
|
|
|
169
169
|
// Operator A writes + pushes first.
|
|
170
170
|
await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
|
|
171
|
-
expect(await
|
|
171
|
+
expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
|
|
172
172
|
|
|
173
|
-
// Operator B writes from the same baseline (chant/
|
|
173
|
+
// Operator B writes from the same baseline (chant/lifecycle doesn't exist
|
|
174
174
|
// on cloneB's remote-tracking yet) and tries to push — should fail
|
|
175
|
-
// with
|
|
175
|
+
// with StaleLifecycleBranchError because A's push moved the remote ref.
|
|
176
176
|
await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
|
|
177
|
-
await expect(
|
|
177
|
+
await expect(pushLifecycle({ cwd: cloneB })).rejects.toBeInstanceOf(StaleLifecycleBranchError);
|
|
178
178
|
} finally {
|
|
179
179
|
await cleanup();
|
|
180
180
|
const { rm } = await import("node:fs/promises");
|
|
@@ -182,9 +182,9 @@ describe("state/git", () => {
|
|
|
182
182
|
}
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
-
test("
|
|
186
|
-
const err = new
|
|
187
|
-
expect(err.name).toBe("
|
|
185
|
+
test("StaleLifecycleBranchError carries the expected SHA used as the lease", async () => {
|
|
186
|
+
const err = new StaleLifecycleBranchError(null, "stale info: ...");
|
|
187
|
+
expect(err.name).toBe("StaleLifecycleBranchError");
|
|
188
188
|
expect(err.expected).toBeNull();
|
|
189
189
|
expect(err.message).toContain("moved");
|
|
190
190
|
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Git plumbing operations for the chant/
|
|
2
|
+
* Git plumbing operations for the chant/lifecycle orphan branch.
|
|
3
3
|
*
|
|
4
4
|
* All operations use git plumbing commands — no checkout, no branch switching,
|
|
5
5
|
* no working tree changes.
|
|
6
6
|
*/
|
|
7
7
|
import { getRuntime } from "../runtime-adapter";
|
|
8
8
|
|
|
9
|
-
const STATE_BRANCH = "chant/
|
|
9
|
+
const STATE_BRANCH = "chant/lifecycle";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Write a state snapshot JSON to the orphan branch.
|
|
@@ -174,28 +174,28 @@ export async function listSnapshots(
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
* Thrown by
|
|
177
|
+
* Thrown by pushLifecycle when the remote chant/lifecycle branch has moved since
|
|
178
178
|
* the local snapshot was prepared — i.e. another snapshot for this or a
|
|
179
179
|
* different env was pushed concurrently. The caller should fetch and retry.
|
|
180
180
|
*/
|
|
181
|
-
export class
|
|
181
|
+
export class StaleLifecycleBranchError extends Error {
|
|
182
182
|
readonly expected: string | null;
|
|
183
183
|
constructor(expected: string | null, stderr: string) {
|
|
184
184
|
super(
|
|
185
|
-
"chant/
|
|
185
|
+
"chant/lifecycle remote branch has moved since this run started — " +
|
|
186
186
|
"another snapshot was pushed concurrently. " +
|
|
187
187
|
`git stderr: ${stderr.trim()}`,
|
|
188
188
|
);
|
|
189
|
-
this.name = "
|
|
189
|
+
this.name = "StaleLifecycleBranchError";
|
|
190
190
|
this.expected = expected;
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
|
-
* Look up the remote-tracking SHA for chant/
|
|
195
|
+
* Look up the remote-tracking SHA for chant/lifecycle, if any. Returns null when
|
|
196
196
|
* the remote ref doesn't exist locally yet (e.g. first-ever snapshot).
|
|
197
197
|
*/
|
|
198
|
-
export async function
|
|
198
|
+
export async function getRemoteLifecycleBranchSha(
|
|
199
199
|
remote: string,
|
|
200
200
|
opts?: { cwd?: string },
|
|
201
201
|
): Promise<string | null> {
|
|
@@ -209,13 +209,13 @@ export async function getRemoteStateBranchSha(
|
|
|
209
209
|
/**
|
|
210
210
|
* Push the state branch to remote with --force-with-lease.
|
|
211
211
|
*
|
|
212
|
-
* If the remote chant/
|
|
212
|
+
* If the remote chant/lifecycle ref has advanced past the local remote-tracking
|
|
213
213
|
* SHA captured at the start of this push, the push is rejected and we throw
|
|
214
|
-
*
|
|
214
|
+
* StaleLifecycleBranchError so the caller can surface a recovery hint.
|
|
215
215
|
*
|
|
216
216
|
* Returns false (without throwing) only when no remote is configured at all.
|
|
217
217
|
*/
|
|
218
|
-
export async function
|
|
218
|
+
export async function pushLifecycle(opts?: { cwd?: string }): Promise<boolean> {
|
|
219
219
|
const rt = getRuntime();
|
|
220
220
|
const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
|
|
221
221
|
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
|
|
@@ -225,7 +225,7 @@ export async function pushState(opts?: { cwd?: string }): Promise<boolean> {
|
|
|
225
225
|
// Capture the lease SHA — if null, the remote ref doesn't exist yet
|
|
226
226
|
// (first-time push) and we send `--force-with-lease=ref:` (empty SHA),
|
|
227
227
|
// which git interprets as "ref does not exist on remote".
|
|
228
|
-
const expected = await
|
|
228
|
+
const expected = await getRemoteLifecycleBranchSha(remote, opts);
|
|
229
229
|
const lease = `refs/heads/${STATE_BRANCH}:${expected ?? ""}`;
|
|
230
230
|
|
|
231
231
|
const pushResult = await rt.spawn(
|
|
@@ -240,7 +240,7 @@ export async function pushState(opts?: { cwd?: string }): Promise<boolean> {
|
|
|
240
240
|
stderr.includes("rejected") ||
|
|
241
241
|
stderr.includes("non-fast-forward")
|
|
242
242
|
) {
|
|
243
|
-
throw new
|
|
243
|
+
throw new StaleLifecycleBranchError(expected, stderr);
|
|
244
244
|
}
|
|
245
245
|
return false;
|
|
246
246
|
}
|
|
@@ -250,7 +250,7 @@ export async function pushState(opts?: { cwd?: string }): Promise<boolean> {
|
|
|
250
250
|
/**
|
|
251
251
|
* Fetch the state branch from remote.
|
|
252
252
|
*/
|
|
253
|
-
export async function
|
|
253
|
+
export async function fetchLifecycle(opts?: { cwd?: string }): Promise<boolean> {
|
|
254
254
|
const rt = getRuntime();
|
|
255
255
|
const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
|
|
256
256
|
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
|
|
@@ -4,12 +4,12 @@ import type { BuildResult } from "../build";
|
|
|
4
4
|
|
|
5
5
|
const writeSnapshotMock = vi.fn();
|
|
6
6
|
const getHeadCommitMock = vi.fn();
|
|
7
|
-
const
|
|
7
|
+
const pushLifecycleMock = vi.fn();
|
|
8
8
|
|
|
9
9
|
vi.mock("./git", () => ({
|
|
10
10
|
writeSnapshot: (...args: unknown[]) => writeSnapshotMock(...args),
|
|
11
11
|
getHeadCommit: () => getHeadCommitMock(),
|
|
12
|
-
|
|
12
|
+
pushLifecycle: () => pushLifecycleMock(),
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
15
|
const { takeSnapshot } = await import("./snapshot");
|
|
@@ -34,10 +34,10 @@ describe("takeSnapshot", () => {
|
|
|
34
34
|
beforeEach(() => {
|
|
35
35
|
writeSnapshotMock.mockReset();
|
|
36
36
|
getHeadCommitMock.mockReset();
|
|
37
|
-
|
|
37
|
+
pushLifecycleMock.mockReset();
|
|
38
38
|
writeSnapshotMock.mockResolvedValue("commit-sha");
|
|
39
39
|
getHeadCommitMock.mockResolvedValue("head-sha");
|
|
40
|
-
|
|
40
|
+
pushLifecycleMock.mockResolvedValue(true);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
test("happy path: writes snapshot per plugin with describeResources", async () => {
|
|
@@ -56,7 +56,7 @@ describe("takeSnapshot", () => {
|
|
|
56
56
|
resources: { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE" } },
|
|
57
57
|
});
|
|
58
58
|
expect(writeSnapshotMock).toHaveBeenCalledTimes(1);
|
|
59
|
-
expect(
|
|
59
|
+
expect(pushLifecycleMock).toHaveBeenCalledTimes(1);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
test("plugin without describeResources is skipped", async () => {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Snapshot orchestration: queries plugins for deployed resource metadata,
|
|
3
|
-
* assembles
|
|
3
|
+
* assembles LifecycleSnapshots, computes build digests, and writes to git.
|
|
4
4
|
*/
|
|
5
|
-
import type {
|
|
5
|
+
import type { ObservationLexicon, ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
6
6
|
import type { BuildResult } from "../build";
|
|
7
7
|
import type { SerializerResult } from "../serializer";
|
|
8
|
-
import type {
|
|
8
|
+
import type { LifecycleSnapshot } from "./types";
|
|
9
9
|
import { computeBuildDigest } from "./digest";
|
|
10
|
-
import { writeSnapshot, getHeadCommit,
|
|
10
|
+
import { writeSnapshot, getHeadCommit, pushLifecycle } from "./git";
|
|
11
11
|
import { sortedJsonReplacer } from "../utils";
|
|
12
12
|
|
|
13
13
|
/** Patterns in attribute names that suggest sensitive data. */
|
|
@@ -71,7 +71,7 @@ function validateResources(
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export interface TakeSnapshotResult {
|
|
74
|
-
snapshots:
|
|
74
|
+
snapshots: LifecycleSnapshot[];
|
|
75
75
|
commit: string;
|
|
76
76
|
warnings: string[];
|
|
77
77
|
errors: string[];
|
|
@@ -82,13 +82,13 @@ export interface TakeSnapshotResult {
|
|
|
82
82
|
*/
|
|
83
83
|
export async function takeSnapshot(
|
|
84
84
|
environment: string,
|
|
85
|
-
plugins:
|
|
85
|
+
plugins: ObservationLexicon[],
|
|
86
86
|
buildResult: BuildResult,
|
|
87
87
|
opts?: { cwd?: string },
|
|
88
88
|
): Promise<TakeSnapshotResult> {
|
|
89
89
|
const warnings: string[] = [];
|
|
90
90
|
const errors: string[] = [];
|
|
91
|
-
const snapshots:
|
|
91
|
+
const snapshots: LifecycleSnapshot[] = [];
|
|
92
92
|
|
|
93
93
|
const headCommit = await getHeadCommit(opts);
|
|
94
94
|
const timestamp = new Date().toISOString();
|
|
@@ -155,7 +155,7 @@ export async function takeSnapshot(
|
|
|
155
155
|
continue;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
const snapshot:
|
|
158
|
+
const snapshot: LifecycleSnapshot = {
|
|
159
159
|
lexicon: plugin.name,
|
|
160
160
|
environment,
|
|
161
161
|
commit: headCommit,
|
|
@@ -187,7 +187,7 @@ export async function takeSnapshot(
|
|
|
187
187
|
|
|
188
188
|
// Push to remote
|
|
189
189
|
if (snapshots.length > 0) {
|
|
190
|
-
await
|
|
190
|
+
await pushLifecycle(opts);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
return {
|
|
@@ -5,7 +5,7 @@ export type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
|
5
5
|
/**
|
|
6
6
|
* State snapshot for a single lexicon in an environment.
|
|
7
7
|
*/
|
|
8
|
-
export interface
|
|
8
|
+
export interface LifecycleSnapshot {
|
|
9
9
|
lexicon: string;
|
|
10
10
|
environment: string;
|
|
11
11
|
/** Main branch commit this corresponds to */
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { loadActivities, resolveActivity, type ActivityFn } from "./activity-registry";
|
|
3
|
+
|
|
4
|
+
describe("loadActivities", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.resetModules();
|
|
7
|
+
vi.doUnmock("@intentius/chant-lexicon-temporal/op/activities");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("loads the lexicon activity library keyed by export name", async () => {
|
|
11
|
+
const activities = await loadActivities();
|
|
12
|
+
// Real export names from lexicons/temporal/src/op/activities.
|
|
13
|
+
expect(activities.has("shellCmd")).toBe(true);
|
|
14
|
+
expect(activities.has("chantBuild")).toBe(true);
|
|
15
|
+
expect(activities.has("kubectlApply")).toBe(true);
|
|
16
|
+
expect(activities.has("lifecycleDiff")).toBe(true);
|
|
17
|
+
expect(typeof activities.get("shellCmd")).toBe("function");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("throws a friendly error when the lexicon is not installed", async () => {
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
vi.doMock("@intentius/chant-lexicon-temporal/op/activities", () => {
|
|
23
|
+
throw new Error("Cannot find module");
|
|
24
|
+
});
|
|
25
|
+
const { loadActivities: fresh } = await import("./activity-registry");
|
|
26
|
+
await expect(fresh()).rejects.toThrow(
|
|
27
|
+
"no activities registered — install `@intentius/chant-lexicon-temporal`",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("resolveActivity", () => {
|
|
33
|
+
test("resolves a known activity by name", () => {
|
|
34
|
+
const fn: ActivityFn = async () => "ok";
|
|
35
|
+
const map = new Map<string, ActivityFn>([["shellCmd", fn]]);
|
|
36
|
+
expect(resolveActivity(map, "shellCmd")).toBe(fn);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("throws listing known names for an unknown fn", () => {
|
|
40
|
+
const map = new Map<string, ActivityFn>([
|
|
41
|
+
["shellCmd", async () => undefined],
|
|
42
|
+
["chantBuild", async () => undefined],
|
|
43
|
+
]);
|
|
44
|
+
expect(() => resolveActivity(map, "nope")).toThrow(
|
|
45
|
+
'no activity named "nope" (known: chantBuild, shellCmd)',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("loadActivities — heartbeat-shim safety", () => {
|
|
51
|
+
test("the activity library loads without @temporalio/activity installed", async () => {
|
|
52
|
+
// kubectlApply/helmInstall/waitForStack/gitlabPipeline heartbeat via the
|
|
53
|
+
// lazy shim; if the shim statically required the SDK, this import would
|
|
54
|
+
// throw (the SDK is not installed in this environment).
|
|
55
|
+
const activities = await loadActivities();
|
|
56
|
+
expect(activities.has("kubectlApply")).toBe(true);
|
|
57
|
+
expect(activities.has("waitForStack")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity registry — resolves an Op step's `fn` name to a callable activity
|
|
3
|
+
* implementation for local execution.
|
|
4
|
+
*
|
|
5
|
+
* Activities live in the Temporal lexicon (`@intentius/chant-lexicon-temporal`)
|
|
6
|
+
* but are plain async functions taking a single args object — Temporal-free to
|
|
7
|
+
* call. Local mode loads them by name instead of registering them with a worker.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An activity is an async function taking a single args object and an optional
|
|
12
|
+
* `AbortSignal`. Local execution passes a signal that fires on timeout or
|
|
13
|
+
* Ctrl-C so the activity can kill in-flight child processes; the Temporal
|
|
14
|
+
* worker invokes activities with args only (cancellation comes from its own
|
|
15
|
+
* `Context`), so the signal is always optional.
|
|
16
|
+
*/
|
|
17
|
+
export type ActivityFn = (args: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Dynamically import the Temporal lexicon's activity library and return a map
|
|
21
|
+
* of every exported function keyed by its export name (`shellCmd`, `chantBuild`,
|
|
22
|
+
* `kubectlApply`, …).
|
|
23
|
+
*
|
|
24
|
+
* Throws a friendly error if the lexicon is not installed — local execution
|
|
25
|
+
* needs the activity implementations even though it never starts a worker.
|
|
26
|
+
*/
|
|
27
|
+
export async function loadActivities(): Promise<Map<string, ActivityFn>> {
|
|
28
|
+
let mod: Record<string, unknown>;
|
|
29
|
+
try {
|
|
30
|
+
// Variable specifier so tsc does not statically resolve the optional dep.
|
|
31
|
+
const spec = "@intentius/chant-lexicon-temporal/op/activities";
|
|
32
|
+
mod = (await import(spec)) as Record<string, unknown>;
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(
|
|
35
|
+
"no activities registered — install `@intentius/chant-lexicon-temporal`",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const activities = new Map<string, ActivityFn>();
|
|
40
|
+
for (const [name, value] of Object.entries(mod)) {
|
|
41
|
+
if (typeof value === "function") {
|
|
42
|
+
activities.set(name, value as ActivityFn);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return activities;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Structural shape of a TEMPORAL_ACTIVITY_PROFILES entry (timeout + retry). */
|
|
49
|
+
export interface ActivityProfile {
|
|
50
|
+
startToCloseTimeout?: string;
|
|
51
|
+
heartbeatTimeout?: string;
|
|
52
|
+
retry?: {
|
|
53
|
+
initialInterval?: string;
|
|
54
|
+
backoffCoefficient?: number;
|
|
55
|
+
maximumAttempts?: number;
|
|
56
|
+
maximumInterval?: string;
|
|
57
|
+
/** Error names (`Error.name`) that should fail immediately without retry. */
|
|
58
|
+
nonRetryableErrorTypes?: string[];
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Dynamically import the lexicon's `TEMPORAL_ACTIVITY_PROFILES` (pure data, no
|
|
64
|
+
* Temporal SDK). Returns an empty record if the lexicon is absent — the
|
|
65
|
+
* executor then falls back to built-in defaults per step.
|
|
66
|
+
*
|
|
67
|
+
* Imports the lexicon's `/config` entry, not its root index. `/config` is a
|
|
68
|
+
* side-effect-free data module; the root index pulls in the plugin, serializer,
|
|
69
|
+
* composites, and re-exported Op machinery, any of which could throw on load
|
|
70
|
+
* and make this silently return `{}` — which would drop every profiled step to
|
|
71
|
+
* the 5-minute default while the Temporal path (reading the same table) kept the
|
|
72
|
+
* declared timeout. Importing the narrow module keeps the two executors agreed.
|
|
73
|
+
*/
|
|
74
|
+
export async function loadProfiles(): Promise<Record<string, ActivityProfile>> {
|
|
75
|
+
try {
|
|
76
|
+
const spec = "@intentius/chant-lexicon-temporal/config";
|
|
77
|
+
const mod = (await import(spec)) as { TEMPORAL_ACTIVITY_PROFILES?: Record<string, ActivityProfile> };
|
|
78
|
+
return mod.TEMPORAL_ACTIVITY_PROFILES ?? {};
|
|
79
|
+
} catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a step's `fn` against the loaded activity map.
|
|
86
|
+
* Throws a clear error listing known names if the activity is missing.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveActivity(
|
|
89
|
+
activities: Map<string, ActivityFn>,
|
|
90
|
+
fn: string,
|
|
91
|
+
): ActivityFn {
|
|
92
|
+
const activity = activities.get(fn);
|
|
93
|
+
if (!activity) {
|
|
94
|
+
const known = [...activities.keys()].sort().join(", ");
|
|
95
|
+
throw new Error(`no activity named "${fn}" (known: ${known})`);
|
|
96
|
+
}
|
|
97
|
+
return activity;
|
|
98
|
+
}
|