@intentius/chant 0.58.0 → 0.59.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/audit/core.d.ts +17 -1
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +15 -4
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +13 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts +0 -16
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +22 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +19 -4
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts +8 -2
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +1 -7
- package/dist/components/verbs/run-agent.d.ts.map +1 -1
- package/dist/detectLexicon.d.ts +13 -0
- package/dist/detectLexicon.d.ts.map +1 -1
- package/dist/lexicon.d.ts +170 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +9 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +62 -2
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/change-signal.d.ts +91 -0
- package/dist/op/change-signal.d.ts.map +1 -0
- package/dist/op/composites/apply-op.d.ts +7 -2
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +40 -0
- package/dist/op/gate-name.d.ts.map +1 -0
- package/dist/op/gate-summary.d.ts +43 -0
- package/dist/op/gate-summary.d.ts.map +1 -0
- package/dist/op/index.d.ts +6 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts +63 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/types.d.ts +18 -3
- package/dist/op/types.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts +26 -7
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve-provider.d.ts +28 -2
- package/dist/terraform/carve-provider.d.ts.map +1 -1
- package/dist/terraform/data-source-shape.d.ts +77 -0
- package/dist/terraform/data-source-shape.d.ts.map +1 -0
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/providers/kubernetes.d.ts +5 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +16 -4
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +8 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/core.ts +25 -3
- package/src/audit/discover.ts +122 -14
- package/src/cli/commands/audit.test.ts +5 -3
- package/src/cli/commands/audit.ts +5 -1
- package/src/cli/commands/build.test.ts +39 -1
- package/src/cli/commands/build.ts +14 -0
- package/src/cli/commands/carve-bridge.test.ts +237 -14
- package/src/cli/commands/carve-bridge.ts +21 -17
- package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
- package/src/cli/commands/lint.test.ts +146 -1
- package/src/cli/commands/lint.ts +82 -7
- package/src/cli/handlers/operator.ts +81 -0
- package/src/cli/handlers/run.test.ts +132 -3
- package/src/cli/handlers/run.ts +87 -3
- package/src/cli/main.test.ts +9 -11
- package/src/cli/main.ts +5 -27
- package/src/cli/plugins.test.ts +68 -2
- package/src/cli/plugins.ts +39 -0
- package/src/cli/registry.ts +14 -0
- package/src/components/README.md +2 -2
- package/src/components/__fixtures__/neo4j-fanout.json +1 -1
- package/src/components/cli-support.test.ts +18 -7
- package/src/components/cli-support.ts +8 -3
- package/src/components/component-schema.test.ts +18 -2
- package/src/components/component.schema.json +17 -4
- package/src/components/component.test.ts +2 -2
- package/src/components/component.ts +25 -5
- package/src/components/config-defaults.test.ts +2 -2
- package/src/components/driver.test.ts +20 -1
- package/src/components/driver.ts +12 -5
- package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
- package/src/components/verbs/run-agent.test.ts +19 -0
- package/src/components/verbs/run-agent.ts +1 -7
- package/src/detectLexicon.ts +18 -1
- package/src/discovery/fold-import.test.ts +1 -1
- package/src/fold/foldable-helpers.ts +1 -1
- package/src/graph-ops.test.ts +1 -1
- package/src/lexicon.ts +177 -3
- package/src/lifecycle/gate-ledger.ts +12 -1
- package/src/lint/pipeline-change-gate.test.ts +2 -2
- package/src/lint/rules/comp/comp.test.ts +26 -0
- package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
- package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
- package/src/op/activities/index.ts +2 -2
- package/src/op/activities/reconcile.test.ts +82 -2
- package/src/op/activities/reconcile.ts +169 -2
- package/src/op/builders.ts +3 -3
- package/src/op/change-signal.test.ts +117 -0
- package/src/op/change-signal.ts +169 -0
- package/src/op/composites/apply-op.ts +14 -4
- package/src/op/composites/composites.test.ts +17 -4
- package/src/op/composites/reconcile-op.ts +7 -4
- package/src/op/effect-step.test.ts +3 -3
- package/src/op/gate-name.test.ts +65 -0
- package/src/op/gate-name.ts +60 -0
- package/src/op/gate-summary.ts +84 -0
- package/src/op/index.ts +9 -2
- package/src/op/local-executor.test.ts +16 -1
- package/src/op/local-executor.ts +4 -3
- package/src/op/op-ir.test.ts +12 -1
- package/src/op/op-ir.ts +5 -3
- package/src/op/op-verb-class.test.ts +2 -2
- package/src/op/op.test.ts +2 -2
- package/src/op/operator.test.ts +368 -0
- package/src/op/operator.ts +141 -5
- package/src/op/runtimes/local.test.ts +1 -1
- package/src/op/types.ts +23 -3
- package/src/terraform/aws-resources.test.ts +13 -4
- package/src/terraform/bridge.test.ts +22 -9
- package/src/terraform/bridge.ts +89 -28
- package/src/terraform/carve-provider.ts +38 -2
- package/src/terraform/data-source-shape.ts +95 -0
- package/src/terraform/graph.ts +35 -7
- package/src/terraform/providers/kubernetes.ts +48 -2
- package/src/terraform/tier-map.ts +21 -6
- package/src/terraform/types.ts +8 -0
package/src/cli/plugins.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { loadPlugin, loadPlugins, resolveLexiconVersions } from "./plugins";
|
|
3
|
-
import { isLexiconPlugin } from "../lexicon";
|
|
2
|
+
import { collectChangeSubscribers, loadPlugin, loadPlugins, resolveLexiconVersions } from "./plugins";
|
|
3
|
+
import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
|
|
4
4
|
|
|
5
5
|
describe("loadPlugin", () => {
|
|
6
6
|
test("loads aws plugin with full LexiconPlugin interface", async () => {
|
|
@@ -61,3 +61,69 @@ describe("resolveLexiconVersions", () => {
|
|
|
61
61
|
expect(resolveLexiconVersions([])).toEqual({});
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
describe("collectChangeSubscribers (#1981)", () => {
|
|
66
|
+
function fakePlugin(name: string, withSeam: boolean): LexiconPlugin {
|
|
67
|
+
const plugin = {
|
|
68
|
+
name,
|
|
69
|
+
serializer: { name, serialize: () => "" },
|
|
70
|
+
generate: async () => {},
|
|
71
|
+
validate: async () => {},
|
|
72
|
+
coverage: async () => {},
|
|
73
|
+
package: async () => {},
|
|
74
|
+
} as unknown as LexiconPlugin;
|
|
75
|
+
if (withSeam) {
|
|
76
|
+
plugin.subscribeChanges = async () => ({ close: async () => {} });
|
|
77
|
+
}
|
|
78
|
+
return plugin;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const entitiesFor = (lexicon: string) =>
|
|
82
|
+
new Map([[lexicon, new Map([["web", { entityType: "K8s::Apps::Deployment", props: {} }]])]]);
|
|
83
|
+
|
|
84
|
+
test("a lexicon without the seam contributes nothing", () => {
|
|
85
|
+
expect(
|
|
86
|
+
collectChangeSubscribers([fakePlugin("aws", false)], {
|
|
87
|
+
environment: "prod",
|
|
88
|
+
entities: entitiesFor("aws"),
|
|
89
|
+
}),
|
|
90
|
+
).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a lexicon with the seam but no declared entities is skipped rather than handed an empty scope", () => {
|
|
94
|
+
expect(
|
|
95
|
+
collectChangeSubscribers([fakePlugin("k8s", true)], {
|
|
96
|
+
environment: "prod",
|
|
97
|
+
entities: new Map(),
|
|
98
|
+
}),
|
|
99
|
+
).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("binds the environment, cwd and this lexicon's own entity slice", async () => {
|
|
103
|
+
const plugin = fakePlugin("k8s", true);
|
|
104
|
+
let seen: Record<string, unknown> | undefined;
|
|
105
|
+
plugin.subscribeChanges = async (options) => {
|
|
106
|
+
seen = options as unknown as Record<string, unknown>;
|
|
107
|
+
return { close: async () => {} };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const entities = new Map([
|
|
111
|
+
["k8s", new Map([["web", { entityType: "K8s::Apps::Deployment", props: {} }]])],
|
|
112
|
+
["aws", new Map([["bucket", { entityType: "AWS::S3::Bucket", props: {} }]])],
|
|
113
|
+
]);
|
|
114
|
+
const subscribers = collectChangeSubscribers([plugin], { environment: "prod", cwd: "/proj", entities });
|
|
115
|
+
expect(subscribers).toHaveLength(1);
|
|
116
|
+
expect(subscribers[0].lexicon).toBe("k8s");
|
|
117
|
+
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const onChange = () => {};
|
|
120
|
+
const onError = () => {};
|
|
121
|
+
await subscribers[0].subscribe({ onChange, onError, signal: controller.signal });
|
|
122
|
+
expect(seen!.environment).toBe("prod");
|
|
123
|
+
expect(seen!.cwd).toBe("/proj");
|
|
124
|
+
expect(seen!.onChange).toBe(onChange);
|
|
125
|
+
expect(seen!.signal).toBe(controller.signal);
|
|
126
|
+
// This lexicon's slice only: aws's bucket is not in scope for k8s.
|
|
127
|
+
expect([...(seen!.entities as Map<string, unknown>).keys()]).toEqual(["web"]);
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/cli/plugins.ts
CHANGED
|
@@ -114,6 +114,45 @@ export function collectBuildRootContributors(
|
|
|
114
114
|
.map((plugin) => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Bind each loaded plugin's `subscribeChanges` seam (#1981) to one environment
|
|
119
|
+
* and its declared entities, producing the `ChangeSubscriber` list `chant
|
|
120
|
+
* operator`'s loop takes, in the same extract-then-thread shape
|
|
121
|
+
* {@link collectBuildRootContributors} uses, and for the same reason: core's
|
|
122
|
+
* operator must not import plugins.
|
|
123
|
+
*
|
|
124
|
+
* Plugins without the seam contribute nothing, so a project whose lexicons all
|
|
125
|
+
* lack it gets a loop that is exactly the timer it always was. `entities` is
|
|
126
|
+
* the per-lexicon slice of a build's declared entities, which is the bound on
|
|
127
|
+
* what a subscription may watch; a lexicon with no declared entities is
|
|
128
|
+
* skipped rather than handed an empty scope to widen.
|
|
129
|
+
*/
|
|
130
|
+
export function collectChangeSubscribers(
|
|
131
|
+
plugins: readonly LexiconPlugin[] | undefined,
|
|
132
|
+
options: {
|
|
133
|
+
environment: string;
|
|
134
|
+
cwd?: string;
|
|
135
|
+
/** Declared entities per lexicon name, from a build. */
|
|
136
|
+
entities: Map<string, Map<string, { entityType: string; props: Record<string, unknown> }>>;
|
|
137
|
+
},
|
|
138
|
+
): Array<import("../op/operator").ChangeSubscriber> {
|
|
139
|
+
return (plugins ?? [])
|
|
140
|
+
.filter((plugin) => typeof plugin.subscribeChanges === "function")
|
|
141
|
+
.filter((plugin) => (options.entities.get(plugin.name)?.size ?? 0) > 0)
|
|
142
|
+
.map((plugin) => ({
|
|
143
|
+
lexicon: plugin.name,
|
|
144
|
+
subscribe: (ctx) =>
|
|
145
|
+
plugin.subscribeChanges!({
|
|
146
|
+
environment: options.environment,
|
|
147
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
148
|
+
entities: options.entities.get(plugin.name),
|
|
149
|
+
onChange: ctx.onChange,
|
|
150
|
+
onError: ctx.onError,
|
|
151
|
+
signal: ctx.signal,
|
|
152
|
+
}),
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
117
156
|
/**
|
|
118
157
|
* Load plugins for all detected lexicon names.
|
|
119
158
|
* Calls `init()` on each plugin if present.
|
package/src/cli/registry.ts
CHANGED
|
@@ -40,6 +40,20 @@ export interface ParsedArgs {
|
|
|
40
40
|
profile?: string;
|
|
41
41
|
/** `chant run` — emit the structured OpRunResult as JSON on stdout. */
|
|
42
42
|
json?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* `chant run <op> --gated-exit <code>` (#2243) — the process exit code a
|
|
45
|
+
* run that stopped at an unapproved gate returns, instead of the default 3.
|
|
46
|
+
*
|
|
47
|
+
* Only the gated outcome is remapped. A run that fails still returns 1 and a
|
|
48
|
+
* run that completes still returns 0, so `--gated-exit 0` on a CI job means
|
|
49
|
+
* "a pending approval is not a broken build" and nothing more. The `--json`
|
|
50
|
+
* payload is untouched by it: the record still says `status: "gated"` and
|
|
51
|
+
* still carries the gate and the `chant approve` line, so a job that maps the
|
|
52
|
+
* code to success can still tell the two apart.
|
|
53
|
+
*
|
|
54
|
+
* Accepts 0-255 (a POSIX exit status). Anything else is refused by name.
|
|
55
|
+
*/
|
|
56
|
+
gatedExit?: number;
|
|
43
57
|
/**
|
|
44
58
|
* `chant run --components <name|all> --progress-json` (local executor) —
|
|
45
59
|
* stream one NDJSON `RunProgressEvent` (../../components/run-progress.ts)
|
package/src/components/README.md
CHANGED
|
@@ -50,7 +50,7 @@ adopting chant.
|
|
|
50
50
|
be a nested `Phase`, which is how fan-out (e.g. a Neo4j cluster seeding one
|
|
51
51
|
node then rolling through the rest) is expressed as composition rather than
|
|
52
52
|
orchestrator knowledge.
|
|
53
|
-
- **Gates** — a distinct step shape (`kind: "gate"`, `
|
|
53
|
+
- **Gates** — a distinct step shape (`kind: "gate"`, `gate`) for a human
|
|
54
54
|
approval, decided against the gate ledger: a run that reaches one with no
|
|
55
55
|
resolution stops there with status `gated` and the pending fact recorded.
|
|
56
56
|
- **Wiring reference forms**, all resolved by the graph, never by orchestrator
|
|
@@ -89,7 +89,7 @@ Lambda function. `component-schema.test.ts` validates every fixture against
|
|
|
89
89
|
`component.schema.json` with [ajv](https://ajv.js.org/) (`ajv/dist/2020` — the
|
|
90
90
|
draft 2020-12 build) and exercises the schema's negative cases (missing
|
|
91
91
|
required fields, malformed wiring references, an invalid archetype, a gate
|
|
92
|
-
|
|
92
|
+
naming neither `gate` nor its deprecated `signalName` spelling, and so on).
|
|
93
93
|
|
|
94
94
|
## Typed authoring form (`component.ts`, #560)
|
|
95
95
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"phase": "Node 1",
|
|
18
18
|
"steps": [
|
|
19
|
-
{ "kind": "gate", "
|
|
19
|
+
{ "kind": "gate", "gate": "approve-neo4j-node-1", "description": "Confirm the seed node is healthy before rolling to node 1", "timeout": "24h" },
|
|
20
20
|
{ "kind": "cfn-deploy", "template": "archive:neo4j-1.template.json" },
|
|
21
21
|
{ "kind": "code-deploy", "instance": 1, "revision": "@Seed.templateUri" },
|
|
22
22
|
{ "kind": "wait-cluster-healthy", "quorum": true }
|
|
@@ -428,9 +428,9 @@ describe("findComponentGate", () => {
|
|
|
428
428
|
const component: DriverComponent = {
|
|
429
429
|
name: "svc",
|
|
430
430
|
dependsOn: [],
|
|
431
|
-
deploy: [{ phase: "Approve", steps: [{ kind: "gate",
|
|
431
|
+
deploy: [{ phase: "Approve", steps: [{ kind: "gate", gate: "release-approval" }] }],
|
|
432
432
|
};
|
|
433
|
-
expect(findComponentGate(component)).toEqual({
|
|
433
|
+
expect(findComponentGate(component)).toEqual({ gate: "release-approval" });
|
|
434
434
|
});
|
|
435
435
|
|
|
436
436
|
test("finds a gate nested inside a fan-out phase", () => {
|
|
@@ -443,13 +443,13 @@ describe("findComponentGate", () => {
|
|
|
443
443
|
steps: [
|
|
444
444
|
{
|
|
445
445
|
phase: "instance-2",
|
|
446
|
-
steps: [{ kind: "gate",
|
|
446
|
+
steps: [{ kind: "gate", gate: "instance-2-approval" }],
|
|
447
447
|
},
|
|
448
448
|
],
|
|
449
449
|
},
|
|
450
450
|
],
|
|
451
451
|
};
|
|
452
|
-
expect(findComponentGate(component)?.
|
|
452
|
+
expect(findComponentGate(component)?.gate).toBe("instance-2-approval");
|
|
453
453
|
});
|
|
454
454
|
|
|
455
455
|
test("finds a gate in a component's rollback phases", () => {
|
|
@@ -457,9 +457,20 @@ describe("findComponentGate", () => {
|
|
|
457
457
|
name: "svc",
|
|
458
458
|
dependsOn: [],
|
|
459
459
|
deploy: [{ phase: "Apply", steps: [{ kind: "deploy-thing" }] }],
|
|
460
|
-
rollback: [{ phase: "Rollback", steps: [{ kind: "gate",
|
|
460
|
+
rollback: [{ phase: "Rollback", steps: [{ kind: "gate", gate: "rollback-approval" }] }],
|
|
461
|
+
};
|
|
462
|
+
expect(findComponentGate(component)?.gate).toBe("rollback-approval");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// #2202: the name comes back on `gate` whichever key the component spelled
|
|
466
|
+
// it with, so no caller has to know about the deprecated one.
|
|
467
|
+
test("normalizes a gate step's deprecated `signalName` key onto `gate`", () => {
|
|
468
|
+
const component = {
|
|
469
|
+
name: "svc",
|
|
470
|
+
dependsOn: [],
|
|
471
|
+
deploy: [{ phase: "Approve", steps: [{ kind: "gate", signalName: "release-approval" }] }],
|
|
461
472
|
};
|
|
462
|
-
expect(findComponentGate(component)
|
|
473
|
+
expect(findComponentGate(component)).toEqual({ gate: "release-approval" });
|
|
463
474
|
});
|
|
464
475
|
});
|
|
465
476
|
|
|
@@ -592,7 +603,7 @@ describe("runComponents", () => {
|
|
|
592
603
|
await writeFile(
|
|
593
604
|
join(testDir, "svc.component.ts"),
|
|
594
605
|
`export const svc = { name: "svc", dependsOn: [], deploy: [
|
|
595
|
-
{ phase: "Approve", steps: [{ kind: "gate",
|
|
606
|
+
{ phase: "Approve", steps: [{ kind: "gate", gate: "release-approval" }] },
|
|
596
607
|
{ phase: "Apply", steps: [{ kind: "deploy-thing" }] },
|
|
597
608
|
] };`,
|
|
598
609
|
);
|
|
@@ -33,11 +33,13 @@ import {
|
|
|
33
33
|
DependencyCycleError,
|
|
34
34
|
DriverRunFailure,
|
|
35
35
|
type DriverComponent,
|
|
36
|
+
type DriverGate,
|
|
36
37
|
type DriverPhase,
|
|
37
38
|
type DriverRunResult,
|
|
38
39
|
} from "./driver";
|
|
39
40
|
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
40
41
|
import type { GateLedgerPort } from "../op/gate";
|
|
42
|
+
import { gateName } from "../op/gate-name";
|
|
41
43
|
import { isLexiconPlugin, type LexiconPlugin, type ComponentPipelineOptions } from "../lexicon";
|
|
42
44
|
import type { RunProgressEvent } from "./run-progress";
|
|
43
45
|
import { relative } from "node:path";
|
|
@@ -319,13 +321,16 @@ function toDriverComponent(component: { name: string; dependsOn: string[]; deplo
|
|
|
319
321
|
* A declaration-time question, not a pre-flight refusal: since #2119 the
|
|
320
322
|
* driver decides a gate against the ledger when it reaches one, so nothing
|
|
321
323
|
* needs to know up front that a component has one.
|
|
324
|
+
*
|
|
325
|
+
* The name comes back on `gate` whichever key the component spelled it with,
|
|
326
|
+
* so a caller never has to know about the deprecated `signalName` (#2202).
|
|
322
327
|
*/
|
|
323
|
-
export function findComponentGate(component: DriverComponent): {
|
|
324
|
-
const search = (phases: DriverPhase[] | undefined): {
|
|
328
|
+
export function findComponentGate(component: DriverComponent): { gate: string } | undefined {
|
|
329
|
+
const search = (phases: DriverPhase[] | undefined): { gate: string } | undefined => {
|
|
325
330
|
for (const phaseDef of phases ?? []) {
|
|
326
331
|
for (const entry of phaseDef.steps) {
|
|
327
332
|
if ((entry as { kind?: unknown }).kind === "gate") {
|
|
328
|
-
return
|
|
333
|
+
return { gate: gateName(entry as DriverGate) };
|
|
329
334
|
}
|
|
330
335
|
if (typeof (entry as DriverPhase).phase === "string" && Array.isArray((entry as DriverPhase).steps)) {
|
|
331
336
|
const nested = search([entry as DriverPhase]);
|
|
@@ -192,14 +192,14 @@ describe("Component JSON Schema", () => {
|
|
|
192
192
|
deploy: [
|
|
193
193
|
{
|
|
194
194
|
phase: "Approve",
|
|
195
|
-
steps: [{ kind: "gate",
|
|
195
|
+
steps: [{ kind: "gate", gate: "approve-gated", description: "confirm", timeout: "24h" }],
|
|
196
196
|
},
|
|
197
197
|
],
|
|
198
198
|
};
|
|
199
199
|
expect(validate(withGate)).toBe(true);
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
-
it("rejects a gate step
|
|
202
|
+
it("rejects a gate step naming neither `gate` nor `signalName`", () => {
|
|
203
203
|
const invalid = {
|
|
204
204
|
name: "gated",
|
|
205
205
|
dependsOn: [],
|
|
@@ -208,6 +208,22 @@ describe("Component JSON Schema", () => {
|
|
|
208
208
|
expect(validate(invalid)).toBe(false);
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
// The gate step's name key was renamed from `signalName` to `gate` in #2202.
|
|
212
|
+
// The schema takes either one and exactly one, through 0.59.0.
|
|
213
|
+
const gated = (step: Record<string, unknown>): Record<string, unknown> => ({
|
|
214
|
+
name: "gated",
|
|
215
|
+
dependsOn: [],
|
|
216
|
+
deploy: [{ phase: "Approve", steps: [step] }],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("still accepts the deprecated `signalName` key on a gate step", () => {
|
|
220
|
+
expect(validate(gated({ kind: "gate", signalName: "approve-gated" }))).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("rejects a gate step carrying both `gate` and `signalName`", () => {
|
|
224
|
+
expect(validate(gated({ kind: "gate", gate: "approve-gated", signalName: "approve-gated" }))).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
211
227
|
it("accepts a stackOutput cross-stack reference", () => {
|
|
212
228
|
const withStackOutput = {
|
|
213
229
|
name: "svc",
|
|
@@ -160,18 +160,31 @@
|
|
|
160
160
|
}
|
|
161
161
|
},
|
|
162
162
|
|
|
163
|
+
"GateName": {
|
|
164
|
+
"type": "string",
|
|
165
|
+
"minLength": 1,
|
|
166
|
+
"description": "A gate's name, on either of the two keys that may carry it."
|
|
167
|
+
},
|
|
168
|
+
|
|
163
169
|
"Gate": {
|
|
164
170
|
"type": "object",
|
|
165
171
|
"description": "Ends the run pending human approval. The driver decides a gate against the gate ledger when it reaches one; a gate nobody has approved stops the run there, and `chant approve <component> <gate>` records the resolution the next run reads.",
|
|
166
|
-
"required": ["kind"
|
|
172
|
+
"required": ["kind"],
|
|
173
|
+
"oneOf": [
|
|
174
|
+
{ "required": ["gate"], "properties": { "gate": { "$ref": "#/$defs/GateName" } } },
|
|
175
|
+
{ "required": ["signalName"], "properties": { "signalName": { "$ref": "#/$defs/GateName" } } }
|
|
176
|
+
],
|
|
167
177
|
"additionalProperties": false,
|
|
168
178
|
"properties": {
|
|
169
179
|
"kind": { "type": "string", "const": "gate" },
|
|
170
|
-
"
|
|
171
|
-
"
|
|
172
|
-
"minLength": 1,
|
|
180
|
+
"gate": {
|
|
181
|
+
"$ref": "#/$defs/GateName",
|
|
173
182
|
"description": "The gate's name — what `chant approve <component> <gate>` resolves."
|
|
174
183
|
},
|
|
184
|
+
"signalName": {
|
|
185
|
+
"$ref": "#/$defs/GateName",
|
|
186
|
+
"description": "Deprecated spelling of `gate` (chant #2202), accepted through 0.59.0 and removed in 0.60.0. Exactly one of `gate` and `signalName` may be present."
|
|
187
|
+
},
|
|
175
188
|
"timeout": {
|
|
176
189
|
"type": "string",
|
|
177
190
|
"description": "Duration string bounding the wait, e.g. \"48h\". Default: \"48h\"."
|
|
@@ -37,13 +37,13 @@ describe("phase()", () => {
|
|
|
37
37
|
|
|
38
38
|
describe("gate()", () => {
|
|
39
39
|
it("builds a minimal Gate", () => {
|
|
40
|
-
expect(gate("approve-x")).toEqual({ kind: "gate",
|
|
40
|
+
expect(gate("approve-x")).toEqual({ kind: "gate", gate: "approve-x" });
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
it("carries optional timeout/description", () => {
|
|
44
44
|
expect(gate("approve-x", { timeout: "24h", description: "confirm" })).toEqual({
|
|
45
45
|
kind: "gate",
|
|
46
|
-
|
|
46
|
+
gate: "approve-x",
|
|
47
47
|
timeout: "24h",
|
|
48
48
|
description: "confirm",
|
|
49
49
|
});
|
|
@@ -77,16 +77,36 @@ export interface Step {
|
|
|
77
77
|
[param: string]: unknown;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
export interface
|
|
80
|
+
/** Everything on a component gate step except the key that names it. */
|
|
81
|
+
export interface GateBase {
|
|
82
82
|
kind: "gate";
|
|
83
|
-
signalName: string;
|
|
84
83
|
/** How long a recorded pending gate stays valid, as a duration string. Default: "48h". */
|
|
85
84
|
timeout?: string;
|
|
86
85
|
/** Human-readable description of the action required to unblock this gate. */
|
|
87
86
|
description?: string;
|
|
88
87
|
}
|
|
89
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Mirrors `$defs.Gate` — a human approval decided against the gate ledger; the
|
|
91
|
+
* run stops here with status `gated` until `chant approve` has answered it.
|
|
92
|
+
* The name lives on `gate`; `signalName` is the key it carried through 0.58.0
|
|
93
|
+
* and is still accepted (#2202). Read both through `gateName()`.
|
|
94
|
+
*/
|
|
95
|
+
export type Gate = GateBase &
|
|
96
|
+
(
|
|
97
|
+
| {
|
|
98
|
+
/** The gate's name — what `chant approve <component> <gate>` resolves. */
|
|
99
|
+
gate: string;
|
|
100
|
+
/** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
|
|
101
|
+
signalName?: string;
|
|
102
|
+
}
|
|
103
|
+
| {
|
|
104
|
+
gate?: undefined;
|
|
105
|
+
/** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
|
|
106
|
+
signalName: string;
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
90
110
|
/**
|
|
91
111
|
* Mirrors `$defs.Phase` — one named phase of a deploy composition. A step may
|
|
92
112
|
* itself be a nested `Phase`, which is how a fan-out unit becomes a mini-
|
|
@@ -171,8 +191,8 @@ export function phase(name: string, steps: Array<Step | Gate | Phase>, opts?: {
|
|
|
171
191
|
}
|
|
172
192
|
|
|
173
193
|
/** Author a gate step — a human approval the driver decides against the gate ledger, stopping the run `gated` when nothing has answered it. */
|
|
174
|
-
export function gate(
|
|
175
|
-
return { kind: "gate",
|
|
194
|
+
export function gate(name: string, opts?: { timeout?: string; description?: string }): Gate {
|
|
195
|
+
return { kind: "gate", gate: name, ...opts };
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
/**
|
|
@@ -257,11 +257,11 @@ describe("applyConfigDefaults", () => {
|
|
|
257
257
|
test("leaves gate steps untouched", () => {
|
|
258
258
|
const config: ChantConfig = { sbom: { format: "cyclonedx" } };
|
|
259
259
|
const comp = component([
|
|
260
|
-
{ phase: "Approve", steps: [{ kind: "gate",
|
|
260
|
+
{ phase: "Approve", steps: [{ kind: "gate", gate: "release-approval" }] },
|
|
261
261
|
]);
|
|
262
262
|
|
|
263
263
|
const result = applyConfigDefaults(comp, config);
|
|
264
|
-
expect(result.deploy[0]!.steps[0]).toEqual({ kind: "gate",
|
|
264
|
+
expect(result.deploy[0]!.steps[0]).toEqual({ kind: "gate", gate: "release-approval" });
|
|
265
265
|
});
|
|
266
266
|
|
|
267
267
|
test("applies defaults to rollback phases too", () => {
|
|
@@ -217,7 +217,7 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
|
|
|
217
217
|
name: "neo4j-cluster",
|
|
218
218
|
deploy: [
|
|
219
219
|
{ phase: "Prepare", steps: [{ kind: "cfn-deploy" }] },
|
|
220
|
-
{ phase: "Node 1", steps: [{ kind: "gate",
|
|
220
|
+
{ phase: "Node 1", steps: [{ kind: "gate", gate: "approve-node-1" }, { kind: "code-deploy" }] },
|
|
221
221
|
],
|
|
222
222
|
rollback: [{ phase: "Undo", steps: [{ kind: "cfn-deploy" }] }],
|
|
223
223
|
});
|
|
@@ -259,6 +259,25 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
|
|
|
259
259
|
]);
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
// #2202: `signalName` was the key that named a component gate through 0.58.0
|
|
263
|
+
// and is still read, so a component on the old key gates identically.
|
|
264
|
+
it("still reads a gate step's deprecated `signalName` key", async () => {
|
|
265
|
+
const { registry } = registryWithCalls();
|
|
266
|
+
const legacy: DriverComponent = {
|
|
267
|
+
name: "neo4j-cluster",
|
|
268
|
+
deploy: [{ phase: "Node 1", steps: [{ kind: "gate", signalName: "approve-node-1" }, { kind: "code-deploy" }] }],
|
|
269
|
+
};
|
|
270
|
+
const port = memoryGateLedgerPort();
|
|
271
|
+
const result = await runComponentDeploy(
|
|
272
|
+
legacy, { env: "dev", component: "neo4j-cluster" }, registry, {}, undefined,
|
|
273
|
+
{ port, now: NOW },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(result.status).toBe("gated");
|
|
277
|
+
expect(result.gate).toMatchObject({ op: "neo4j-cluster", gate: "approve-node-1" });
|
|
278
|
+
expect(result.records.map((r) => r.kind)).toEqual(["gate:approve-node-1", "code-deploy"]);
|
|
279
|
+
});
|
|
280
|
+
|
|
262
281
|
it("a resolution newer than the pending fact passes the gate and carries the approver", async () => {
|
|
263
282
|
const { registry, calls } = registryWithCalls();
|
|
264
283
|
const port = memoryGateLedgerPort({
|
package/src/components/driver.ts
CHANGED
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
import { topoSort } from "../codegen/topo-sort";
|
|
50
50
|
import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
51
|
+
import { gateName } from "../op/gate-name";
|
|
51
52
|
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
52
53
|
import type { CapabilityRegistry, DeployContext } from "./capability";
|
|
53
54
|
import type { RunProgressEvent, RunProgressStatus } from "./run-progress";
|
|
@@ -68,10 +69,16 @@ export interface DriverStep {
|
|
|
68
69
|
[param: string]: unknown;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* A gate step — a human approval, decided against the gate ledger when the run
|
|
74
|
+
* reaches it (schema `Gate`). The name is on `gate`; `signalName` is the
|
|
75
|
+
* deprecated spelling (#2202), so read the name through `gateName()`.
|
|
76
|
+
*/
|
|
72
77
|
export interface DriverGate {
|
|
73
78
|
kind: "gate";
|
|
74
|
-
|
|
79
|
+
gate?: string;
|
|
80
|
+
/** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
|
|
81
|
+
signalName?: string;
|
|
75
82
|
timeout?: string;
|
|
76
83
|
description?: string;
|
|
77
84
|
}
|
|
@@ -444,12 +451,12 @@ async function runPhase(
|
|
|
444
451
|
const start = Date.now();
|
|
445
452
|
const check = await evaluateGate(gates.port, {
|
|
446
453
|
op: ctx.component,
|
|
447
|
-
gate: gate
|
|
454
|
+
gate: gateName(gate),
|
|
448
455
|
...(gate.description ? { description: gate.description } : {}),
|
|
449
456
|
...(gate.timeout ? { timeout: gate.timeout } : {}),
|
|
450
457
|
...(gates.now ? { now: gates.now } : {}),
|
|
451
458
|
});
|
|
452
|
-
const base = { component: ctx.component, phase: phaseDef.phase, kind: `gate:${gate
|
|
459
|
+
const base = { component: ctx.component, phase: phaseDef.phase, kind: `gate:${gateName(gate)}` };
|
|
453
460
|
if (!check.satisfied) {
|
|
454
461
|
gateRecords.push({ ...base, status: "skipped" as const, durationMs: Date.now() - start });
|
|
455
462
|
for (const skipped of phaseDef.steps.filter((s): s is DriverStep | DriverPhase => !isGateStep(s))) {
|
|
@@ -462,7 +469,7 @@ async function runPhase(
|
|
|
462
469
|
status: "ok" as const,
|
|
463
470
|
durationMs: Date.now() - start,
|
|
464
471
|
approval: {
|
|
465
|
-
gate: gate
|
|
472
|
+
gate: gateName(gate),
|
|
466
473
|
resolvedBy: check.resolution.resolvedBy,
|
|
467
474
|
timestamp: check.resolution.timestamp,
|
|
468
475
|
...(check.resolution.url ? { url: check.resolution.url } : {}),
|
|
@@ -31,7 +31,7 @@ interface Neo4jInstanceConfig {
|
|
|
31
31
|
*/
|
|
32
32
|
health: { size: number } | { quorum: true };
|
|
33
33
|
/** Optional human gate placed before this instance's steps (rolling instances only, never the seed). */
|
|
34
|
-
approval?: {
|
|
34
|
+
approval?: { gate: string; description: string; timeout: string };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const SEED: Neo4jInstanceConfig = {
|
|
@@ -55,7 +55,7 @@ const FOLLOWERS: Neo4jInstanceConfig[] = [
|
|
|
55
55
|
template: "archive:neo4j-1.template.json",
|
|
56
56
|
health: { quorum: true },
|
|
57
57
|
approval: {
|
|
58
|
-
|
|
58
|
+
gate: "approve-neo4j-node-1",
|
|
59
59
|
description: "Confirm the seed node is healthy before rolling to node 1",
|
|
60
60
|
timeout: "24h",
|
|
61
61
|
},
|
|
@@ -71,7 +71,7 @@ const FOLLOWERS: Neo4jInstanceConfig[] = [
|
|
|
71
71
|
/** Build one instance's mini-composition phase — the unit that repeats N times for an N-node cluster. */
|
|
72
72
|
function instancePhase(instance: Neo4jInstanceConfig): Phase {
|
|
73
73
|
const steps = [
|
|
74
|
-
...(instance.approval ? [gate(instance.approval.
|
|
74
|
+
...(instance.approval ? [gate(instance.approval.gate, instance.approval)] : []),
|
|
75
75
|
{ kind: "cfn-deploy", template: instance.template },
|
|
76
76
|
{ kind: "code-deploy", instance: instance.index, revision: "@Seed.templateUri" },
|
|
77
77
|
{ kind: "wait-cluster-healthy", ...instance.health },
|
|
@@ -287,6 +287,25 @@ describe("run() — failed turn: non-zero exit surfaces as status \"failed\", ne
|
|
|
287
287
|
});
|
|
288
288
|
});
|
|
289
289
|
|
|
290
|
+
describe("RunAgentTurn[\"status\"] is exactly the two states run() produces (#2072)", () => {
|
|
291
|
+
it("has no third member: a switch handling only \"completed\" and \"failed\" compiles with no default needed", () => {
|
|
292
|
+
// Compile-time exhaustiveness check, not a runtime assertion: if a third
|
|
293
|
+
// member were ever added back to the union (e.g. "interrupted"), this
|
|
294
|
+
// switch would stop being exhaustive and `tsc` would reject the missing
|
|
295
|
+
// case, since `identity`'s return type has no branch left to satisfy it.
|
|
296
|
+
const identity = (status: RunAgentOutput["turn"]["status"]): "completed" | "failed" => {
|
|
297
|
+
switch (status) {
|
|
298
|
+
case "completed":
|
|
299
|
+
return "completed";
|
|
300
|
+
case "failed":
|
|
301
|
+
return "failed";
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
expect(identity("completed")).toBe("completed");
|
|
305
|
+
expect(identity("failed")).toBe("failed");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
290
309
|
describe("collectArtifacts — only a \"not found\" read means \"no artifact\"; a genuine infra failure propagates (#1942 review finding 2)", () => {
|
|
291
310
|
it("branch A: readFile rejecting with the sprite-fs \"not found\" shape resolves to empty artifacts.files, not a run() rejection", async () => {
|
|
292
311
|
const { sprites } = makeFakeSprites(succeed); // never writes /work/output — readFile hits the fake's "not found" branch
|
|
@@ -149,12 +149,6 @@
|
|
|
149
149
|
* snapshot folded into a `BuildArchiveManifest` entry (./build-archive.ts)
|
|
150
150
|
* remains an open question, as does `artifacts.diff`, which `run()` never
|
|
151
151
|
* populates (the real Sprites API has no built-in diff endpoint).
|
|
152
|
-
* - Interrupted turns — `turn.status: "interrupted"` stays in the type but
|
|
153
|
-
* is unreachable from this implementation: any `sprites.exec` rejection
|
|
154
|
-
* (including one caused by an aborted signal) propagates as a genuine
|
|
155
|
-
* `run()` failure rather than being classified as an interrupted turn.
|
|
156
|
-
* Distinguishing "deadline hit mid-run" from "the sprite backend errored"
|
|
157
|
-
* well enough to surface `"interrupted"` safely is left for #1944.
|
|
158
152
|
*
|
|
159
153
|
* **#1943 (this revision) resolved the transcript-hash basis and
|
|
160
154
|
* sign/verify-gate interop, closing #1941's open "transcript hash basis"
|
|
@@ -285,7 +279,7 @@ export interface RunAgentInput {
|
|
|
285
279
|
|
|
286
280
|
/** Mirrors fountain's `Turn` shape (`status`/`exit_code`/`started_at`/`ended_at`), even though the turn itself runs on a chant-owned sprite rather than a fountain-managed `Sandbox`. */
|
|
287
281
|
export interface RunAgentTurn {
|
|
288
|
-
status: "completed" | "failed"
|
|
282
|
+
status: "completed" | "failed";
|
|
289
283
|
exitCode: number | null;
|
|
290
284
|
startedAt: string;
|
|
291
285
|
endedAt: string | null;
|
package/src/detectLexicon.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The message {@link detectLexicons} throws when a scan of the project's
|
|
5
|
+
* source files turns up no `@intentius/chant-lexicon-*` import at all.
|
|
6
|
+
*
|
|
7
|
+
* Named (chant #2222) because one caller has to tell this failure apart from
|
|
8
|
+
* every other one: `chant lint` reports a lexicon it cannot resolve as an
|
|
9
|
+
* error diagnostic, but "this directory declares no lexicon and imports none"
|
|
10
|
+
* is not that. It is a project that lints under the core rules alone, and it
|
|
11
|
+
* has always been allowed to. See {@link isNoLexiconDetected}.
|
|
12
|
+
*/
|
|
13
|
+
export const NO_LEXICON_DETECTED_MESSAGE = "No lexicon detected in infrastructure files";
|
|
14
|
+
|
|
15
|
+
/** Whether `error` is the {@link NO_LEXICON_DETECTED_MESSAGE} failure. */
|
|
16
|
+
export function isNoLexiconDetected(error: unknown): boolean {
|
|
17
|
+
return error instanceof Error && error.message === NO_LEXICON_DETECTED_MESSAGE;
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
/**
|
|
4
21
|
* Detects which lexicons are being used by analyzing import statements
|
|
5
22
|
* in the provided infrastructure files. Matches any `@intentius/chant-lexicon-*` package.
|
|
@@ -32,7 +49,7 @@ export async function detectLexicons(files: string[]): Promise<string[]> {
|
|
|
32
49
|
|
|
33
50
|
// Validate results
|
|
34
51
|
if (detectedLexicons.size === 0) {
|
|
35
|
-
throw new Error(
|
|
52
|
+
throw new Error(NO_LEXICON_DETECTED_MESSAGE);
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
return Array.from(detectedLexicons);
|
|
@@ -1276,7 +1276,7 @@ describe("tryFoldFile — registered authoring helpers (#1082)", () => {
|
|
|
1276
1276
|
stack: "web",
|
|
1277
1277
|
inputs: { pVpcId: { stackOutput: { stack: "shared-foundation", name: "oVpcId" } } },
|
|
1278
1278
|
},
|
|
1279
|
-
{ kind: "gate",
|
|
1279
|
+
{ kind: "gate", gate: "approve", timeout: "24h" },
|
|
1280
1280
|
],
|
|
1281
1281
|
},
|
|
1282
1282
|
],
|
|
@@ -123,7 +123,7 @@ export const FOLDABLE_AUTHORING_HELPERS: readonly FoldableHelperDef[] = [
|
|
|
123
123
|
{
|
|
124
124
|
name: "gate",
|
|
125
125
|
module: "components/component.ts, op/builders.ts",
|
|
126
|
-
note: "Returns a plain `{ kind: 'gate',
|
|
126
|
+
note: "Returns a plain `{ kind: 'gate', gate, ... }` object literal built from its arguments.",
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
name: "activity",
|