@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.
- 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} +37 -37
- package/src/cli/handlers/{state.ts → lifecycle.ts} +148 -36
- 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 +38 -12
- 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 +21 -2
- package/src/codegen/fetch.test.ts +103 -2
- package/src/codegen/fetch.ts +62 -10
- package/src/config.ts +31 -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 +91 -0
- package/src/op/builders.ts +3 -3
- package/src/op/index.ts +6 -1
- package/src/op/local-executor.test.ts +247 -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 +4 -4
- package/src/op/types.ts +1 -1
- 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,247 @@
|
|
|
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
|
+
|
|
115
|
+
describe("runOpLocally — cancellation", () => {
|
|
116
|
+
test("aborts the activity's signal on timeout", async () => {
|
|
117
|
+
let abortedSeen = false;
|
|
118
|
+
const hang: ActivityFn = async (_args, signal) => {
|
|
119
|
+
await new Promise<void>((resolve) => {
|
|
120
|
+
signal?.addEventListener("abort", () => { abortedSeen = true; resolve(); });
|
|
121
|
+
setTimeout(resolve, 5000); // would hang far past the timeout if not aborted
|
|
122
|
+
});
|
|
123
|
+
throw new Error("abandoned");
|
|
124
|
+
};
|
|
125
|
+
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "hang" }] }] });
|
|
126
|
+
const profiles = { ...PROFILES, fastIdempotent: { startToCloseTimeout: "30ms", retry: { maximumAttempts: 1 } } };
|
|
127
|
+
await expect(runOpLocally(config, new Map([["hang", hang]]), profiles)).rejects.toBeInstanceOf(OpRunFailure);
|
|
128
|
+
expect(abortedSeen).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("stops retrying once the run signal aborts (Ctrl-C)", async () => {
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
let calls = 0;
|
|
134
|
+
const failOnAbort: ActivityFn = async () => {
|
|
135
|
+
calls++;
|
|
136
|
+
controller.abort(); // simulate SIGINT mid-attempt
|
|
137
|
+
throw new Error("boom");
|
|
138
|
+
};
|
|
139
|
+
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "failOnAbort" }] }] });
|
|
140
|
+
// fastIdempotent permits 3 attempts; the abort must cut it to 1.
|
|
141
|
+
await expect(
|
|
142
|
+
runOpLocally(config, new Map([["failOnAbort", failOnAbort]]), PROFILES, controller.signal),
|
|
143
|
+
).rejects.toBeInstanceOf(OpRunFailure);
|
|
144
|
+
expect(calls).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("skips onFailure compensation when aborted", async () => {
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const comp = vi.fn();
|
|
150
|
+
const main: ActivityFn = async () => { controller.abort(); throw new Error("boom"); };
|
|
151
|
+
const config = op({
|
|
152
|
+
phases: [{ name: "Main", steps: [{ kind: "activity", fn: "main" }] }],
|
|
153
|
+
onFailure: [{ name: "C", steps: [{ kind: "activity", fn: "comp" }] }],
|
|
154
|
+
});
|
|
155
|
+
const activities = new Map<string, ActivityFn>([["main", main], ["comp", async () => { comp(); }]]);
|
|
156
|
+
await expect(runOpLocally(config, activities, PROFILES, controller.signal)).rejects.toBeInstanceOf(OpRunFailure);
|
|
157
|
+
expect(comp).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("runOpLocally — non-retryable errors", () => {
|
|
162
|
+
test("fails immediately on a non-retryable error type", async () => {
|
|
163
|
+
let calls = 0;
|
|
164
|
+
const fatal: ActivityFn = async () => {
|
|
165
|
+
calls++;
|
|
166
|
+
const e = new Error("bad manifest");
|
|
167
|
+
e.name = "ValidationError";
|
|
168
|
+
throw e;
|
|
169
|
+
};
|
|
170
|
+
const profiles = {
|
|
171
|
+
...PROFILES,
|
|
172
|
+
fastIdempotent: {
|
|
173
|
+
startToCloseTimeout: "5m",
|
|
174
|
+
retry: { maximumAttempts: 3, initialInterval: "1ms", nonRetryableErrorTypes: ["ValidationError"] },
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "fatal" }] }] });
|
|
178
|
+
await expect(runOpLocally(config, new Map([["fatal", fatal]]), profiles)).rejects.toBeInstanceOf(OpRunFailure);
|
|
179
|
+
expect(calls).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("runOpLocally — outcomeAttribute", () => {
|
|
184
|
+
test("captures a dot-path from the return value", async () => {
|
|
185
|
+
const diff: ActivityFn = async () => ({ output: "...", exitCode: 0, drifted: false });
|
|
186
|
+
const config = op({
|
|
187
|
+
phases: [{ name: "Check", steps: [
|
|
188
|
+
{ kind: "activity", fn: "lifecycleDiff", outcomeAttribute: { name: "Drift", from: "drifted" } },
|
|
189
|
+
] }],
|
|
190
|
+
});
|
|
191
|
+
const result = await runOpLocally(config, new Map([["lifecycleDiff", diff]]), PROFILES);
|
|
192
|
+
expect(result.records[0].outcome).toEqual({ name: "Drift", value: false });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("runOpLocally — onFailure", () => {
|
|
197
|
+
test("runs compensation phases in reverse and rejects with ok=false", async () => {
|
|
198
|
+
const order: string[] = [];
|
|
199
|
+
const make = (tag: string, fail = false): ActivityFn => async () => {
|
|
200
|
+
order.push(tag);
|
|
201
|
+
if (fail) throw new Error("boom");
|
|
202
|
+
};
|
|
203
|
+
const activities = new Map<string, ActivityFn>([
|
|
204
|
+
["main", make("main", true)],
|
|
205
|
+
["comp1", make("comp1")],
|
|
206
|
+
["comp2", make("comp2")],
|
|
207
|
+
]);
|
|
208
|
+
const config = op({
|
|
209
|
+
phases: [{ name: "Main", steps: [{ kind: "activity", fn: "main" }] }],
|
|
210
|
+
onFailure: [
|
|
211
|
+
{ name: "C1", steps: [{ kind: "activity", fn: "comp1" }] },
|
|
212
|
+
{ name: "C2", steps: [{ kind: "activity", fn: "comp2" }] },
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
const err = await runOpLocally(config, activities, PROFILES).catch((e) => e);
|
|
216
|
+
expect(err).toBeInstanceOf(OpRunFailure);
|
|
217
|
+
expect(err.result.ok).toBe(false);
|
|
218
|
+
// Main fails (3 attempts), then compensation runs in reverse: comp2, comp1.
|
|
219
|
+
expect(order).toEqual(["main", "main", "main", "comp2", "comp1"]);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("runOpLocally — gate rejection", () => {
|
|
224
|
+
test("rejects before running any step when a gate is present", async () => {
|
|
225
|
+
const ran = vi.fn();
|
|
226
|
+
const activities = new Map<string, ActivityFn>([["a", async () => { ran(); }]]);
|
|
227
|
+
const config = op({
|
|
228
|
+
phases: [
|
|
229
|
+
{ name: "P", steps: [
|
|
230
|
+
{ kind: "activity", fn: "a" },
|
|
231
|
+
{ kind: "gate", signalName: "approve-prod" },
|
|
232
|
+
] },
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
await expect(runOpLocally(config, activities, PROFILES)).rejects.toBeInstanceOf(LocalGateUnsupportedError);
|
|
236
|
+
await expect(runOpLocally(config, activities, PROFILES)).rejects.toThrow(/--temporal/);
|
|
237
|
+
expect(ran).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("findGate locates a gate in phases or onFailure", () => {
|
|
241
|
+
expect(findGate(op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "a" }] }] }))).toBeUndefined();
|
|
242
|
+
const gated = findGate(op({
|
|
243
|
+
phases: [{ name: "P", steps: [{ kind: "gate", signalName: "g" }] }],
|
|
244
|
+
}));
|
|
245
|
+
expect(gated?.signalName).toBe("g");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { OpRunResult } from "./local-executor";
|
|
3
|
+
import { renderHuman, renderJson } from "./local-output";
|
|
4
|
+
|
|
5
|
+
const RESULT: OpRunResult = {
|
|
6
|
+
op: "hello",
|
|
7
|
+
totalMs: 100,
|
|
8
|
+
ok: true,
|
|
9
|
+
records: [
|
|
10
|
+
{ phase: "Greet", fn: "shellCmd", args: { cmd: "echo hello from chant" }, status: "ok", durationMs: 42 },
|
|
11
|
+
{ phase: "Check", fn: "lifecycleDiff", args: { env: "prod" }, status: "ok", durationMs: 1200,
|
|
12
|
+
outcome: { name: "Drift", value: false } },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("renderHuman", () => {
|
|
17
|
+
test("renders phase banners, step lines, outcomes, and a summary", () => {
|
|
18
|
+
const lines: string[] = [];
|
|
19
|
+
renderHuman(RESULT, (l) => lines.push(l));
|
|
20
|
+
const out = lines.join("\n");
|
|
21
|
+
expect(out).toContain("[phase] Greet");
|
|
22
|
+
expect(out).toContain("✓ shellCmd(cmd=echo hello from chant) 42ms");
|
|
23
|
+
expect(out).toContain("[phase] Check");
|
|
24
|
+
expect(out).toContain("[outcome] Drift=false");
|
|
25
|
+
expect(out).toContain("✓ lifecycleDiff(env=prod) 1.2s");
|
|
26
|
+
expect(out).toContain('Op "hello" completed in 0.1s');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("renders failures with ✗ and the error", () => {
|
|
30
|
+
const failed: OpRunResult = {
|
|
31
|
+
op: "deploy", totalMs: 50, ok: false,
|
|
32
|
+
records: [{ phase: "Apply", fn: "kubectlApply", args: { manifest: "x.yaml" }, status: "fail", durationMs: 10, error: "boom" }],
|
|
33
|
+
};
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
renderHuman(failed, (l) => lines.push(l));
|
|
36
|
+
const out = lines.join("\n");
|
|
37
|
+
expect(out).toContain("✗ kubectlApply(manifest=x.yaml)");
|
|
38
|
+
expect(out).toContain("boom");
|
|
39
|
+
expect(out).toContain('Op "deploy" failed');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("renderJson", () => {
|
|
44
|
+
test("prints valid JSON parseable back to OpRunResult", () => {
|
|
45
|
+
const lines: string[] = [];
|
|
46
|
+
renderJson(RESULT, (l) => lines.push(l));
|
|
47
|
+
expect(lines).toHaveLength(1);
|
|
48
|
+
const parsed = JSON.parse(lines[0]) as OpRunResult;
|
|
49
|
+
expect(parsed.op).toBe("hello");
|
|
50
|
+
expect(parsed.ok).toBe(true);
|
|
51
|
+
expect(parsed.records).toHaveLength(2);
|
|
52
|
+
expect(parsed.records[1].outcome).toEqual({ name: "Drift", value: false });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderers for local Op execution. Both consume the same `OpRunResult`:
|
|
3
|
+
* `renderHuman` is the default (logs to stderr), `renderJson` prints the
|
|
4
|
+
* machine-readable record array to stdout and nothing else.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { OpRunResult, StepRecord } from "./local-executor";
|
|
8
|
+
|
|
9
|
+
type Writer = (line: string) => void;
|
|
10
|
+
|
|
11
|
+
const stderr: Writer = (line) => process.stderr.write(line + "\n");
|
|
12
|
+
const stdout: Writer = (line) => process.stdout.write(line + "\n");
|
|
13
|
+
|
|
14
|
+
function formatArgs(args: Record<string, unknown>): string {
|
|
15
|
+
return Object.entries(args)
|
|
16
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
17
|
+
.join(", ");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatDuration(ms: number): string {
|
|
21
|
+
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render a run result as human-readable progress. Defaults to stderr so stdout
|
|
26
|
+
* stays clean for piping (the `--json` renderer owns stdout).
|
|
27
|
+
*/
|
|
28
|
+
export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
|
|
29
|
+
let currentPhase: string | undefined;
|
|
30
|
+
for (const record of result.records) {
|
|
31
|
+
if (record.phase !== currentPhase) {
|
|
32
|
+
currentPhase = record.phase;
|
|
33
|
+
write(`[phase] ${currentPhase}`);
|
|
34
|
+
}
|
|
35
|
+
const mark = record.status === "ok" ? "✓" : record.status === "fail" ? "✗" : "•";
|
|
36
|
+
const call = `${record.fn}(${formatArgs(record.args)})`;
|
|
37
|
+
if (record.status === "skipped") {
|
|
38
|
+
write(` ${mark} ${call} skipped`);
|
|
39
|
+
} else {
|
|
40
|
+
write(` ${mark} ${call} ${formatDuration(record.durationMs)}`);
|
|
41
|
+
}
|
|
42
|
+
if (record.outcome) {
|
|
43
|
+
write(` [outcome] ${record.outcome.name}=${String(record.outcome.value)}`);
|
|
44
|
+
}
|
|
45
|
+
if (record.error) {
|
|
46
|
+
write(` ${record.error}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const total = `${(result.totalMs / 1000).toFixed(1)}s`;
|
|
51
|
+
if (result.ok) {
|
|
52
|
+
write(`Op "${result.op}" completed in ${total}`);
|
|
53
|
+
} else {
|
|
54
|
+
write(`Op "${result.op}" failed after ${total}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Render a run result as JSON on stdout (and nothing else on stdout). */
|
|
59
|
+
export function renderJson(result: OpRunResult, write: Writer = stdout): void {
|
|
60
|
+
write(JSON.stringify(result));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type { OpRunResult, StepRecord };
|
package/src/op/op.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
6
6
|
import { Op, phase, activity, gate, build, kubectlApply, helmInstall,
|
|
7
|
-
waitForStack, gitlabPipeline,
|
|
7
|
+
waitForStack, gitlabPipeline, lifecycleSnapshot, shell, teardown } from "./builders";
|
|
8
8
|
import { DECLARABLE_MARKER, type Declarable } from "../declarable";
|
|
9
9
|
|
|
10
10
|
// ── Op() ──────────────────────────────────────────────────────────────────────
|
|
@@ -177,9 +177,9 @@ describe("pre-built shortcuts", () => {
|
|
|
177
177
|
expect(a.profile).toBe("longInfra");
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
it("
|
|
181
|
-
const a =
|
|
182
|
-
expect(a.fn).toBe("
|
|
180
|
+
it("lifecycleSnapshot() produces lifecycleSnapshot activity with env arg", () => {
|
|
181
|
+
const a = lifecycleSnapshot("prod");
|
|
182
|
+
expect(a.fn).toBe("lifecycleSnapshot");
|
|
183
183
|
expect(a.args?.env).toBe("prod");
|
|
184
184
|
expect("profile" in a).toBe(false);
|
|
185
185
|
});
|