@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
package/src/op/builders.ts
CHANGED
|
@@ -60,36 +60,72 @@ export function gate(
|
|
|
60
60
|
|
|
61
61
|
// ── Pre-built activity shortcuts ──────────────────────────────────────────────
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Pull an optional `profile` override out of an opts bag, returning the
|
|
65
|
+
* remaining keys (which become activity args) separately.
|
|
66
|
+
*
|
|
67
|
+
* Without this, a `profile` passed in opts would spread into the activity's
|
|
68
|
+
* **args** rather than set the step's `profile` — a silent no-op on the step's
|
|
69
|
+
* timeout. The activity then runs under the default profile, so a step the
|
|
70
|
+
* author tagged `longInfra` (20m) would still get the 5m default. Routing it
|
|
71
|
+
* here lets every shortcut accept a `profile` override that actually takes.
|
|
72
|
+
*/
|
|
73
|
+
function takeProfile(
|
|
74
|
+
opts: Record<string, unknown> | undefined,
|
|
75
|
+
): { args: Record<string, unknown>; profile?: ActivityStep["profile"] } {
|
|
76
|
+
if (!opts) return { args: {} };
|
|
77
|
+
const { profile, ...args } = opts as { profile?: ActivityStep["profile"] } & Record<string, unknown>;
|
|
78
|
+
return { args, profile };
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
/** Run `npm run build` (or `chant build`) in the given project directory. */
|
|
64
|
-
export const build = (path: string, opts?: Record<string, unknown>): ActivityStep =>
|
|
65
|
-
|
|
82
|
+
export const build = (path: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
83
|
+
const { args, profile } = takeProfile(opts);
|
|
84
|
+
return activity("chantBuild", { path, ...args }, profile);
|
|
85
|
+
};
|
|
66
86
|
|
|
67
|
-
/** Run `kubectl apply -f <manifest>`.
|
|
68
|
-
export const kubectlApply = (manifest: string, opts?: Record<string, unknown>): ActivityStep =>
|
|
69
|
-
|
|
87
|
+
/** Run `kubectl apply -f <manifest>`. Defaults to the `longInfra` profile (override via `opts.profile`). */
|
|
88
|
+
export const kubectlApply = (manifest: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
89
|
+
const { args, profile } = takeProfile(opts);
|
|
90
|
+
return activity("kubectlApply", { manifest, ...args }, profile ?? "longInfra");
|
|
91
|
+
};
|
|
70
92
|
|
|
71
|
-
/** Run `helm upgrade --install`.
|
|
93
|
+
/** Run `helm upgrade --install`. Defaults to the `longInfra` profile (override via `opts.profile`). */
|
|
72
94
|
export const helmInstall = (
|
|
73
95
|
name: string,
|
|
74
96
|
chart: string,
|
|
75
|
-
opts?: { values?: string; namespace?: string; [k: string]: unknown },
|
|
76
|
-
): ActivityStep =>
|
|
97
|
+
opts?: { values?: string; namespace?: string; profile?: ActivityStep["profile"]; [k: string]: unknown },
|
|
98
|
+
): ActivityStep => {
|
|
99
|
+
const { args, profile } = takeProfile(opts);
|
|
100
|
+
return activity("helmInstall", { name, chart, ...args }, profile ?? "longInfra");
|
|
101
|
+
};
|
|
77
102
|
|
|
78
|
-
/** Poll for stack readiness (kubectl rollout, CloudFormation complete, etc).
|
|
79
|
-
export const waitForStack = (name: string, opts?: Record<string, unknown>): ActivityStep =>
|
|
80
|
-
|
|
103
|
+
/** Poll for stack readiness (kubectl rollout, CloudFormation complete, etc). Defaults to the `k8sWait` profile (override via `opts.profile`). */
|
|
104
|
+
export const waitForStack = (name: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
105
|
+
const { args, profile } = takeProfile(opts);
|
|
106
|
+
return activity("waitForStack", { name, ...args }, profile ?? "k8sWait");
|
|
107
|
+
};
|
|
81
108
|
|
|
82
|
-
/** Trigger and wait for a GitLab CI pipeline to complete.
|
|
83
|
-
export const gitlabPipeline = (name: string, opts?: Record<string, unknown>): ActivityStep =>
|
|
84
|
-
|
|
109
|
+
/** Trigger and wait for a GitLab CI pipeline to complete. Defaults to the `longInfra` profile (override via `opts.profile`). */
|
|
110
|
+
export const gitlabPipeline = (name: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
111
|
+
const { args, profile } = takeProfile(opts);
|
|
112
|
+
return activity("gitlabPipeline", { name, ...args }, profile ?? "longInfra");
|
|
113
|
+
};
|
|
85
114
|
|
|
86
|
-
/** Take a chant
|
|
87
|
-
export const
|
|
88
|
-
activity("
|
|
115
|
+
/** Take a chant lifecycle snapshot for the given environment. */
|
|
116
|
+
export const lifecycleSnapshot = (env: string): ActivityStep =>
|
|
117
|
+
activity("lifecycleSnapshot", { env });
|
|
89
118
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Run an arbitrary shell command. Tag long-running commands with a `profile`
|
|
121
|
+
* (e.g. `longInfra` for a multi-GB image push) so they get the right
|
|
122
|
+
* start-to-close timeout under both the local executor and Temporal.
|
|
123
|
+
*/
|
|
124
|
+
export const shell = (
|
|
125
|
+
cmd: string,
|
|
126
|
+
opts?: { env?: Record<string, string>; profile?: ActivityStep["profile"] },
|
|
127
|
+
): ActivityStep =>
|
|
128
|
+
activity("shellCmd", { cmd, ...(opts?.env ? { env: opts.env } : {}) }, opts?.profile);
|
|
93
129
|
|
|
94
130
|
/** Run `chant teardown` in the given project directory. Uses `longInfra` profile. */
|
|
95
131
|
export const teardown = (path: 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,
|
|
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";
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import type { OpConfig } from "./types";
|
|
3
|
+
import type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
4
|
+
import {
|
|
5
|
+
runOpLocally,
|
|
6
|
+
parseDuration,
|
|
7
|
+
findGate,
|
|
8
|
+
LocalGateUnsupportedError,
|
|
9
|
+
OpRunFailure,
|
|
10
|
+
} from "./local-executor";
|
|
11
|
+
|
|
12
|
+
// Fast profiles so retry/timeout tests run in milliseconds.
|
|
13
|
+
const PROFILES: Record<string, ActivityProfile> = {
|
|
14
|
+
fastIdempotent: {
|
|
15
|
+
startToCloseTimeout: "5m",
|
|
16
|
+
retry: { maximumAttempts: 3, initialInterval: "5ms", backoffCoefficient: 2 },
|
|
17
|
+
},
|
|
18
|
+
quickTimeout: {
|
|
19
|
+
startToCloseTimeout: "50ms",
|
|
20
|
+
retry: { maximumAttempts: 2, initialInterval: "1ms", backoffCoefficient: 1 },
|
|
21
|
+
},
|
|
22
|
+
single: { startToCloseTimeout: "5m", retry: { maximumAttempts: 1 } },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function op(partial: Partial<OpConfig>): OpConfig {
|
|
26
|
+
return { name: "test-op", overview: "", phases: [], ...partial };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("parseDuration", () => {
|
|
30
|
+
test("parses single and compound durations", () => {
|
|
31
|
+
expect(parseDuration("5m")).toBe(300_000);
|
|
32
|
+
expect(parseDuration("30s")).toBe(30_000);
|
|
33
|
+
expect(parseDuration("100ms")).toBe(100);
|
|
34
|
+
expect(parseDuration("1h30m")).toBe(5_400_000);
|
|
35
|
+
expect(parseDuration("48h")).toBe(172_800_000);
|
|
36
|
+
});
|
|
37
|
+
test("throws on garbage", () => {
|
|
38
|
+
expect(() => parseDuration("soon")).toThrow(/unparseable/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("runOpLocally — sequencing", () => {
|
|
43
|
+
test("runs phases and steps in declared order", async () => {
|
|
44
|
+
const order: string[] = [];
|
|
45
|
+
const make = (tag: string): ActivityFn => async () => { order.push(tag); };
|
|
46
|
+
const activities = new Map<string, ActivityFn>([
|
|
47
|
+
["a", make("a")], ["b", make("b")], ["c", make("c")],
|
|
48
|
+
]);
|
|
49
|
+
const config = op({
|
|
50
|
+
phases: [
|
|
51
|
+
{ name: "P1", steps: [{ kind: "activity", fn: "a" }] },
|
|
52
|
+
{ name: "P2", steps: [{ kind: "activity", fn: "b" }] },
|
|
53
|
+
{ name: "P3", steps: [{ kind: "activity", fn: "c" }] },
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
const result = await runOpLocally(config, activities, PROFILES);
|
|
57
|
+
expect(order).toEqual(["a", "b", "c"]);
|
|
58
|
+
expect(result.ok).toBe(true);
|
|
59
|
+
expect(result.records.map((r) => r.fn)).toEqual(["a", "b", "c"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("parallel phase runs steps concurrently (~max, not sum)", async () => {
|
|
63
|
+
const slow = (ms: number): ActivityFn => async () => { await new Promise((r) => setTimeout(r, ms)); };
|
|
64
|
+
const activities = new Map<string, ActivityFn>([["s1", slow(60)], ["s2", slow(60)]]);
|
|
65
|
+
const config = op({
|
|
66
|
+
phases: [{ name: "P", parallel: true, steps: [
|
|
67
|
+
{ kind: "activity", fn: "s1" }, { kind: "activity", fn: "s2" },
|
|
68
|
+
] }],
|
|
69
|
+
});
|
|
70
|
+
const start = Date.now();
|
|
71
|
+
await runOpLocally(config, activities, PROFILES);
|
|
72
|
+
const elapsed = Date.now() - start;
|
|
73
|
+
expect(elapsed).toBeLessThan(110); // ~60 (max), well under 120 (sum)
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("runOpLocally — retry + timeout", () => {
|
|
78
|
+
test("retries until success", async () => {
|
|
79
|
+
let calls = 0;
|
|
80
|
+
const flaky: ActivityFn = async () => {
|
|
81
|
+
calls++;
|
|
82
|
+
if (calls < 3) throw new Error("transient");
|
|
83
|
+
return "ok";
|
|
84
|
+
};
|
|
85
|
+
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "flaky" }] }] });
|
|
86
|
+
const result = await runOpLocally(config, new Map([["flaky", flaky]]), PROFILES);
|
|
87
|
+
expect(calls).toBe(3);
|
|
88
|
+
expect(result.records[0].status).toBe("ok");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("times out a slow attempt and retries", async () => {
|
|
92
|
+
let calls = 0;
|
|
93
|
+
const slow: ActivityFn = async () => {
|
|
94
|
+
calls++;
|
|
95
|
+
// First attempt hangs past the 50ms timeout; second resolves fast.
|
|
96
|
+
await new Promise((r) => setTimeout(r, calls === 1 ? 200 : 1));
|
|
97
|
+
};
|
|
98
|
+
// Map the default profile to quickTimeout (50ms timeout, 2 attempts).
|
|
99
|
+
const config = op({
|
|
100
|
+
phases: [{ name: "P", steps: [{ kind: "activity", fn: "slow" }] }],
|
|
101
|
+
});
|
|
102
|
+
const profiles = { ...PROFILES, fastIdempotent: PROFILES.quickTimeout };
|
|
103
|
+
const result = await runOpLocally(config, new Map([["slow", slow]]), profiles);
|
|
104
|
+
expect(calls).toBe(2);
|
|
105
|
+
expect(result.records[0].status).toBe("ok");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("rejects after exhausting retries", async () => {
|
|
109
|
+
const always: ActivityFn = async () => { throw new Error("nope"); };
|
|
110
|
+
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "always" }] }] });
|
|
111
|
+
await expect(runOpLocally(config, new Map([["always", always]]), PROFILES)).rejects.toBeInstanceOf(OpRunFailure);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("honors a step's non-default profile timeout (not the default)", async () => {
|
|
115
|
+
// The default profile would time out at 50ms; the step is tagged longInfra,
|
|
116
|
+
// which gives it room. Guards the bug where a profiled step silently got the
|
|
117
|
+
// default cap (local vs --temporal disagreement).
|
|
118
|
+
const slow: ActivityFn = async () => { await new Promise((r) => setTimeout(r, 150)); return "done"; };
|
|
119
|
+
const profiles = {
|
|
120
|
+
fastIdempotent: { startToCloseTimeout: "50ms", retry: { maximumAttempts: 1 } },
|
|
121
|
+
longInfra: { startToCloseTimeout: "5m", retry: { maximumAttempts: 1 } },
|
|
122
|
+
};
|
|
123
|
+
const config = op({
|
|
124
|
+
phases: [{ name: "P", steps: [{ kind: "activity", fn: "slow", profile: "longInfra" }] }],
|
|
125
|
+
});
|
|
126
|
+
const result = await runOpLocally(config, new Map([["slow", slow]]), profiles);
|
|
127
|
+
expect(result.records[0].status).toBe("ok");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("runOpLocally — cancellation", () => {
|
|
132
|
+
test("aborts the activity's signal on timeout", async () => {
|
|
133
|
+
let abortedSeen = false;
|
|
134
|
+
const hang: ActivityFn = async (_args, signal) => {
|
|
135
|
+
await new Promise<void>((resolve) => {
|
|
136
|
+
signal?.addEventListener("abort", () => { abortedSeen = true; resolve(); });
|
|
137
|
+
setTimeout(resolve, 5000); // would hang far past the timeout if not aborted
|
|
138
|
+
});
|
|
139
|
+
throw new Error("abandoned");
|
|
140
|
+
};
|
|
141
|
+
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "hang" }] }] });
|
|
142
|
+
const profiles = { ...PROFILES, fastIdempotent: { startToCloseTimeout: "30ms", retry: { maximumAttempts: 1 } } };
|
|
143
|
+
await expect(runOpLocally(config, new Map([["hang", hang]]), profiles)).rejects.toBeInstanceOf(OpRunFailure);
|
|
144
|
+
expect(abortedSeen).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("stops retrying once the run signal aborts (Ctrl-C)", async () => {
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
let calls = 0;
|
|
150
|
+
const failOnAbort: ActivityFn = async () => {
|
|
151
|
+
calls++;
|
|
152
|
+
controller.abort(); // simulate SIGINT mid-attempt
|
|
153
|
+
throw new Error("boom");
|
|
154
|
+
};
|
|
155
|
+
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "failOnAbort" }] }] });
|
|
156
|
+
// fastIdempotent permits 3 attempts; the abort must cut it to 1.
|
|
157
|
+
await expect(
|
|
158
|
+
runOpLocally(config, new Map([["failOnAbort", failOnAbort]]), PROFILES, controller.signal),
|
|
159
|
+
).rejects.toBeInstanceOf(OpRunFailure);
|
|
160
|
+
expect(calls).toBe(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("skips onFailure compensation when aborted", async () => {
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const comp = vi.fn();
|
|
166
|
+
const main: ActivityFn = async () => { controller.abort(); throw new Error("boom"); };
|
|
167
|
+
const config = op({
|
|
168
|
+
phases: [{ name: "Main", steps: [{ kind: "activity", fn: "main" }] }],
|
|
169
|
+
onFailure: [{ name: "C", steps: [{ kind: "activity", fn: "comp" }] }],
|
|
170
|
+
});
|
|
171
|
+
const activities = new Map<string, ActivityFn>([["main", main], ["comp", async () => { comp(); }]]);
|
|
172
|
+
await expect(runOpLocally(config, activities, PROFILES, controller.signal)).rejects.toBeInstanceOf(OpRunFailure);
|
|
173
|
+
expect(comp).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("runOpLocally — non-retryable errors", () => {
|
|
178
|
+
test("fails immediately on a non-retryable error type", async () => {
|
|
179
|
+
let calls = 0;
|
|
180
|
+
const fatal: ActivityFn = async () => {
|
|
181
|
+
calls++;
|
|
182
|
+
const e = new Error("bad manifest");
|
|
183
|
+
e.name = "ValidationError";
|
|
184
|
+
throw e;
|
|
185
|
+
};
|
|
186
|
+
const profiles = {
|
|
187
|
+
...PROFILES,
|
|
188
|
+
fastIdempotent: {
|
|
189
|
+
startToCloseTimeout: "5m",
|
|
190
|
+
retry: { maximumAttempts: 3, initialInterval: "1ms", nonRetryableErrorTypes: ["ValidationError"] },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "fatal" }] }] });
|
|
194
|
+
await expect(runOpLocally(config, new Map([["fatal", fatal]]), profiles)).rejects.toBeInstanceOf(OpRunFailure);
|
|
195
|
+
expect(calls).toBe(1);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("runOpLocally — outcomeAttribute", () => {
|
|
200
|
+
test("captures a dot-path from the return value", async () => {
|
|
201
|
+
const diff: ActivityFn = async () => ({ output: "...", exitCode: 0, drifted: false });
|
|
202
|
+
const config = op({
|
|
203
|
+
phases: [{ name: "Check", steps: [
|
|
204
|
+
{ kind: "activity", fn: "lifecycleDiff", outcomeAttribute: { name: "Drift", from: "drifted" } },
|
|
205
|
+
] }],
|
|
206
|
+
});
|
|
207
|
+
const result = await runOpLocally(config, new Map([["lifecycleDiff", diff]]), PROFILES);
|
|
208
|
+
expect(result.records[0].outcome).toEqual({ name: "Drift", value: false });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("runOpLocally — onFailure", () => {
|
|
213
|
+
test("runs compensation phases in reverse and rejects with ok=false", async () => {
|
|
214
|
+
const order: string[] = [];
|
|
215
|
+
const make = (tag: string, fail = false): ActivityFn => async () => {
|
|
216
|
+
order.push(tag);
|
|
217
|
+
if (fail) throw new Error("boom");
|
|
218
|
+
};
|
|
219
|
+
const activities = new Map<string, ActivityFn>([
|
|
220
|
+
["main", make("main", true)],
|
|
221
|
+
["comp1", make("comp1")],
|
|
222
|
+
["comp2", make("comp2")],
|
|
223
|
+
]);
|
|
224
|
+
const config = op({
|
|
225
|
+
phases: [{ name: "Main", steps: [{ kind: "activity", fn: "main" }] }],
|
|
226
|
+
onFailure: [
|
|
227
|
+
{ name: "C1", steps: [{ kind: "activity", fn: "comp1" }] },
|
|
228
|
+
{ name: "C2", steps: [{ kind: "activity", fn: "comp2" }] },
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
const err = await runOpLocally(config, activities, PROFILES).catch((e) => e);
|
|
232
|
+
expect(err).toBeInstanceOf(OpRunFailure);
|
|
233
|
+
expect(err.result.ok).toBe(false);
|
|
234
|
+
// Main fails (3 attempts), then compensation runs in reverse: comp2, comp1.
|
|
235
|
+
expect(order).toEqual(["main", "main", "main", "comp2", "comp1"]);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("runOpLocally — gate rejection", () => {
|
|
240
|
+
test("rejects before running any step when a gate is present", async () => {
|
|
241
|
+
const ran = vi.fn();
|
|
242
|
+
const activities = new Map<string, ActivityFn>([["a", async () => { ran(); }]]);
|
|
243
|
+
const config = op({
|
|
244
|
+
phases: [
|
|
245
|
+
{ name: "P", steps: [
|
|
246
|
+
{ kind: "activity", fn: "a" },
|
|
247
|
+
{ kind: "gate", signalName: "approve-prod" },
|
|
248
|
+
] },
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
await expect(runOpLocally(config, activities, PROFILES)).rejects.toBeInstanceOf(LocalGateUnsupportedError);
|
|
252
|
+
await expect(runOpLocally(config, activities, PROFILES)).rejects.toThrow(/--temporal/);
|
|
253
|
+
expect(ran).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("findGate locates a gate in phases or onFailure", () => {
|
|
257
|
+
expect(findGate(op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "a" }] }] }))).toBeUndefined();
|
|
258
|
+
const gated = findGate(op({
|
|
259
|
+
phases: [{ name: "P", steps: [{ kind: "gate", signalName: "g" }] }],
|
|
260
|
+
}));
|
|
261
|
+
expect(gated?.signalName).toBe("g");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Op executor — runs an Op's phases in-process with no Temporal worker.
|
|
3
|
+
*
|
|
4
|
+
* A first-class peer to Temporal mode for dev loops, CI, and drift/observation
|
|
5
|
+
* Ops. Provides phase sequencing, parallel phases, per-step retry + timeout via
|
|
6
|
+
* activity profiles, `outcomeAttribute` capture, and `onFailure` compensation.
|
|
7
|
+
* Gates and schedules are unsupported and rejected before any phase runs.
|
|
8
|
+
*
|
|
9
|
+
* The executor is deliberately decoupled from the Temporal lexicon: activity
|
|
10
|
+
* implementations and profiles are passed in (loaded dynamically by the CLI),
|
|
11
|
+
* so core never statically depends on `@intentius/chant-lexicon-temporal`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, StepDefinition } from "./types";
|
|
15
|
+
import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activity-registry";
|
|
16
|
+
|
|
17
|
+
// ── Records ─────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface StepRecord {
|
|
20
|
+
phase: string;
|
|
21
|
+
fn: string;
|
|
22
|
+
args: Record<string, unknown>;
|
|
23
|
+
status: "ok" | "fail" | "skipped";
|
|
24
|
+
durationMs: number;
|
|
25
|
+
outcome?: { name: string; value: unknown };
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OpRunResult {
|
|
30
|
+
op: string;
|
|
31
|
+
records: StepRecord[];
|
|
32
|
+
totalMs: number;
|
|
33
|
+
ok: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Errors ────────────────────────────────────────────────────────────────��─
|
|
37
|
+
|
|
38
|
+
/** Thrown when an Op contains a gate (or schedule) that local mode cannot run. */
|
|
39
|
+
export class LocalGateUnsupportedError extends Error {
|
|
40
|
+
constructor(public readonly signalName: string) {
|
|
41
|
+
super(
|
|
42
|
+
`gate "${signalName}" is not supported in local mode — gates and schedules ` +
|
|
43
|
+
`need a durable runtime. Re-run with --temporal.`,
|
|
44
|
+
);
|
|
45
|
+
this.name = "LocalGateUnsupportedError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Thrown on terminal Op failure; carries the partial run result for rendering. */
|
|
50
|
+
export class OpRunFailure extends Error {
|
|
51
|
+
constructor(public readonly result: OpRunResult) {
|
|
52
|
+
super(`Op "${result.op}" failed`);
|
|
53
|
+
this.name = "OpRunFailure";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Internal: a phase aborted; carries records produced before the abort. */
|
|
58
|
+
class PhaseFailure extends Error {
|
|
59
|
+
constructor(public readonly records: StepRecord[]) {
|
|
60
|
+
super("phase failed");
|
|
61
|
+
this.name = "PhaseFailure";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Helpers ───────────────────────────────────────────────────────────────��─
|
|
66
|
+
|
|
67
|
+
const DEFAULT_PROFILE = "fastIdempotent";
|
|
68
|
+
const FALLBACK_TIMEOUT_MS = 5 * 60_000;
|
|
69
|
+
|
|
70
|
+
const isActivity = (s: StepDefinition): s is ActivityStep => s.kind === "activity";
|
|
71
|
+
const isGate = (s: StepDefinition): s is GateStep => s.kind === "gate";
|
|
72
|
+
|
|
73
|
+
/** Parse a Temporal duration string ("5m", "30s", "1h30m", "100ms") to ms. */
|
|
74
|
+
export function parseDuration(s: string): number {
|
|
75
|
+
const units: Record<string, number> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
76
|
+
let total = 0;
|
|
77
|
+
let matched = false;
|
|
78
|
+
for (const m of s.matchAll(/(\d+)(ms|s|m|h|d)/g)) {
|
|
79
|
+
total += Number(m[1]) * units[m[2]];
|
|
80
|
+
matched = true;
|
|
81
|
+
}
|
|
82
|
+
if (!matched) throw new Error(`unparseable duration: "${s}"`);
|
|
83
|
+
return total;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Resolve a dot-path into a value; returns the whole value when path is absent. */
|
|
87
|
+
function resolvePath(value: unknown, path?: string): unknown {
|
|
88
|
+
if (!path) return value;
|
|
89
|
+
return path.split(".").reduce<unknown>(
|
|
90
|
+
(acc, key) => (acc == null ? acc : (acc as Record<string, unknown>)[key]),
|
|
91
|
+
value,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Find the first gate step anywhere in the Op (phases + onFailure), if any. */
|
|
96
|
+
export function findGate(config: OpConfig): GateStep | undefined {
|
|
97
|
+
const all = [...config.phases, ...(config.onFailure ?? [])];
|
|
98
|
+
for (const phase of all) {
|
|
99
|
+
const gate = phase.steps.find(isGate);
|
|
100
|
+
if (gate) return gate;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Race a single activity attempt against its start-to-close timeout. On timeout
|
|
109
|
+
* (or when the run-level `parentSignal` aborts, e.g. Ctrl-C) the attempt's
|
|
110
|
+
* `AbortSignal` fires so the activity can kill its child process, then the call
|
|
111
|
+
* rejects so the retry loop can react. The losing promise is swallowed to keep
|
|
112
|
+
* its eventual rejection from surfacing as an unhandled rejection.
|
|
113
|
+
*/
|
|
114
|
+
async function callWithTimeout(
|
|
115
|
+
fn: ActivityFn,
|
|
116
|
+
args: Record<string, unknown>,
|
|
117
|
+
timeoutMs: number,
|
|
118
|
+
parentSignal?: AbortSignal,
|
|
119
|
+
): Promise<unknown> {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const onParentAbort = () => controller.abort();
|
|
122
|
+
if (parentSignal) {
|
|
123
|
+
if (parentSignal.aborted) controller.abort();
|
|
124
|
+
else parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
128
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
129
|
+
timer = setTimeout(() => {
|
|
130
|
+
controller.abort();
|
|
131
|
+
reject(new Error(`activity timed out after ${timeoutMs}ms`));
|
|
132
|
+
}, timeoutMs);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const call = Promise.resolve(fn(args, controller.signal));
|
|
136
|
+
call.catch(() => {}); // losing-race rejection must not become unhandled
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
return await Promise.race([call, timeout]);
|
|
140
|
+
} finally {
|
|
141
|
+
if (timer) clearTimeout(timer);
|
|
142
|
+
if (parentSignal) parentSignal.removeEventListener("abort", onParentAbort);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Step + phase execution ────────────────────────────────────────────────��─
|
|
147
|
+
|
|
148
|
+
/** Run one activity step with retry + timeout. Never throws — returns a record. */
|
|
149
|
+
async function runStep(
|
|
150
|
+
step: ActivityStep,
|
|
151
|
+
phaseName: string,
|
|
152
|
+
activities: Map<string, ActivityFn>,
|
|
153
|
+
profiles: Record<string, ActivityProfile>,
|
|
154
|
+
signal?: AbortSignal,
|
|
155
|
+
): Promise<StepRecord> {
|
|
156
|
+
const args = step.args ?? {};
|
|
157
|
+
const base = { phase: phaseName, fn: step.fn, args };
|
|
158
|
+
const start = Date.now();
|
|
159
|
+
|
|
160
|
+
let fn: ActivityFn;
|
|
161
|
+
try {
|
|
162
|
+
fn = resolveActivity(activities, step.fn);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { ...base, status: "fail", durationMs: 0, error: errMessage(err) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const profile = profiles[step.profile ?? DEFAULT_PROFILE] ?? {};
|
|
168
|
+
const timeoutMs = profile.startToCloseTimeout
|
|
169
|
+
? parseDuration(profile.startToCloseTimeout)
|
|
170
|
+
: FALLBACK_TIMEOUT_MS;
|
|
171
|
+
const maxAttempts =
|
|
172
|
+
profile.retry?.maximumAttempts && profile.retry.maximumAttempts > 0
|
|
173
|
+
? profile.retry.maximumAttempts
|
|
174
|
+
: 1;
|
|
175
|
+
const initial = profile.retry?.initialInterval ? parseDuration(profile.retry.initialInterval) : 0;
|
|
176
|
+
const backoff = profile.retry?.backoffCoefficient ?? 1;
|
|
177
|
+
const maxInterval = profile.retry?.maximumInterval
|
|
178
|
+
? parseDuration(profile.retry.maximumInterval)
|
|
179
|
+
: Infinity;
|
|
180
|
+
const nonRetryable = profile.retry?.nonRetryableErrorTypes ?? [];
|
|
181
|
+
|
|
182
|
+
let lastErr: unknown;
|
|
183
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
184
|
+
try {
|
|
185
|
+
const result = await callWithTimeout(fn, args, timeoutMs, signal);
|
|
186
|
+
const record: StepRecord = { ...base, status: "ok", durationMs: Date.now() - start };
|
|
187
|
+
if (step.outcomeAttribute) {
|
|
188
|
+
record.outcome = {
|
|
189
|
+
name: step.outcomeAttribute.name,
|
|
190
|
+
value: resolvePath(result, step.outcomeAttribute.from),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return record;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
lastErr = err;
|
|
196
|
+
// Stop retrying on abort (Ctrl-C / timeout cascade) or a non-retryable error.
|
|
197
|
+
const fatal =
|
|
198
|
+
signal?.aborted || (err instanceof Error && nonRetryable.includes(err.name));
|
|
199
|
+
if (!fatal && attempt < maxAttempts) {
|
|
200
|
+
const wait = Math.min(initial * Math.pow(backoff, attempt - 1), maxInterval);
|
|
201
|
+
if (wait > 0) await sleep(wait);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { ...base, status: "fail", durationMs: Date.now() - start, error: errMessage(lastErr) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Run a phase. Throws PhaseFailure (with records so far) if any step fails. */
|
|
211
|
+
async function runPhase(
|
|
212
|
+
phase: PhaseDefinition,
|
|
213
|
+
activities: Map<string, ActivityFn>,
|
|
214
|
+
profiles: Record<string, ActivityProfile>,
|
|
215
|
+
signal?: AbortSignal,
|
|
216
|
+
): Promise<StepRecord[]> {
|
|
217
|
+
// Defensive: gates are pre-flighted, but never execute one if it slips through.
|
|
218
|
+
const gate = phase.steps.find(isGate);
|
|
219
|
+
if (gate) throw new LocalGateUnsupportedError(gate.signalName);
|
|
220
|
+
|
|
221
|
+
const steps = phase.steps.filter(isActivity);
|
|
222
|
+
|
|
223
|
+
if (phase.parallel) {
|
|
224
|
+
const records = await Promise.all(
|
|
225
|
+
steps.map((s) => runStep(s, phase.name, activities, profiles, signal)),
|
|
226
|
+
);
|
|
227
|
+
if (records.some((r) => r.status === "fail")) throw new PhaseFailure(records);
|
|
228
|
+
return records;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const records: StepRecord[] = [];
|
|
232
|
+
for (let i = 0; i < steps.length; i++) {
|
|
233
|
+
const record = await runStep(steps[i], phase.name, activities, profiles, signal);
|
|
234
|
+
records.push(record);
|
|
235
|
+
if (record.status === "fail") {
|
|
236
|
+
// Mark the remaining steps in this phase as skipped, then abort.
|
|
237
|
+
for (const skipped of steps.slice(i + 1)) {
|
|
238
|
+
records.push({
|
|
239
|
+
phase: phase.name,
|
|
240
|
+
fn: skipped.fn,
|
|
241
|
+
args: skipped.args ?? {},
|
|
242
|
+
status: "skipped",
|
|
243
|
+
durationMs: 0,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
throw new PhaseFailure(records);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return records;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Public API ────────────────────────────────────────────────────────────��─
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Execute an Op locally. Resolves with the run result on success; rejects with
|
|
256
|
+
* `OpRunFailure` (carrying the partial result) on terminal failure, after
|
|
257
|
+
* running any `onFailure` phases in reverse order. Throws
|
|
258
|
+
* `LocalGateUnsupportedError` up front if the Op contains a gate.
|
|
259
|
+
*/
|
|
260
|
+
export async function runOpLocally(
|
|
261
|
+
config: OpConfig,
|
|
262
|
+
activities: Map<string, ActivityFn>,
|
|
263
|
+
profiles: Record<string, ActivityProfile>,
|
|
264
|
+
signal?: AbortSignal,
|
|
265
|
+
): Promise<OpRunResult> {
|
|
266
|
+
const gate = findGate(config);
|
|
267
|
+
if (gate) throw new LocalGateUnsupportedError(gate.signalName);
|
|
268
|
+
|
|
269
|
+
const records: StepRecord[] = [];
|
|
270
|
+
const start = Date.now();
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
for (const phase of config.phases) {
|
|
274
|
+
if (signal?.aborted) throw new PhaseFailure([]);
|
|
275
|
+
records.push(...(await runPhase(phase, activities, profiles, signal)));
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err instanceof PhaseFailure) records.push(...err.records);
|
|
279
|
+
|
|
280
|
+
// Compensation: run onFailure phases in reverse order (best-effort). Skipped
|
|
281
|
+
// on abort (Ctrl-C) — the user asked to stop, so don't start new work.
|
|
282
|
+
if (!signal?.aborted) {
|
|
283
|
+
for (const phase of [...(config.onFailure ?? [])].reverse()) {
|
|
284
|
+
try {
|
|
285
|
+
records.push(...(await runPhase(phase, activities, profiles, signal)));
|
|
286
|
+
} catch (compErr) {
|
|
287
|
+
if (compErr instanceof PhaseFailure) records.push(...compErr.records);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
throw new OpRunFailure({ op: config.name, records, totalMs: Date.now() - start, ok: false });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { op: config.name, records, totalMs: Date.now() - start, ok: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function errMessage(err: unknown): string {
|
|
299
|
+
return err instanceof Error ? err.message : String(err);
|
|
300
|
+
}
|