@intentius/chant 0.50.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +57 -0
- package/dist/cli/handlers/op-progress.d.ts.map +1 -0
- package/dist/cli/handlers/run-client.d.ts +21 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run-report.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +22 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +33 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/run-progress.d.ts +7 -5
- package/dist/components/run-progress.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +77 -0
- package/dist/lifecycle/assert-live.d.ts.map +1 -0
- package/dist/lifecycle/change-set.d.ts +17 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/disruption.d.ts +96 -0
- package/dist/lifecycle/disruption.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +2 -0
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +7 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/testing.d.ts +23 -2
- package/dist/testing.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/lifecycle.test.ts +90 -0
- package/src/cli/handlers/lifecycle.ts +18 -1
- package/src/cli/handlers/op-progress.test.ts +202 -0
- package/src/cli/handlers/op-progress.ts +192 -0
- package/src/cli/handlers/run-client.test.ts +82 -0
- package/src/cli/handlers/run-client.ts +85 -2
- package/src/cli/handlers/run-report.test.ts +62 -0
- package/src/cli/handlers/run-report.ts +20 -58
- package/src/cli/handlers/run.test.ts +144 -0
- package/src/cli/handlers/run.ts +40 -18
- package/src/cli/handlers/search-drift.test.ts +263 -0
- package/src/cli/handlers/search.ts +150 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/mcp/op-tools.ts +17 -6
- package/src/cli/mcp/resource-handlers.ts +13 -5
- package/src/cli/registry.ts +33 -1
- package/src/components/run-progress.ts +9 -5
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/assert-live.test.ts +125 -0
- package/src/lifecycle/assert-live.ts +154 -0
- package/src/lifecycle/change-set.ts +35 -3
- package/src/lifecycle/disruption.test.ts +186 -0
- package/src/lifecycle/disruption.ts +224 -0
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/replay.test.ts +25 -0
- package/src/lifecycle/replay.ts +11 -3
- package/src/op/local-executor.ts +7 -1
- package/src/op/local-output.ts +1 -1
- package/src/testing.test.ts +89 -2
- package/src/testing.ts +63 -3
|
@@ -8,7 +8,11 @@ import { discover } from "../../discovery/index";
|
|
|
8
8
|
|
|
9
9
|
import { observeResources } from "../../lifecycle/observe";
|
|
10
10
|
import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
|
|
11
|
+
import { diffLive, type LiveDiffResult } from "../../lifecycle/live-diff";
|
|
11
12
|
import type { LiveObservation } from "../../graph-ir";
|
|
13
|
+
import type { ResourceMetadata } from "../../lexicon";
|
|
14
|
+
import type { ObservationDepth } from "../../lifecycle/types";
|
|
15
|
+
import { formatUnobserved } from "../../observation";
|
|
12
16
|
import { loadChantConfig, matchesDeclaredEnvironment } from "../../config";
|
|
13
17
|
import { loadPlugins, resolveProjectLexicons } from "../plugins";
|
|
14
18
|
import { formatError, formatWarning } from "../format";
|
|
@@ -56,6 +60,31 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
56
60
|
}
|
|
57
61
|
const show = parseShow(args);
|
|
58
62
|
|
|
63
|
+
// Query-scoped drift (#1268): --check-live checks a snapshot answer against
|
|
64
|
+
// a live read, --check-snapshot checks a live answer against the recorded
|
|
65
|
+
// snapshot — the direction each needs is the source it does NOT already have.
|
|
66
|
+
if (args.checkLive && !args.at) {
|
|
67
|
+
console.error(formatError({
|
|
68
|
+
message: "chant search --check-live needs --at <ref>",
|
|
69
|
+
hint: "answer from a snapshot and check the matched rows against a live read: --at latest --check-live --env <name>",
|
|
70
|
+
}));
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
if (args.checkSnapshot && !args.live) {
|
|
74
|
+
console.error(formatError({
|
|
75
|
+
message: "chant search --check-snapshot needs --live",
|
|
76
|
+
hint: "answer live and check the matched rows against the recorded snapshot: --live --check-snapshot --env <name>",
|
|
77
|
+
}));
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
if (args.failOnDrift && !args.checkLive && !args.checkSnapshot) {
|
|
81
|
+
console.error(formatError({
|
|
82
|
+
message: "chant search --fail-on-drift needs --check-live or --check-snapshot",
|
|
83
|
+
hint: "it fails the check those flags run, so it is meaningless without one",
|
|
84
|
+
}));
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
const projectPath = resolve(".");
|
|
60
89
|
const { config } = await loadChantConfig(projectPath);
|
|
61
90
|
|
|
@@ -73,6 +102,14 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
73
102
|
let ambientKinds: string[] = [];
|
|
74
103
|
// Set only on a replay: whether the recording itself holds ambient resources.
|
|
75
104
|
let replayAmbient: { recordedAmbient: boolean } | undefined;
|
|
105
|
+
// Query-scoped drift (#1268): the OTHER observation, read only when
|
|
106
|
+
// --check-live/--check-snapshot asked for one — the declared canvas to
|
|
107
|
+
// classify against, and whether the exit code should reflect what it found.
|
|
108
|
+
let checkObservations: LiveObservation[] | undefined;
|
|
109
|
+
let primaryObservations: LiveObservation[] | undefined;
|
|
110
|
+
let declaredForDrift: GraphIR | undefined;
|
|
111
|
+
let snapshotDepth: ObservationDepth | undefined;
|
|
112
|
+
let driftFailure = false;
|
|
76
113
|
if (args.live || args.at) {
|
|
77
114
|
const environment = args.env;
|
|
78
115
|
if (!environment) {
|
|
@@ -126,6 +163,21 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
126
163
|
),
|
|
127
164
|
};
|
|
128
165
|
source = { kind: "snapshot", commit: replay.commit, timestamp: replay.timestamp };
|
|
166
|
+
snapshotDepth = replay.depth;
|
|
167
|
+
if (args.checkLive) {
|
|
168
|
+
// A fresh live read, additional to the snapshot the answer itself came
|
|
169
|
+
// from — this is what the matched rows get checked against.
|
|
170
|
+
const liveCheck = await observeResources(environment, observing, buildResult, {
|
|
171
|
+
owned: true,
|
|
172
|
+
stacks,
|
|
173
|
+
ambient: args.ambient === true,
|
|
174
|
+
});
|
|
175
|
+
for (const e of liveCheck.errors) {
|
|
176
|
+
liveFailures.push(e);
|
|
177
|
+
console.error(formatError({ message: `live read failed — ${e}` }));
|
|
178
|
+
}
|
|
179
|
+
checkObservations = liveCheck.observations;
|
|
180
|
+
}
|
|
129
181
|
} else {
|
|
130
182
|
const observed = await observeResources(environment, observing, buildResult, {
|
|
131
183
|
owned: true,
|
|
@@ -142,7 +194,22 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
142
194
|
observations = observed.observations;
|
|
143
195
|
liveNotes = observed.notes ?? [];
|
|
144
196
|
source = { kind: "live" };
|
|
197
|
+
if (args.checkSnapshot) {
|
|
198
|
+
// The reverse direction: answer live, check the matched rows against
|
|
199
|
+
// the most recently recorded snapshot. Missing entirely is not a
|
|
200
|
+
// failure of THIS command — the live answer already stands — so it is
|
|
201
|
+
// a note, not an error.
|
|
202
|
+
const scoped = new Set(stacks.filter((st) => st.src).map((st) => st.name));
|
|
203
|
+
const checkReplay = await replaySnapshots(environment, "latest", scoped);
|
|
204
|
+
if ("error" in checkReplay) {
|
|
205
|
+
console.error(formatWarning({ message: `--check-snapshot: ${checkReplay.error}` }));
|
|
206
|
+
} else {
|
|
207
|
+
checkObservations = checkReplay.observations;
|
|
208
|
+
snapshotDepth = checkReplay.depth;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
145
211
|
}
|
|
212
|
+
primaryObservations = observations;
|
|
146
213
|
let live = buildLiveGraphIr(observations);
|
|
147
214
|
// Containment edges, kept aside until after the overlay (see below).
|
|
148
215
|
let containmentEdges: IREdge[] = [];
|
|
@@ -206,6 +273,7 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
206
273
|
stacks.length > 0
|
|
207
274
|
? await buildDeclaredPerStack(stacks, projectPath)
|
|
208
275
|
: buildGraphIr((await discover(resolve(args.src ?? config.sourceDir ?? "."))).entities, projectPath);
|
|
276
|
+
declaredForDrift = declared;
|
|
209
277
|
// Carry the NOT-OBSERVED half of the tri-state (#1089) onto the rows, so a
|
|
210
278
|
// declared entity nobody could read is painted `_unobserved` and a row can
|
|
211
279
|
// say so instead of printing blank where a physical id would go (#1263).
|
|
@@ -279,6 +347,29 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
279
347
|
// Qualifies the provenance line, so it sits with it: one line per distinct
|
|
280
348
|
// note for the whole run, not one per stack, and after the rows (#1265).
|
|
281
349
|
for (const n of liveNotes) console.error(formatWarning({ message: n }));
|
|
350
|
+
// Query-scoped drift (#1268): the matched rows, and only the matched rows,
|
|
351
|
+
// checked against the observation the primary answer did NOT use. Scoping
|
|
352
|
+
// both sides of diffLive() to the matched ids is what keeps a query-scoped-
|
|
353
|
+
// out resource from reading as `missing` or `unobserved` — it was never
|
|
354
|
+
// asked about, which is a third thing from both.
|
|
355
|
+
if (checkObservations && declaredForDrift && primaryObservations) {
|
|
356
|
+
const declaredIds = new Set(declaredForDrift.nodes.map((n) => n.id));
|
|
357
|
+
const matchedIds = new Set(matches.map((n) => n.id));
|
|
358
|
+
const nowObservations = args.checkLive ? checkObservations : primaryObservations;
|
|
359
|
+
const thenObservations = args.checkLive ? primaryObservations : checkObservations;
|
|
360
|
+
const driftCount = renderDrift(
|
|
361
|
+
diffLive({
|
|
362
|
+
declared: new Set([...matchedIds].filter((id) => declaredIds.has(id))),
|
|
363
|
+
observedNow: pickResources(mergeResources(nowObservations), matchedIds),
|
|
364
|
+
observedThen: pickResources(mergeResources(thenObservations), matchedIds),
|
|
365
|
+
unobserved: pickResources(collectUnobserved(nowObservations), matchedIds),
|
|
366
|
+
}),
|
|
367
|
+
matches.length,
|
|
368
|
+
args.checkLive ? "live" : "snapshot",
|
|
369
|
+
snapshotDepth,
|
|
370
|
+
);
|
|
371
|
+
if (args.failOnDrift && driftCount > 0) driftFailure = true;
|
|
372
|
+
}
|
|
282
373
|
ambientHint(matches, ambientKinds, args.ambient === true, replayAmbient);
|
|
283
374
|
showMiss(matches, show);
|
|
284
375
|
regionSpread(terms, matches, show);
|
|
@@ -297,6 +388,7 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
297
388
|
}));
|
|
298
389
|
return 1;
|
|
299
390
|
}
|
|
391
|
+
if (driftFailure) return 1;
|
|
300
392
|
return 0;
|
|
301
393
|
}
|
|
302
394
|
|
|
@@ -463,6 +555,63 @@ function provenance(matches: IRNode[], source: AnswerSource, recorded?: string,
|
|
|
463
555
|
console.log(`— observed from snapshot${at}${taken} · bound ${bound}/${matches.length}`);
|
|
464
556
|
}
|
|
465
557
|
|
|
558
|
+
/** Union `LiveObservation.resources` across lexicons into one name-keyed map. */
|
|
559
|
+
function mergeResources(observations: LiveObservation[]): Record<string, ResourceMetadata> {
|
|
560
|
+
const out: Record<string, ResourceMetadata> = {};
|
|
561
|
+
for (const o of observations) Object.assign(out, o.resources);
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Restrict a name-keyed map to the matched ids — the whole of "query-scoped". */
|
|
566
|
+
function pickResources<T>(map: Record<string, T>, ids: Set<string>): Record<string, T> {
|
|
567
|
+
const out: Record<string, T> = {};
|
|
568
|
+
for (const id of ids) if (id in map) out[id] = map[id];
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Render the query-scoped drift check (#1268) — one line per matched resource
|
|
574
|
+
* whose {@link diffLive} verdict is not `unchanged`, then a summary.
|
|
575
|
+
*
|
|
576
|
+
* Reuses `diffLive` unmodified, so every category means exactly what it means
|
|
577
|
+
* in `lifecycle diff --live`: `missing`/`orphan`/`disappeared`/`drifted` count
|
|
578
|
+
* as drift, the same set `lifecycle diff` totals; `newlyObserved` and runtime
|
|
579
|
+
* children are reported but do not, and `unobserved` — a read that could not
|
|
580
|
+
* look — stays distinct from both, the same #1089 tri-state everywhere else.
|
|
581
|
+
*/
|
|
582
|
+
function renderDrift(
|
|
583
|
+
diff: LiveDiffResult,
|
|
584
|
+
checked: number,
|
|
585
|
+
direction: "live" | "snapshot",
|
|
586
|
+
depth?: ObservationDepth,
|
|
587
|
+
): number {
|
|
588
|
+
for (const name of diff.missing) console.log(`⚠ ${name} — missing (declared, not found by this check)`);
|
|
589
|
+
for (const name of diff.orphan) console.log(`⚠ ${name} — orphan (observed, not declared)`);
|
|
590
|
+
for (const name of diff.disappeared) console.log(`⚠ ${name} — disappeared since the recorded snapshot`);
|
|
591
|
+
for (const drift of diff.driftedSinceSnapshot) {
|
|
592
|
+
for (const change of drift.changes) {
|
|
593
|
+
console.log(`⚠ ${drift.name} ${change.path}: ${formatValue(change.oldValue)} → ${formatValue(change.newValue)} — drifted`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
for (const name of diff.newlyObserved) console.log(` · ${name} — newly observed since the recorded snapshot`);
|
|
597
|
+
for (const r of diff.runtimeChildren) console.log(` · ${r.name} (${r.type}) — runtime, owned by ${r.owner}`);
|
|
598
|
+
for (const u of diff.unobserved) console.log(` ? ${formatUnobserved(u.name, u)}`);
|
|
599
|
+
const driftCount = diff.missing.length + diff.orphan.length + diff.disappeared.length + diff.driftedSinceSnapshot.length;
|
|
600
|
+
const label = direction === "live" ? "a live read" : "the recorded snapshot";
|
|
601
|
+
const depthNote = depth === "deep" ? " · snapshot recorded at deep depth, compared here at identity" : "";
|
|
602
|
+
console.log(
|
|
603
|
+
`— checked against ${label} · ${driftCount > 0 ? `${driftCount} of ${checked} matched drifted` : `no drift across ${checked} matched`}${depthNote}`,
|
|
604
|
+
);
|
|
605
|
+
return driftCount;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function formatValue(v: unknown): string {
|
|
609
|
+
if (v === undefined) return "<unset>";
|
|
610
|
+
if (typeof v === "string") return v.length > 60 ? v.slice(0, 57) + "..." : v;
|
|
611
|
+
const json = JSON.stringify(v);
|
|
612
|
+
return json.length > 60 ? json.slice(0, 57) + "..." : json;
|
|
613
|
+
}
|
|
614
|
+
|
|
466
615
|
/**
|
|
467
616
|
* Name the facts chant computed for the kinds in this result that the query did not use.
|
|
468
617
|
*
|
|
@@ -815,4 +964,4 @@ function formatRow(n: IRNode, show: string[]): string {
|
|
|
815
964
|
}
|
|
816
965
|
|
|
817
966
|
/** Internals exposed for unit tests. */
|
|
818
|
-
export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance };
|
|
967
|
+
export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance, renderDrift, mergeResources, pickResources };
|
package/src/cli/main.ts
CHANGED
|
@@ -83,6 +83,9 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
83
83
|
"--yes",
|
|
84
84
|
"--confirm-prod",
|
|
85
85
|
"--once",
|
|
86
|
+
"--check-live",
|
|
87
|
+
"--check-snapshot",
|
|
88
|
+
"--fail-on-drift",
|
|
86
89
|
]);
|
|
87
90
|
|
|
88
91
|
/**
|
|
@@ -305,6 +308,12 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
305
308
|
result.at = args[++i];
|
|
306
309
|
} else if (arg === "--ambient") {
|
|
307
310
|
result.ambient = true;
|
|
311
|
+
} else if (arg === "--check-live") {
|
|
312
|
+
result.checkLive = true;
|
|
313
|
+
} else if (arg === "--check-snapshot") {
|
|
314
|
+
result.checkSnapshot = true;
|
|
315
|
+
} else if (arg === "--fail-on-drift") {
|
|
316
|
+
result.failOnDrift = true;
|
|
308
317
|
} else if (arg === "--run-examples") {
|
|
309
318
|
result.runExamples = true;
|
|
310
319
|
} else if (arg === "--pinned-digest") {
|
package/src/cli/mcp/op-tools.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { discoverOps } from "../../op/discover";
|
|
3
3
|
import { makeTemporalClient } from "../handlers/run";
|
|
4
|
-
import { resolveWorkflowId } from "../handlers/run-client";
|
|
4
|
+
import { resolveWorkflowId, fetchNormalizedHistory } from "../handlers/run-client";
|
|
5
5
|
import { generateReport } from "../handlers/run-report";
|
|
6
|
+
import { extractStepRecords, countActivities, queryGateState } from "../handlers/op-progress";
|
|
6
7
|
import type { ToolRegistration } from "./lifecycle-tools";
|
|
7
8
|
|
|
8
9
|
function workflowFnName(opName: string): string {
|
|
@@ -117,14 +118,22 @@ export function createOpStatusTool(): ToolRegistration {
|
|
|
117
118
|
const name = params.name as string;
|
|
118
119
|
const profile = params.profile as string | undefined;
|
|
119
120
|
|
|
121
|
+
const { ops } = await discoverOps();
|
|
122
|
+
const config = ops.get(name)?.config;
|
|
123
|
+
|
|
120
124
|
const { client } = await makeTemporalClient(profile, resolve("."));
|
|
121
125
|
const handle = client.workflow.getHandle(resolveWorkflowId(name));
|
|
122
126
|
const desc = await handle.describe();
|
|
123
|
-
const history = await handle
|
|
127
|
+
const history = await fetchNormalizedHistory(handle);
|
|
124
128
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
const { completed: activitiesCompleted, scheduled: activitiesScheduled } = countActivities(history);
|
|
130
|
+
// Per-phase progress (#1676) — the same StepRecord shape `chant run
|
|
131
|
+
// <name> --json` uses locally, so a consumer renders one way
|
|
132
|
+
// regardless of executor. Only buildable when the Op's config was
|
|
133
|
+
// discoverable (a *.op.ts file on disk); absent otherwise rather than
|
|
134
|
+
// guessed at.
|
|
135
|
+
const progress = config ? extractStepRecords(config, history, { final: Boolean(desc.closeTime) }) : undefined;
|
|
136
|
+
const gate = await queryGateState(handle);
|
|
128
137
|
|
|
129
138
|
return {
|
|
130
139
|
workflowId: desc.workflowId,
|
|
@@ -135,6 +144,8 @@ export function createOpStatusTool(): ToolRegistration {
|
|
|
135
144
|
taskQueue: desc.taskQueue,
|
|
136
145
|
activitiesCompleted,
|
|
137
146
|
activitiesScheduled,
|
|
147
|
+
...(progress ? { progress } : {}),
|
|
148
|
+
gate: gate ?? null,
|
|
138
149
|
};
|
|
139
150
|
},
|
|
140
151
|
};
|
|
@@ -196,7 +207,7 @@ export function createOpReportTool(): ToolRegistration {
|
|
|
196
207
|
const { client } = await makeTemporalClient(profile, resolve("."));
|
|
197
208
|
const handle = client.workflow.getHandle(resolveWorkflowId(name));
|
|
198
209
|
const desc = await handle.describe();
|
|
199
|
-
const history = await handle
|
|
210
|
+
const history = await fetchNormalizedHistory(handle);
|
|
200
211
|
|
|
201
212
|
return generateReport(name, config, desc, history);
|
|
202
213
|
},
|
|
@@ -5,7 +5,8 @@ import { getContext } from "./resources/context";
|
|
|
5
5
|
import { readSnapshot, readEnvironmentSnapshots } from "../../lifecycle/git";
|
|
6
6
|
import { discoverOps } from "../../op/discover";
|
|
7
7
|
import { makeTemporalClient } from "../handlers/run";
|
|
8
|
-
import { resolveWorkflowId } from "../handlers/run-client";
|
|
8
|
+
import { resolveWorkflowId, fetchNormalizedHistory } from "../handlers/run-client";
|
|
9
|
+
import { extractStepRecords, countActivities, queryGateState } from "../handlers/op-progress";
|
|
9
10
|
import { loadOkfBundle } from "../../okf-read";
|
|
10
11
|
import { loadChantConfigUpward, resolveKnowledgeDir } from "../../config";
|
|
11
12
|
|
|
@@ -177,11 +178,16 @@ export async function handleResourcesRead(
|
|
|
177
178
|
if (uri.startsWith("chant://ops/") && uri.endsWith("/runs/latest")) {
|
|
178
179
|
const name = uri.replace("chant://ops/", "").replace("/runs/latest", "");
|
|
179
180
|
try {
|
|
181
|
+
const { ops } = await discoverOps();
|
|
182
|
+
const config = ops.get(name)?.config;
|
|
183
|
+
|
|
180
184
|
const { client } = await makeTemporalClient(undefined, resolve("."));
|
|
181
185
|
const handle = client.workflow.getHandle(resolveWorkflowId(name));
|
|
182
186
|
const desc = await handle.describe();
|
|
183
|
-
const history = await handle
|
|
184
|
-
const
|
|
187
|
+
const history = await fetchNormalizedHistory(handle);
|
|
188
|
+
const { completed: activitiesCompleted, scheduled: activitiesScheduled } = countActivities(history);
|
|
189
|
+
const progress = config ? extractStepRecords(config, history, { final: Boolean(desc.closeTime) }) : undefined;
|
|
190
|
+
const gate = await queryGateState(handle);
|
|
185
191
|
const result = {
|
|
186
192
|
workflowId: desc.workflowId,
|
|
187
193
|
runId: desc.runId,
|
|
@@ -189,8 +195,10 @@ export async function handleResourcesRead(
|
|
|
189
195
|
startTime: desc.startTime,
|
|
190
196
|
closeTime: desc.closeTime ?? null,
|
|
191
197
|
taskQueue: desc.taskQueue,
|
|
192
|
-
activitiesCompleted
|
|
193
|
-
activitiesScheduled
|
|
198
|
+
activitiesCompleted,
|
|
199
|
+
activitiesScheduled,
|
|
200
|
+
...(progress ? { progress } : {}),
|
|
201
|
+
gate: gate ?? null,
|
|
194
202
|
};
|
|
195
203
|
return {
|
|
196
204
|
contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }],
|
package/src/cli/registry.ts
CHANGED
|
@@ -26,7 +26,20 @@ export interface ParsedArgs {
|
|
|
26
26
|
temporal?: boolean;
|
|
27
27
|
/** `chant run` — emit the structured OpRunResult as JSON on stdout. */
|
|
28
28
|
json?: boolean;
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* `chant run --components <name|all> --progress-json` (local executor) —
|
|
31
|
+
* stream one NDJSON `RunProgressEvent` (../../components/run-progress.ts)
|
|
32
|
+
* per line to stdout while the run executes, so a consumer can render live
|
|
33
|
+
* wave/component/phase/step progress instead of tailing raw logs.
|
|
34
|
+
*
|
|
35
|
+
* `chant run <name> --temporal --progress-json` (chant #1676) — the same
|
|
36
|
+
* flag on an Op's durable path streams one NDJSON `StepRecord`
|
|
37
|
+
* (../../op/local-executor.ts, reconstructed from workflow history by
|
|
38
|
+
* ../handlers/op-progress.ts) per settled step instead.
|
|
39
|
+
*
|
|
40
|
+
* Both are purely additive: run semantics, ordering, and exit code are
|
|
41
|
+
* unchanged; omitted (undefined, not false) when the flag isn't passed.
|
|
42
|
+
*/
|
|
30
43
|
progressJson?: boolean;
|
|
31
44
|
live: boolean;
|
|
32
45
|
/** `chant migrate --from <name>` (default "github") */
|
|
@@ -185,6 +198,25 @@ export interface ParsedArgs {
|
|
|
185
198
|
* what is declared, which is a broader read and a different claim.
|
|
186
199
|
*/
|
|
187
200
|
ambient?: boolean;
|
|
201
|
+
/**
|
|
202
|
+
* `chant search "<q>" --at <ref> --check-live --env <name>` (#1268) —
|
|
203
|
+
* additionally read the estate live and diff the matched rows against the
|
|
204
|
+
* snapshot the answer came from, reusing `diffLive` (the same engine
|
|
205
|
+
* `lifecycle diff --live` uses) scoped to just those rows. Requires `--at`.
|
|
206
|
+
*/
|
|
207
|
+
checkLive?: boolean;
|
|
208
|
+
/**
|
|
209
|
+
* `chant search "<q>" --live --check-snapshot --env <name>` (#1268) — the
|
|
210
|
+
* reverse of `--check-live`: answer live, diff the matched rows against the
|
|
211
|
+
* most recently recorded snapshot. Requires `--live`.
|
|
212
|
+
*/
|
|
213
|
+
checkSnapshot?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* `chant search "<q>" --check-live|--check-snapshot --fail-on-drift`
|
|
216
|
+
* (#1268) — exit non-zero when the scoped check finds drift, so it is usable
|
|
217
|
+
* as a CI gate. Meaningless without one of the two flags above.
|
|
218
|
+
*/
|
|
219
|
+
failOnDrift?: boolean;
|
|
188
220
|
|
|
189
221
|
/** `chant dev surface-diff --run-examples` — also run the example build harness */
|
|
190
222
|
runExamples?: boolean;
|
|
@@ -107,11 +107,15 @@ export type RunProgressSink = (event: RunProgressEvent) => void;
|
|
|
107
107
|
/**
|
|
108
108
|
* Build a sink that writes `JSON.stringify(event) + "\n"` to `write` (default:
|
|
109
109
|
* `process.stdout.write`), one line per event, as they happen — the
|
|
110
|
-
* `--progress-json` CLI wiring's sink (../cli/handlers/run.ts
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
110
|
+
* `--progress-json` CLI wiring's sink (../cli/handlers/run.ts, for both the
|
|
111
|
+
* `--components` driver's `RunProgressEvent`s and the Temporal Op path's
|
|
112
|
+
* `StepRecord`s, chant #1676). Kept separate from `driver-output.ts`'s
|
|
113
|
+
* end-of-run renderers: this emits *during* the run, one line at a time;
|
|
114
|
+
* `renderDriverJson`/`renderDriverHuman` render the completed
|
|
115
|
+
* `DriverRunResult` once, after the run finishes.
|
|
114
116
|
*/
|
|
115
|
-
export function ndjsonProgressSink
|
|
117
|
+
export function ndjsonProgressSink<T = RunProgressEvent>(
|
|
118
|
+
write: (chunk: string) => void = (s) => void process.stdout.write(s),
|
|
119
|
+
): (event: T) => void {
|
|
116
120
|
return (event) => write(JSON.stringify(event) + "\n");
|
|
117
121
|
}
|
package/src/lexicon.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { ReferenceCatalog } from "./graph-refs";
|
|
|
18
18
|
import type { IREdge } from "./graph-ir";
|
|
19
19
|
import type { DescribeResourcesResult, UnobservedReason } from "./observation";
|
|
20
20
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
21
|
+
import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
|
|
21
22
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
22
23
|
import type { CommandGroup } from "./cli/command-group";
|
|
23
24
|
|
|
@@ -44,6 +45,16 @@ export type {
|
|
|
44
45
|
UnobservedReason,
|
|
45
46
|
} from "./observation";
|
|
46
47
|
|
|
48
|
+
// Disruption classification (#1665), re-exported from the same entry so a
|
|
49
|
+
// lexicon's `classifyDisruption` types itself without a second import path.
|
|
50
|
+
// Runtime helpers live in `@intentius/chant/lifecycle/disruption`.
|
|
51
|
+
export type {
|
|
52
|
+
Disruption,
|
|
53
|
+
DisruptionQuery,
|
|
54
|
+
DisruptionVerdict,
|
|
55
|
+
DisruptionClassifier,
|
|
56
|
+
} from "./lifecycle/disruption";
|
|
57
|
+
|
|
47
58
|
// The deep observation contract (#1014), re-exported for the same reason: a
|
|
48
59
|
// lexicon authoring `observeResourcesDeep` + its pruning/ordering hooks types
|
|
49
60
|
// them from the same entry. Runtime helpers live in
|
|
@@ -878,6 +889,46 @@ export interface LexiconPlugin {
|
|
|
878
889
|
namespace?: string;
|
|
879
890
|
}): Promise<DescribeResourcesResult>;
|
|
880
891
|
|
|
892
|
+
/**
|
|
893
|
+
* Classify how much each pending `update` in a plan hurts (#1665) —
|
|
894
|
+
* in-place / rolling / replace / destroy, or `unknown`.
|
|
895
|
+
*
|
|
896
|
+
* `lifecycle plan` reports WHAT changes. What it costs to apply is a
|
|
897
|
+
* different question, and the answer is spec knowledge: CloudFormation's
|
|
898
|
+
* registry schema declares `createOnlyProperties` per type, Kubernetes' SSA
|
|
899
|
+
* schema knows which field changes roll a workload. Core owns neither, so it
|
|
900
|
+
* defines the vocabulary and does the reporting, and the lexicon that
|
|
901
|
+
* compiled the spec answers — the same division `postSynthChecks` draws for
|
|
902
|
+
* validation. A replacement rule hardcoded in core would be a rule the tool
|
|
903
|
+
* has to keep in step with a provider it does not generate from.
|
|
904
|
+
*
|
|
905
|
+
* Only the lexicon can answer, for a second reason: the `deltas` on an entry
|
|
906
|
+
* are paths into ITS OWN observation shape (`attributes.<key>` off
|
|
907
|
+
* {@link describeResources}), which core cannot map back onto spec property
|
|
908
|
+
* names without knowing how the reader named things.
|
|
909
|
+
*
|
|
910
|
+
* Called once per lexicon with that lexicon's `update` entries, before the
|
|
911
|
+
* plan merges the change sets. Expected to be a table lookup over compiled
|
|
912
|
+
* spec data — no live API call, no mutation. Returning a `Promise` is
|
|
913
|
+
* allowed, so a lexicon whose answer needs an await is not shut out.
|
|
914
|
+
*
|
|
915
|
+
* **Every degradation lands on `unknown`.** Return a partial map: a name
|
|
916
|
+
* absent from the result is `unknown`, which is the right answer when the
|
|
917
|
+
* spec does not say (a conditionally-immutable property, a type with no
|
|
918
|
+
* schema on record). Core also forces `unknown` when this method is absent,
|
|
919
|
+
* when it throws, and when it returns a level outside the vocabulary. There
|
|
920
|
+
* is no path by which a lexicon accidentally produces a confident
|
|
921
|
+
* `in-place` — that claim has to be made deliberately, and a consumer gating
|
|
922
|
+
* on `replace` can trust it for exactly that reason.
|
|
923
|
+
*
|
|
924
|
+
* Omit for lexicons with no replacement semantics to publish.
|
|
925
|
+
*/
|
|
926
|
+
classifyDisruption?(options: {
|
|
927
|
+
environment: string;
|
|
928
|
+
/** This lexicon's `update` entries — name, type, and the attribute deltas. */
|
|
929
|
+
changes: DisruptionQuery[];
|
|
930
|
+
}): Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
|
|
931
|
+
|
|
881
932
|
/**
|
|
882
933
|
* Report the undeclared resources this estate *depends on* (#1273), as
|
|
883
934
|
* opposed to the ones it manages.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createMockPlugin, staticObservation } from "@intentius/chant-test-utils";
|
|
3
|
+
import { observation } from "../observation";
|
|
4
|
+
import { assertLiveEntity, LiveAssertionError, UnobservedAssertionError } from "./assert-live";
|
|
5
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
6
|
+
|
|
7
|
+
const MARKER = { stack: "shop", env: "test-suite-abc123" };
|
|
8
|
+
|
|
9
|
+
const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
|
|
10
|
+
type: "Mock::Queue",
|
|
11
|
+
status: "READY",
|
|
12
|
+
marker: MARKER,
|
|
13
|
+
ownership: "owned",
|
|
14
|
+
...overrides,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const call = (opts: Partial<Parameters<typeof assertLiveEntity>[0]> = {}) =>
|
|
18
|
+
assertLiveEntity({
|
|
19
|
+
plugin: createMockPlugin(),
|
|
20
|
+
name: "taskQueue",
|
|
21
|
+
entityType: "Mock::Queue",
|
|
22
|
+
props: {},
|
|
23
|
+
buildOutput: "",
|
|
24
|
+
environment: MARKER.env,
|
|
25
|
+
marker: MARKER,
|
|
26
|
+
...opts,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("assertLiveEntity", () => {
|
|
30
|
+
test("resolves the metadata for an observed, marker-matched entity", async () => {
|
|
31
|
+
const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta() }) });
|
|
32
|
+
await expect(call({ plugin })).resolves.toEqual(meta());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("checks status when given, passes when it matches", async () => {
|
|
36
|
+
const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta({ status: "READY" }) }) });
|
|
37
|
+
await expect(call({ plugin, status: "READY" })).resolves.toEqual(meta());
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("throws naming the entity when observed absent", async () => {
|
|
41
|
+
const plugin = createMockPlugin({ describeResources: staticObservation({}) });
|
|
42
|
+
await expect(call({ plugin })).rejects.toThrow(LiveAssertionError);
|
|
43
|
+
await expect(call({ plugin })).rejects.toThrow(/taskQueue.*observed absent/s);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("throws UnobservedAssertionError, not a plain failure, when NOT-OBSERVED", async () => {
|
|
47
|
+
const plugin = createMockPlugin({
|
|
48
|
+
describeResources: staticObservation({}, { taskQueue: { reason: "no-credentials", type: "Mock::Queue" } }),
|
|
49
|
+
});
|
|
50
|
+
const err = await call({ plugin }).then(
|
|
51
|
+
() => undefined,
|
|
52
|
+
(e: unknown) => e,
|
|
53
|
+
);
|
|
54
|
+
expect(err).toBeInstanceOf(UnobservedAssertionError);
|
|
55
|
+
expect(err).not.toBeInstanceOf(LiveAssertionError);
|
|
56
|
+
expect((err as UnobservedAssertionError).reason).toBe("no-credentials");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a thrown describeResources degrades to NOT-OBSERVED read-failed, never a silent absence", async () => {
|
|
60
|
+
const plugin = createMockPlugin({
|
|
61
|
+
describeResources: async () => {
|
|
62
|
+
throw new Error("ECONNREFUSED");
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const err = await call({ plugin }).then(
|
|
66
|
+
() => undefined,
|
|
67
|
+
(e: unknown) => e,
|
|
68
|
+
);
|
|
69
|
+
expect(err).toBeInstanceOf(UnobservedAssertionError);
|
|
70
|
+
expect((err as UnobservedAssertionError).reason).toBe("read-failed");
|
|
71
|
+
expect((err as UnobservedAssertionError).detail).toContain("ECONNREFUSED");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a lexicon with no describeResources is NOT-OBSERVED, unsupported-kind", async () => {
|
|
75
|
+
const plugin = createMockPlugin();
|
|
76
|
+
const err = await call({ plugin }).then(
|
|
77
|
+
() => undefined,
|
|
78
|
+
(e: unknown) => e,
|
|
79
|
+
);
|
|
80
|
+
expect(err).toBeInstanceOf(UnobservedAssertionError);
|
|
81
|
+
expect((err as UnobservedAssertionError).reason).toBe("unsupported-kind");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws when the observed marker names a foreign stack", async () => {
|
|
85
|
+
const plugin = createMockPlugin({
|
|
86
|
+
describeResources: staticObservation({ taskQueue: meta({ marker: { stack: "other-stack", env: MARKER.env } }) }),
|
|
87
|
+
});
|
|
88
|
+
await expect(call({ plugin })).rejects.toThrow(/not this deploy's/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("throws when the observed marker names a foreign env — a same-named leftover from another run", async () => {
|
|
92
|
+
const plugin = createMockPlugin({
|
|
93
|
+
describeResources: staticObservation({ taskQueue: meta({ marker: { stack: MARKER.stack, env: "test-other-999999" } }) }),
|
|
94
|
+
});
|
|
95
|
+
await expect(call({ plugin })).rejects.toThrow(LiveAssertionError);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("throws when the entity is confirmed foreign (no marker, ownership foreign)", async () => {
|
|
99
|
+
const plugin = createMockPlugin({
|
|
100
|
+
describeResources: staticObservation({
|
|
101
|
+
taskQueue: meta({ marker: undefined, ownership: "foreign" }),
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
await expect(call({ plugin })).rejects.toThrow(/not this deploy's/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("passes through an entity whose lexicon has no marker channel (ownership unknown, no marker) rather than always failing", async () => {
|
|
108
|
+
const plugin = createMockPlugin({
|
|
109
|
+
describeResources: staticObservation({
|
|
110
|
+
taskQueue: meta({ marker: undefined, ownership: "unknown" }),
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
await expect(call({ plugin })).resolves.toEqual(meta({ marker: undefined, ownership: "unknown" }));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("throws a status mismatch after identity is confirmed", async () => {
|
|
117
|
+
const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta({ status: "PENDING" }) }) });
|
|
118
|
+
await expect(call({ plugin, status: "READY" })).rejects.toThrow(/status "PENDING"/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("uses the observation() envelope form identically to the bare-map form", async () => {
|
|
122
|
+
const plugin = createMockPlugin({ describeResources: async () => observation({ taskQueue: meta() }) });
|
|
123
|
+
await expect(call({ plugin })).resolves.toEqual(meta());
|
|
124
|
+
});
|
|
125
|
+
});
|