@intentius/chant 0.1.14 → 0.1.15

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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +18 -2
  3. package/src/cli/commands/build.ts +9 -1
  4. package/src/cli/commands/import-live.test.ts +126 -0
  5. package/src/cli/commands/import.ts +152 -2
  6. package/src/cli/commands/migrate.ts +2 -2
  7. package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +37 -37
  8. package/src/cli/handlers/{state.ts → lifecycle.ts} +148 -36
  9. package/src/cli/handlers/misc.ts +31 -2
  10. package/src/cli/handlers/run.test.ts +98 -0
  11. package/src/cli/handlers/run.ts +123 -0
  12. package/src/cli/main.test.ts +14 -0
  13. package/src/cli/main.ts +38 -12
  14. package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
  15. package/src/cli/mcp/op-tools.ts +2 -2
  16. package/src/cli/mcp/resource-handlers.ts +1 -1
  17. package/src/cli/mcp/server.test.ts +2 -2
  18. package/src/cli/mcp/server.ts +1 -1
  19. package/src/cli/registry.ts +21 -2
  20. package/src/codegen/fetch.test.ts +103 -2
  21. package/src/codegen/fetch.ts +62 -10
  22. package/src/config.ts +31 -0
  23. package/src/detectLexicon.test.ts +2 -2
  24. package/src/index.ts +2 -2
  25. package/src/lexicon-export.test.ts +92 -0
  26. package/src/lexicon.ts +88 -9
  27. package/src/lifecycle/change-set.test.ts +151 -0
  28. package/src/lifecycle/change-set.ts +172 -0
  29. package/src/{state → lifecycle}/git.test.ts +15 -15
  30. package/src/{state → lifecycle}/git.ts +14 -14
  31. package/src/{state → lifecycle}/index.ts +2 -0
  32. package/src/{state → lifecycle}/snapshot.test.ts +5 -5
  33. package/src/{state → lifecycle}/snapshot.ts +9 -9
  34. package/src/{state → lifecycle}/types.ts +1 -1
  35. package/src/op/activity-registry.test.ts +59 -0
  36. package/src/op/activity-registry.ts +91 -0
  37. package/src/op/builders.ts +3 -3
  38. package/src/op/index.ts +6 -1
  39. package/src/op/local-executor.test.ts +247 -0
  40. package/src/op/local-executor.ts +300 -0
  41. package/src/op/local-output.test.ts +54 -0
  42. package/src/op/local-output.ts +63 -0
  43. package/src/op/op.test.ts +4 -4
  44. package/src/op/types.ts +1 -1
  45. package/src/ownership.test.ts +109 -0
  46. package/src/ownership.ts +142 -0
  47. package/src/serializer.ts +19 -1
  48. package/src/toml-parse.ts +3 -3
  49. /package/src/{state → lifecycle}/digest.test.ts +0 -0
  50. /package/src/{state → lifecycle}/digest.ts +0 -0
  51. /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
  52. /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
- pushState,
13
- StaleStateBranchError,
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("state/git", () => {
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 pushState({ cwd: clonePath });
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 pushState({ cwd: clonePath })).toBe(true);
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/state:refs/remotes/origin/chant/state"], clonePath);
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 pushState({ cwd: clonePath })).toBe(true);
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 StaleStateBranchError", async () => {
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 pushState({ cwd: cloneA })).toBe(true);
171
+ expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
172
172
 
173
- // Operator B writes from the same baseline (chant/state doesn't exist
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 StaleStateBranchError because A's push moved the remote ref.
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(pushState({ cwd: cloneB })).rejects.toBeInstanceOf(StaleStateBranchError);
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("StaleStateBranchError carries the expected SHA used as the lease", async () => {
186
- const err = new StaleStateBranchError(null, "stale info: ...");
187
- expect(err.name).toBe("StaleStateBranchError");
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/state orphan branch.
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/state";
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 pushState when the remote chant/state branch has moved since
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 StaleStateBranchError extends Error {
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/state remote branch has moved since this run started — " +
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 = "StaleStateBranchError";
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/state, if any. Returns null when
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 getRemoteStateBranchSha(
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/state ref has advanced past the local remote-tracking
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
- * StaleStateBranchError so the caller can surface a recovery hint.
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 pushState(opts?: { cwd?: string }): Promise<boolean> {
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 getRemoteStateBranchSha(remote, opts);
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 StaleStateBranchError(expected, stderr);
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 fetchState(opts?: { cwd?: string }): Promise<boolean> {
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;
@@ -2,3 +2,5 @@ export * from "./types";
2
2
  export * from "./git";
3
3
  export * from "./digest";
4
4
  export * from "./snapshot";
5
+ export * from "./live-diff";
6
+ export * from "./change-set";
@@ -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 pushStateMock = vi.fn();
7
+ const pushLifecycleMock = vi.fn();
8
8
 
9
9
  vi.mock("./git", () => ({
10
10
  writeSnapshot: (...args: unknown[]) => writeSnapshotMock(...args),
11
11
  getHeadCommit: () => getHeadCommitMock(),
12
- pushState: () => pushStateMock(),
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
- pushStateMock.mockReset();
37
+ pushLifecycleMock.mockReset();
38
38
  writeSnapshotMock.mockResolvedValue("commit-sha");
39
39
  getHeadCommitMock.mockResolvedValue("head-sha");
40
- pushStateMock.mockResolvedValue(true);
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(pushStateMock).toHaveBeenCalledTimes(1);
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 StateSnapshots, computes build digests, and writes to git.
3
+ * assembles LifecycleSnapshots, computes build digests, and writes to git.
4
4
  */
5
- import type { LexiconPlugin, ResourceMetadata, ArtifactMetadata } from "../lexicon";
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 { StateSnapshot } from "./types";
8
+ import type { LifecycleSnapshot } from "./types";
9
9
  import { computeBuildDigest } from "./digest";
10
- import { writeSnapshot, getHeadCommit, pushState } from "./git";
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: StateSnapshot[];
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: LexiconPlugin[],
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: StateSnapshot[] = [];
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: StateSnapshot = {
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 pushState(opts);
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 StateSnapshot {
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,91 @@
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
+ export async function loadProfiles(): Promise<Record<string, ActivityProfile>> {
68
+ try {
69
+ const spec = "@intentius/chant-lexicon-temporal";
70
+ const mod = (await import(spec)) as { TEMPORAL_ACTIVITY_PROFILES?: Record<string, ActivityProfile> };
71
+ return mod.TEMPORAL_ACTIVITY_PROFILES ?? {};
72
+ } catch {
73
+ return {};
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Resolve a step's `fn` against the loaded activity map.
79
+ * Throws a clear error listing known names if the activity is missing.
80
+ */
81
+ export function resolveActivity(
82
+ activities: Map<string, ActivityFn>,
83
+ fn: string,
84
+ ): ActivityFn {
85
+ const activity = activities.get(fn);
86
+ if (!activity) {
87
+ const known = [...activities.keys()].sort().join(", ");
88
+ throw new Error(`no activity named "${fn}" (known: ${known})`);
89
+ }
90
+ return activity;
91
+ }
@@ -83,9 +83,9 @@ export const waitForStack = (name: string, opts?: Record<string, unknown>): Acti
83
83
  export const gitlabPipeline = (name: string, opts?: Record<string, unknown>): ActivityStep =>
84
84
  activity("gitlabPipeline", { name, ...opts }, "longInfra");
85
85
 
86
- /** Take a chant state snapshot for the given environment. */
87
- export const stateSnapshot = (env: string): ActivityStep =>
88
- activity("stateSnapshot", { env });
86
+ /** Take a chant lifecycle snapshot for the given environment. */
87
+ export const lifecycleSnapshot = (env: string): ActivityStep =>
88
+ activity("lifecycleSnapshot", { env });
89
89
 
90
90
  /** Run an arbitrary shell command. */
91
91
  export const shell = (cmd: string, opts?: { env?: Record<string, string> }): ActivityStep =>
package/src/op/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  export { Op, phase, activity, gate, build, kubectlApply, helmInstall, waitForStack,
2
- gitlabPipeline, stateSnapshot, shell, teardown } from "./builders";
2
+ gitlabPipeline, lifecycleSnapshot, shell, teardown } from "./builders";
3
3
  export { OpResource } from "./resource";
4
4
  export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./types";
5
5
  export { discoverOps } from "./discover";
6
6
  export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
7
+ export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
8
+ export type { ActivityFn, ActivityProfile } from "./activity-registry";
9
+ export { runOpLocally, parseDuration, findGate, LocalGateUnsupportedError, OpRunFailure } from "./local-executor";
10
+ export type { StepRecord, OpRunResult } from "./local-executor";
11
+ export { renderHuman, renderJson } from "./local-output";