@intentius/chant 0.124.24 → 0.124.26
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/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +4 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +4 -2
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +39 -4
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +21 -7
- package/dist/cli/mcp/workspace-tools.d.ts +3 -3
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +27 -4
- package/dist/effect-receipt.d.ts +13 -0
- package/dist/effect-receipt.d.ts.map +1 -1
- package/dist/effect-receipt.js +19 -0
- package/dist/gate-decision.d.ts +1 -1
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +1 -1
- package/dist/lexicon-module.d.ts +7 -1
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +7 -2
- package/dist/lifecycle/gate-audit.d.ts +7 -1
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.js +40 -3
- package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
- package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
- package/dist/lifecycle/gate-checkpoint.js +103 -0
- package/dist/lifecycle/gate-ledger.d.ts +41 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.js +18 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.js +4 -2
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +2 -0
- package/dist/lint/rules/op/index.d.ts +6 -2
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +7 -3
- package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
- package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
- package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
- package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
- package/dist/op/activity-contract.d.ts +14 -0
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-contract.js +16 -0
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +13 -0
- package/dist/op/gate-alias.d.ts +57 -0
- package/dist/op/gate-alias.d.ts.map +1 -0
- package/dist/op/gate-alias.js +127 -0
- package/dist/op/gate-checkpoint.d.ts +123 -0
- package/dist/op/gate-checkpoint.d.ts.map +1 -0
- package/dist/op/gate-checkpoint.js +280 -0
- package/dist/op/gate.d.ts +54 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +142 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +2 -1
- package/dist/op/local-executor.d.ts +57 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +200 -11
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/local-output.js +20 -2
- package/dist/op/op-ir.d.ts +4 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-ir.js +4 -0
- package/dist/op/receipt-store.d.ts +5 -0
- package/dist/op/receipt-store.d.ts.map +1 -1
- package/dist/op/receipt-store.js +4 -3
- package/dist/op/runtime.d.ts +15 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +5 -0
- package/dist/op/step-output-ref.d.ts +26 -18
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/step-output-ref.js +86 -63
- package/dist/op/types.d.ts +31 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +223 -221
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/agent-cli.js +3 -0
- package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/dist/workspace/conformance/writer.js +1 -1
- package/dist/workspace/decide.d.ts +51 -9
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decide.js +125 -14
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +20 -11
- package/dist/workspace/point-answer.schema.json +16 -1
- package/dist/workspace/points-cli.d.ts +5 -3
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points-cli.js +16 -7
- package/dist/workspace/points-write.schema.json +39 -4
- package/dist/workspace/points.schema.json +42 -3
- package/dist/workspace/reason-codes.d.ts +2 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +2 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.js +27 -7
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records-write.js +2 -3
- package/dist/workspace/records.d.ts +10 -4
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/records.js +16 -3
- package/dist/workspace/records.schema.json +2 -1
- package/dist/workspace/trust/gate-seal.d.ts +32 -1
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.js +41 -2
- package/dist/workspace/trust/seal.d.ts +1 -1
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +1 -1
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
- package/dist/workspace/trust/verify-cli.js +2 -1
- package/dist/workspace/trust/verify.d.ts +22 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/trust/verify.js +94 -14
- package/dist/workspace/write-scope.d.ts +3 -1
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -4
- package/package.json +1 -1
- package/src/cli/commands/build.ts +9 -2
- package/src/cli/commands/lint.ts +4 -2
- package/src/cli/compiled-cli.e2e.test.ts +45 -1
- package/src/cli/handlers/approve.ts +46 -4
- package/src/cli/handlers/operator.test.ts +91 -0
- package/src/cli/main.test.ts +10 -0
- package/src/cli/main.ts +21 -7
- package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +27 -4
- package/src/effect-receipt.ts +22 -0
- package/src/gate-decision.ts +2 -0
- package/src/lexicon-module.ts +9 -2
- package/src/lifecycle/gate-audit.ts +41 -4
- package/src/lifecycle/gate-checkpoint.test.ts +128 -0
- package/src/lifecycle/gate-checkpoint.ts +133 -0
- package/src/lifecycle/gate-ledger.ts +61 -0
- package/src/lifecycle/receipt-plan.test.ts +9 -0
- package/src/lifecycle/receipt-plan.ts +4 -1
- package/src/lifecycle/run-ledger.ts +4 -0
- package/src/lint/rules/op/index.ts +9 -3
- package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
- package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
- package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
- package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
- package/src/op/activity-contract.ts +22 -1
- package/src/op/builders.ts +21 -1
- package/src/op/effect-step.test.ts +95 -1
- package/src/op/gate-alias.e2e.test.ts +78 -0
- package/src/op/gate-alias.test.ts +350 -0
- package/src/op/gate-alias.ts +137 -0
- package/src/op/gate-checkpoint.test.ts +284 -0
- package/src/op/gate-checkpoint.ts +322 -0
- package/src/op/gate.ts +198 -4
- package/src/op/index.ts +4 -1
- package/src/op/local-executor.test.ts +65 -0
- package/src/op/local-executor.ts +248 -12
- package/src/op/local-output.ts +18 -2
- package/src/op/op-ir.test.ts +25 -0
- package/src/op/op-ir.ts +8 -0
- package/src/op/receipt-store.ts +13 -3
- package/src/op/runtime.ts +8 -0
- package/src/op/runtimes/local.ts +5 -0
- package/src/op/step-output-ref.test.ts +68 -24
- package/src/op/step-output-ref.ts +85 -65
- package/src/op/types.ts +32 -0
- package/src/workspace/agent-cli.ts +2 -0
- package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/src/workspace/conformance/writer.ts +1 -1
- package/src/workspace/decide.test.ts +87 -3
- package/src/workspace/decide.ts +142 -17
- package/src/workspace/lineage-upgrade.test.ts +21 -0
- package/src/workspace/lineage-upgrade.ts +19 -11
- package/src/workspace/point-answer.schema.json +16 -1
- package/src/workspace/points-cli.ts +16 -8
- package/src/workspace/points-write.schema.json +39 -4
- package/src/workspace/points.schema.json +42 -3
- package/src/workspace/reason-codes.ts +2 -1
- package/src/workspace/record-assets.test.ts +14 -0
- package/src/workspace/records-cli.ts +28 -7
- package/src/workspace/records-write-commands.e2e.test.ts +32 -0
- package/src/workspace/records-write.ts +2 -3
- package/src/workspace/records.schema.json +2 -1
- package/src/workspace/records.ts +17 -4
- package/src/workspace/trust/gate-seal.test.ts +29 -2
- package/src/workspace/trust/gate-seal.ts +46 -2
- package/src/workspace/trust/seal.ts +1 -1
- package/src/workspace/trust/trust.test.ts +105 -0
- package/src/workspace/trust/verify-cli.ts +2 -1
- package/src/workspace/trust/verify.ts +106 -13
- package/src/workspace/write-scope.test.ts +18 -0
- package/src/workspace/write-scope.ts +16 -5
package/src/op/runtimes/local.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { lexiconNames } from "../../lexicon-module";
|
|
|
22
22
|
import { loadChantConfig } from "../../config";
|
|
23
23
|
import { loadActivities, loadProfiles } from "../activity-registry";
|
|
24
24
|
import { runOpLocally, OpRunFailure, type OpRunResult } from "../local-executor";
|
|
25
|
+
import { loadActivityContracts } from "../activity-contract-registry";
|
|
26
|
+
import { resumeGatesOf } from "../gate-checkpoint";
|
|
25
27
|
import { stepHeartbeatMs } from "../step-console";
|
|
26
28
|
import { runComponents } from "../../components/cli-support";
|
|
27
29
|
import { discoverOps } from "../discover";
|
|
@@ -139,6 +141,8 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
const [activities, profiles] = await Promise.all([loadActivities(lexicons), loadProfiles()]);
|
|
144
|
+
// #3947: a gate that resumes stores no output a contract marks secret.
|
|
145
|
+
const contracts = resumeGatesOf(op).length > 0 ? await loadActivityContracts(lexicons) : undefined;
|
|
142
146
|
|
|
143
147
|
// A caller that names the run (#3539: `chant acp` on fountain passes the
|
|
144
148
|
// conversation id) gets its id on the record; otherwise one is minted.
|
|
@@ -179,6 +183,7 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
179
183
|
stepOutput: (line) => process.stderr.write(`${line}\n`),
|
|
180
184
|
stepHeartbeatMs: stepHeartbeatMs(process.env, (message) => process.stderr.write(`warning: ${message}\n`)),
|
|
181
185
|
...(startOpts.work ? { work: startOpts.work } : {}),
|
|
186
|
+
...(contracts ? { contracts } : {}),
|
|
182
187
|
onWorkLeaseWarning: (message) =>
|
|
183
188
|
process.stderr.write(`warning: "${op.name}" could not renew its work lease, retrying at the next beat: ${message}\n`),
|
|
184
189
|
},
|
|
@@ -10,7 +10,7 @@ import { z } from "zod";
|
|
|
10
10
|
import { activity, phase, effect } from "./builders";
|
|
11
11
|
import type { OpConfig } from "./types";
|
|
12
12
|
import { activityContract, type ActivityContract } from "./activity-contract";
|
|
13
|
-
import { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs } from "./step-output-ref";
|
|
13
|
+
import { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs, validateStepOutputRefScope } from "./step-output-ref";
|
|
14
14
|
import { EffectReceipt } from "../effect-receipt";
|
|
15
15
|
|
|
16
16
|
const lifecycleDiffContract = activityContract(
|
|
@@ -248,39 +248,83 @@ describe("validateStepOutputRefs() — structural coverage", () => {
|
|
|
248
248
|
expect(issues.some((i) => i.message.includes("declares no return schema"))).toBe(true);
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it("
|
|
251
|
+
it("reports every issue across multiple refs, not just the first", () => {
|
|
252
|
+
const apply1 = activity("applyStacks", { stacks: stepOutput("nope1", "x") });
|
|
253
|
+
const apply2 = activity("applyStacks", { stacks: stepOutput("nope2", "x") });
|
|
254
|
+
const config = opWith({ phases: [phase("P", [apply1, apply2])] });
|
|
255
|
+
const issues = validateStepOutputRefs(config, new Map([["applyStacks", applyStacksContract]]));
|
|
256
|
+
expect(issues.some((i) => i.message.includes("nope1"))).toBe(true);
|
|
257
|
+
expect(issues.some((i) => i.message.includes("nope2"))).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── onFailure and effect steps (#3948) ───────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
describe("validateStepOutputRefs() — onFailure and effect steps (#3948)", () => {
|
|
264
|
+
const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql", version: 3 } });
|
|
265
|
+
const contracts = new Map<string, ActivityContract>([
|
|
266
|
+
["lifecycleDiff", lifecycleDiffContract],
|
|
267
|
+
["applyStacks", applyStacksContract],
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
it("an onFailure step may reference any main-phase step", () => {
|
|
252
271
|
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
272
|
+
const apply = activity("applyStacks", { stacks: ["a"] });
|
|
253
273
|
const rollback = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
|
|
274
|
+
const config = opWith({ phases: [phase("Diff", [diff]), phase("Apply", [apply])], onFailure: [phase("Rollback", [rollback])] });
|
|
275
|
+
expect(validateStepOutputRefs(config, contracts)).toEqual([]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("an onFailure step's reference is still checked against the producer's contract", () => {
|
|
279
|
+
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
280
|
+
const rollback = activity("applyStacks", { stacks: stepOutput("diff", "nope") });
|
|
254
281
|
const config = opWith({ phases: [phase("P", [diff])], onFailure: [phase("Rollback", [rollback])] });
|
|
255
|
-
const contracts = new Map<string, ActivityContract>([
|
|
256
|
-
["lifecycleDiff", lifecycleDiffContract],
|
|
257
|
-
["applyStacks", applyStacksContract],
|
|
258
|
-
]);
|
|
259
282
|
const issues = validateStepOutputRefs(config, contracts);
|
|
260
|
-
expect(issues
|
|
283
|
+
expect(issues).toHaveLength(1);
|
|
284
|
+
expect(issues[0].phase).toBe("Rollback");
|
|
285
|
+
expect(issues[0].message).toMatch(/path "nope", which does not exist/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("an onFailure step cannot reference another onFailure step", () => {
|
|
289
|
+
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "undoDiff" });
|
|
290
|
+
const rollback = activity("applyStacks", { stacks: stepOutput("undoDiff", "driftedStacks") });
|
|
291
|
+
const config = opWith({ phases: [phase("P", [activity("applyStacks", { stacks: [] })])], onFailure: [phase("Rollback", [diff, rollback])] });
|
|
292
|
+
expect(validateStepOutputRefScope(config)[0].message).toMatch(/unknown step id "undoDiff"/);
|
|
261
293
|
});
|
|
262
294
|
|
|
263
|
-
it("
|
|
264
|
-
const receipt = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql", version: 3 } });
|
|
295
|
+
it("a step inside an effect may reference a step that runs before the effect", () => {
|
|
265
296
|
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
266
297
|
const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
const contracts = new Map<string, ActivityContract>([
|
|
270
|
-
["lifecycleDiff", lifecycleDiffContract],
|
|
271
|
-
["applyStacks", applyStacksContract],
|
|
272
|
-
]);
|
|
273
|
-
const issues = validateStepOutputRefs(config, contracts);
|
|
274
|
-
expect(issues.some((i) => i.message.includes("nested inside an effect step"))).toBe(true);
|
|
298
|
+
const config = opWith({ phases: [phase("P", [diff, effect(seeded, [nested])])] });
|
|
299
|
+
expect(validateStepOutputRefs(config, contracts)).toEqual([]);
|
|
275
300
|
});
|
|
276
301
|
|
|
277
|
-
it("
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
const config = opWith({ phases: [phase("P", [
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
302
|
+
it("a step inside an effect in onFailure may reference a main-phase step", () => {
|
|
303
|
+
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
304
|
+
const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
|
|
305
|
+
const config = opWith({ phases: [phase("P", [diff])], onFailure: [phase("Undo", [effect(seeded, [nested])])] });
|
|
306
|
+
expect(validateStepOutputRefs(config, contracts)).toEqual([]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("a step inside an effect cannot reference a step after the effect", () => {
|
|
310
|
+
const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
|
|
311
|
+
const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
|
|
312
|
+
const config = opWith({ phases: [phase("P", [effect(seeded, [nested]), diff])] });
|
|
313
|
+
expect(validateStepOutputRefScope(config)[0].message).toMatch(/step "diff" does not run before this step/);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("nothing can reference a step nested inside an effect (decision A7)", () => {
|
|
317
|
+
const inner = activity("lifecycleDiff", { env: "prod" }, { id: "inner" });
|
|
318
|
+
const sibling = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
|
|
319
|
+
const later = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
|
|
320
|
+
const rollback = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
|
|
321
|
+
const config = opWith({
|
|
322
|
+
phases: [phase("P", [effect(seeded, [inner, sibling]), later])],
|
|
323
|
+
onFailure: [phase("Rollback", [rollback])],
|
|
324
|
+
});
|
|
325
|
+
const issues = validateStepOutputRefScope(config);
|
|
326
|
+
expect(issues).toHaveLength(3);
|
|
327
|
+
for (const issue of issues) expect(issue.message).toMatch(/"inner", which is nested inside an effect step/);
|
|
284
328
|
});
|
|
285
329
|
});
|
|
286
330
|
|
|
@@ -23,15 +23,20 @@
|
|
|
23
23
|
* Scope, kept deliberately tight (see the issue's "Open questions"):
|
|
24
24
|
* - same-Op only — no cross-Op plumbing (that needs a durable place to put
|
|
25
25
|
* the value, which #1290 explicitly defers).
|
|
26
|
-
* -
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
26
|
+
* - producers are the top-level activity steps of the main `phases`. A step
|
|
27
|
+
* in `onFailure` or nested inside an `EffectStep` produces nothing another
|
|
28
|
+
* step may reference: an effect's nested steps run only when the receipt
|
|
29
|
+
* comparison mismatches, so a value one of them returns isn't reliably
|
|
30
|
+
* there for anything else (#3948 decision A7, "for now").
|
|
31
|
+
* - consumers are those producers, plus (#3948) every activity step in
|
|
32
|
+
* `onFailure` and every activity step nested inside an effect. An
|
|
33
|
+
* `onFailure` step may reference any main-phase producer. Compensation
|
|
34
|
+
* runs after a failure, so its producer may never have completed; the
|
|
35
|
+
* executor then skips the compensation step and records why (decision
|
|
36
|
+
* A6), rather than calling it with an `undefined` in place of the value.
|
|
37
|
+
* A step nested inside an effect may reference a producer that runs
|
|
38
|
+
* before the effect, and the executor folds the resolved values into the
|
|
39
|
+
* effect's expectation, so a changed value re-fires the effect.
|
|
35
40
|
* - producer must run to completion before the consumer starts: an earlier
|
|
36
41
|
* phase, or an earlier step within the same non-parallel phase. Two
|
|
37
42
|
* steps in the same parallel phase run concurrently (`Promise.all`) with
|
|
@@ -243,26 +248,63 @@ function locateStepOutputRefPath(value: unknown, target: StepOutputRef, path: st
|
|
|
243
248
|
// ── Validation ──────────────────────────────────────────────────────────────
|
|
244
249
|
|
|
245
250
|
interface StepLocation {
|
|
251
|
+
/** Index into `phases`; every `onFailure` phase sits after all of them. */
|
|
246
252
|
phaseIndex: number;
|
|
253
|
+
/** Index into the phase's `steps`. A step nested in an effect takes the effect's. */
|
|
247
254
|
stepIndex: number;
|
|
248
255
|
parallel: boolean;
|
|
249
256
|
step: ActivityStep;
|
|
257
|
+
phaseName: string;
|
|
250
258
|
}
|
|
251
259
|
|
|
252
|
-
/** Top-level `ActivityStep`s of `phases`, in authored (phase, then step) order
|
|
260
|
+
/** Top-level `ActivityStep`s of `phases`, in authored (phase, then step) order: the producers (see the module doc's scope note). */
|
|
253
261
|
function locateActivitySteps(phases: PhaseDefinition[]): StepLocation[] {
|
|
254
262
|
const locations: StepLocation[] = [];
|
|
255
263
|
phases.forEach((phase, phaseIndex) => {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
stepIndex++;
|
|
261
|
-
}
|
|
264
|
+
phase.steps.forEach((step, stepIndex) => {
|
|
265
|
+
if (step.kind !== "activity") return;
|
|
266
|
+
locations.push({ phaseIndex, stepIndex, parallel: phase.parallel === true, step, phaseName: phase.name });
|
|
267
|
+
});
|
|
262
268
|
});
|
|
263
269
|
return locations;
|
|
264
270
|
}
|
|
265
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Every activity step that may hold a reference (#3948): the producers, each
|
|
274
|
+
* step nested inside an effect of a main phase, and every activity step of
|
|
275
|
+
* `onFailure`, effect-nested ones included. An `onFailure` step is placed after
|
|
276
|
+
* every main phase, so any main-phase producer precedes it.
|
|
277
|
+
*/
|
|
278
|
+
function locateConsumers(config: Pick<OpConfig, "phases" | "onFailure">): StepLocation[] {
|
|
279
|
+
const consumers: StepLocation[] = [];
|
|
280
|
+
const visit = (phases: PhaseDefinition[], offset: number) => {
|
|
281
|
+
phases.forEach((phase, i) => {
|
|
282
|
+
const at = { phaseIndex: offset + i, parallel: phase.parallel === true, phaseName: phase.name };
|
|
283
|
+
phase.steps.forEach((step, stepIndex) => {
|
|
284
|
+
if (step.kind === "activity") consumers.push({ ...at, stepIndex, step });
|
|
285
|
+
if (step.kind === "effect") {
|
|
286
|
+
for (const nested of step.steps) if (nested.kind === "activity") consumers.push({ ...at, stepIndex, step: nested });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
visit(config.phases, 0);
|
|
292
|
+
visit(config.onFailure ?? [], config.phases.length);
|
|
293
|
+
return consumers;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Ids of the activity steps nested inside an effect, in any phase. Nothing may reference them (#3948 decision A7). */
|
|
297
|
+
function effectNestedIds(config: Pick<OpConfig, "phases" | "onFailure">): Set<string> {
|
|
298
|
+
const ids = new Set<string>();
|
|
299
|
+
for (const phase of [...config.phases, ...(config.onFailure ?? [])]) {
|
|
300
|
+
for (const step of phase.steps) {
|
|
301
|
+
if (step.kind !== "effect") continue;
|
|
302
|
+
for (const nested of step.steps) if (nested.kind === "activity" && nested.id) ids.add(nested.id);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return ids;
|
|
306
|
+
}
|
|
307
|
+
|
|
266
308
|
/** True when `producer` is guaranteed to have completed before `consumer` starts. */
|
|
267
309
|
function producerPrecedes(producer: StepLocation, consumer: StepLocation): boolean {
|
|
268
310
|
if (producer.phaseIndex !== consumer.phaseIndex) return producer.phaseIndex < consumer.phaseIndex;
|
|
@@ -270,10 +312,6 @@ function producerPrecedes(producer: StepLocation, consumer: StepLocation): boole
|
|
|
270
312
|
return producer.stepIndex < consumer.stepIndex;
|
|
271
313
|
}
|
|
272
314
|
|
|
273
|
-
function effectNestedActivitySteps(phase: PhaseDefinition): ActivityStep[] {
|
|
274
|
-
return phase.steps.flatMap((s) => (s.kind === "effect" ? s.steps.filter((n) => n.kind === "activity") : []));
|
|
275
|
-
}
|
|
276
|
-
|
|
277
315
|
/** `byId`/`duplicateIds` over an Op's top-level main-phase activity steps — shared by {@link validateStepOutputRefScope} and {@link validateStepOutputRefs}. */
|
|
278
316
|
function indexById(locations: StepLocation[]): { byId: Map<string, StepLocation>; duplicateIds: Set<string> } {
|
|
279
317
|
const byId = new Map<string, StepLocation>();
|
|
@@ -298,46 +336,28 @@ function indexById(locations: StepLocation[]): { byId: Map<string, StepLocation>
|
|
|
298
336
|
* a public export a caller can invoke directly, bypassing that lint pass —
|
|
299
337
|
* this is its own defense-in-depth.
|
|
300
338
|
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* explicitly here rather than left to fall
|
|
339
|
+
* Consumers are the main-phase activity steps, the steps nested inside an
|
|
340
|
+
* effect, and every `onFailure` activity step (#3948). Flags:
|
|
341
|
+
* - a reference to a step nested inside an effect step, from anywhere
|
|
342
|
+
* (#3948 decision A7) — flagged explicitly here rather than left to fall
|
|
343
|
+
* through as "unknown producer",
|
|
305
344
|
* - a reference to an unregistered step id (no step in scope declares it),
|
|
306
345
|
* - a duplicate step id (ambiguous producer),
|
|
307
346
|
* - a reference to a step that does not precede the referencing step
|
|
308
347
|
* (a later phase, a later step in the same phase, itself, or a step in
|
|
309
|
-
* the same parallel phase).
|
|
348
|
+
* the same parallel phase). A step nested inside an effect takes the
|
|
349
|
+
* effect's position, so it may reference only a step that runs before
|
|
350
|
+
* the effect. An `onFailure` step comes after every main phase.
|
|
310
351
|
*/
|
|
311
352
|
export function validateStepOutputRefScope(
|
|
312
353
|
config: Pick<OpConfig, "name" | "phases" | "onFailure"> & Partial<Pick<OpConfig, "workLease">>,
|
|
313
354
|
): ActivityContractIssue[] {
|
|
314
355
|
const issues: ActivityContractIssue[] = [];
|
|
315
356
|
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
for (const ref of collectStepOutputRefs(step.args)) {
|
|
319
|
-
issues.push({
|
|
320
|
-
opName: config.name,
|
|
321
|
-
phase: phaseName,
|
|
322
|
-
fn: step.fn,
|
|
323
|
-
message: `references step "${ref.step}"'s output, but ${reason}`,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
for (const phase of config.onFailure ?? []) {
|
|
329
|
-
const steps = phase.steps.filter((s): s is ActivityStep => s.kind === "activity").concat(effectNestedActivitySteps(phase));
|
|
330
|
-
flagOutOfScope(steps, phase.name, "step-output references are not supported in onFailure compensation phases");
|
|
331
|
-
}
|
|
332
|
-
for (const phase of config.phases) {
|
|
333
|
-
flagOutOfScope(effectNestedActivitySteps(phase), phase.name, "step-output references are not supported for a step nested inside an effect step");
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const locations = locateActivitySteps(config.phases);
|
|
337
|
-
const { byId, duplicateIds } = indexById(locations);
|
|
338
|
-
const phaseNameOf = (loc: StepLocation): string => config.phases[loc.phaseIndex]!.name;
|
|
357
|
+
const { byId, duplicateIds } = indexById(locateActivitySteps(config.phases));
|
|
358
|
+
const nestedIds = effectNestedIds(config);
|
|
339
359
|
|
|
340
|
-
for (const consumer of
|
|
360
|
+
for (const consumer of locateConsumers(config)) {
|
|
341
361
|
const refs = collectStepOutputRefs(consumer.step.args);
|
|
342
362
|
for (const ref of refs) {
|
|
343
363
|
// The run's work lease (#2748) is published under a reserved id once it
|
|
@@ -346,7 +366,7 @@ export function validateStepOutputRefScope(
|
|
|
346
366
|
if (duplicateIds.has(ref.step)) {
|
|
347
367
|
issues.push({
|
|
348
368
|
opName: config.name,
|
|
349
|
-
phase:
|
|
369
|
+
phase: consumer.phaseName,
|
|
350
370
|
fn: consumer.step.fn,
|
|
351
371
|
message: `references step id "${ref.step}", but that id is used by more than one step in this Op — ids must be unique`,
|
|
352
372
|
});
|
|
@@ -357,9 +377,11 @@ export function validateStepOutputRefScope(
|
|
|
357
377
|
if (!producer) {
|
|
358
378
|
issues.push({
|
|
359
379
|
opName: config.name,
|
|
360
|
-
phase:
|
|
380
|
+
phase: consumer.phaseName,
|
|
361
381
|
fn: consumer.step.fn,
|
|
362
|
-
message:
|
|
382
|
+
message: nestedIds.has(ref.step)
|
|
383
|
+
? `references step "${ref.step}", which is nested inside an effect step — a step inside an effect runs only when the effect fires, so no other step can reference its output`
|
|
384
|
+
: `references unknown step id "${ref.step}" — no step in this Op's main phases declares that id`,
|
|
363
385
|
});
|
|
364
386
|
continue;
|
|
365
387
|
}
|
|
@@ -373,7 +395,7 @@ export function validateStepOutputRefScope(
|
|
|
373
395
|
: `step "${ref.step}" does not run before this step`;
|
|
374
396
|
issues.push({
|
|
375
397
|
opName: config.name,
|
|
376
|
-
phase:
|
|
398
|
+
phase: consumer.phaseName,
|
|
377
399
|
fn: consumer.step.fn,
|
|
378
400
|
message: `references ${reason} — a step can only reference an earlier step's output`,
|
|
379
401
|
});
|
|
@@ -385,10 +407,10 @@ export function validateStepOutputRefScope(
|
|
|
385
407
|
}
|
|
386
408
|
|
|
387
409
|
/**
|
|
388
|
-
* Validate every step-output reference in an Op
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
410
|
+
* Validate every step-output reference in an Op against a contract map and
|
|
411
|
+
* the Op's own step ordering, over the consumers {@link
|
|
412
|
+
* validateStepOutputRefScope} covers (see the module doc). Layers
|
|
413
|
+
* contract-based checks on top of {@link
|
|
392
414
|
* validateStepOutputRefScope}'s scope/ordering checks.
|
|
393
415
|
*
|
|
394
416
|
* Flags everything {@link validateStepOutputRefScope} does, plus:
|
|
@@ -404,11 +426,9 @@ export function validateStepOutputRefs(
|
|
|
404
426
|
): ActivityContractIssue[] {
|
|
405
427
|
const issues = validateStepOutputRefScope(config);
|
|
406
428
|
|
|
407
|
-
const
|
|
408
|
-
const { byId, duplicateIds } = indexById(locations);
|
|
409
|
-
const phaseNameOf = (loc: StepLocation): string => config.phases[loc.phaseIndex]!.name;
|
|
429
|
+
const { byId, duplicateIds } = indexById(locateActivitySteps(config.phases));
|
|
410
430
|
|
|
411
|
-
for (const consumer of
|
|
431
|
+
for (const consumer of locateConsumers(config)) {
|
|
412
432
|
const refs = collectStepOutputRefs(consumer.step.args);
|
|
413
433
|
for (const ref of refs) {
|
|
414
434
|
// Already flagged by validateStepOutputRefScope — nothing to validate
|
|
@@ -421,7 +441,7 @@ export function validateStepOutputRefs(
|
|
|
421
441
|
if (!producerContract) {
|
|
422
442
|
issues.push({
|
|
423
443
|
opName: config.name,
|
|
424
|
-
phase:
|
|
444
|
+
phase: consumer.phaseName,
|
|
425
445
|
fn: consumer.step.fn,
|
|
426
446
|
message: `references step "${ref.step}"'s output, but "${producer.step.fn}" has no registered activity contract to validate the reference against`,
|
|
427
447
|
});
|
|
@@ -430,7 +450,7 @@ export function validateStepOutputRefs(
|
|
|
430
450
|
if (!producerContract.returns) {
|
|
431
451
|
issues.push({
|
|
432
452
|
opName: config.name,
|
|
433
|
-
phase:
|
|
453
|
+
phase: consumer.phaseName,
|
|
434
454
|
fn: consumer.step.fn,
|
|
435
455
|
message: `references step "${ref.step}"'s output, but "${producer.step.fn}"'s contract declares no return schema`,
|
|
436
456
|
});
|
|
@@ -439,7 +459,7 @@ export function validateStepOutputRefs(
|
|
|
439
459
|
if (ref.path && !pathExistsInSchema(producerContract.returns as z.ZodTypeAny, ref.path)) {
|
|
440
460
|
issues.push({
|
|
441
461
|
opName: config.name,
|
|
442
|
-
phase:
|
|
462
|
+
phase: consumer.phaseName,
|
|
443
463
|
fn: consumer.step.fn,
|
|
444
464
|
message: `references step "${ref.step}"'s output path "${ref.path}", which does not exist on "${producer.step.fn}"'s declared return type`,
|
|
445
465
|
});
|
|
@@ -466,7 +486,7 @@ export function validateStepOutputRefs(
|
|
|
466
486
|
if (consumerKind && producerKind && consumerKind !== producerKind) {
|
|
467
487
|
issues.push({
|
|
468
488
|
opName: config.name,
|
|
469
|
-
phase:
|
|
489
|
+
phase: consumer.phaseName,
|
|
470
490
|
fn: consumer.step.fn,
|
|
471
491
|
message: `references step "${ref.step}"'s output path "${ref.path}" (${producerKind}), but arg "${argPath.join(".")}" on "${consumer.step.fn}" declares ${consumerKind} — type mismatch`,
|
|
472
492
|
});
|
package/src/op/types.ts
CHANGED
|
@@ -298,6 +298,38 @@ export interface GateStepBase {
|
|
|
298
298
|
* `./gate-point.ts`.
|
|
299
299
|
*/
|
|
300
300
|
point?: string | GatePoint;
|
|
301
|
+
/**
|
|
302
|
+
* A run that stops at this gate can be resumed here once it is approved
|
|
303
|
+
* (#3947), rather than starting again at the first phase. The run stores
|
|
304
|
+
* the step outputs a later step references in a checkpoint on
|
|
305
|
+
* `chant/lifecycle`, and the next run after `chant approve` re-runs the
|
|
306
|
+
* step that produced the gate's `plan`, checks the checkpoint, and carries
|
|
307
|
+
* on from the gate. Any check that fails starts the run at the first phase,
|
|
308
|
+
* and the run says why. See `./gate-checkpoint.ts`.
|
|
309
|
+
*
|
|
310
|
+
* Only a gate at the top of a main phase resumes, and not one that asks a
|
|
311
|
+
* decision point or sits in an Op with a `workLease`. OPS016 refuses the
|
|
312
|
+
* rest at build.
|
|
313
|
+
*/
|
|
314
|
+
resume?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* The names this gate had before an Op was restructured (#3949), so the
|
|
317
|
+
* approvals already recorded under them still count. `op` defaults to this
|
|
318
|
+
* Op. An approval recorded under an alias counts toward this gate when it
|
|
319
|
+
* answers that name's standing pending fact, the fact hasn't expired, it
|
|
320
|
+
* is for the plan this run planned and the same environment, it hasn't
|
|
321
|
+
* been spent, it passes this gate's `identity.gates` rule, and a person
|
|
322
|
+
* made it. The run then writes it again under this gate's name, citing
|
|
323
|
+
* the line it came from (`carriedFrom`). Only a gate bound to a `plan`
|
|
324
|
+
* takes aliases, and OPS017 refuses an alias that is still a gate.
|
|
325
|
+
*/
|
|
326
|
+
aliases?: GateAlias[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A name a gate had before (#3949): a gate of `op`, or of the gate's own Op when `op` is absent. */
|
|
330
|
+
export interface GateAlias {
|
|
331
|
+
op?: string;
|
|
332
|
+
gate: string;
|
|
301
333
|
}
|
|
302
334
|
|
|
303
335
|
/**
|
|
@@ -91,6 +91,8 @@ export async function agentSession(q: { cwd: string; name: string }): Promise<Ag
|
|
|
91
91
|
try {
|
|
92
92
|
const source = scopeSource(q.cwd);
|
|
93
93
|
if (source.declaration === null || source.from === null) {
|
|
94
|
+
// A declaration that is there but fails validation says so (#3433).
|
|
95
|
+
if (source.failure && source.failure.code !== "declaration-missing") throw source.failure;
|
|
94
96
|
throw new WorkspaceReadError("declaration-missing", "no chant.workspace.json or .jsonc between this directory and the git root, so no agent session is declared");
|
|
95
97
|
}
|
|
96
98
|
const decl = source.declaration;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"$id": "urn:intentius:chant:point-answer:1",
|
|
4
4
|
"title": "Decision point answer front matter",
|
|
5
|
-
"description": "The front matter of one answer to a decision point (ws-058, #2739), as `chant workspace points ask` and `points answer` write it into the records directory of a record kind with an `answers` block. One record per point, declaration and inputs: its id is the point's name and the first 12 hex digits of the inputs hash, so the same question is answered once. A table's answer is `answered`; a model's answer at or above its threshold is `proposed` until a person confirms it; a question no decider before the quorum answered is `escalated`, open for people. An answer never changes in place: `points retract` escalates the question to people again and moves the answer into `retractions` (#3351, ws-084). An ad-hoc point's answer keeps the question and candidates its ask gave as `asked`, and they are part of the inputs hash (#3403). A workspace copies this file next to its answer kind, as reference-workspace/answers/ does. Values stay within what YAML and JSON share. Fields other than these must start with x-.",
|
|
5
|
+
"description": "The front matter of one answer to a decision point (ws-058, #2739), as `chant workspace points ask` and `points answer` write it into the records directory of a record kind with an `answers` block. One record per point, declaration and inputs: its id is the point's name and the first 12 hex digits of the inputs hash, so the same question is answered once. A table's answer is `answered`; a model's answer at or above its threshold is `proposed` until a person confirms it; a question no decider before the quorum answered is `escalated`, open for people. An answer never changes in place: `points retract` escalates the question to people again and moves the answer into `retractions` (#3351, ws-084). An open question that no longer applies is `withdrawn` (`points withdraw`, #2888), and asking it again opens it again. An ad-hoc point's answer keeps the question and candidates its ask gave as `asked`, and they are part of the inputs hash (#3403). A workspace copies this file next to its answer kind, as reference-workspace/answers/ does. Values stay within what YAML and JSON share. Fields other than these must start with x-.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"patternProperties": { "^x-": {} },
|
|
@@ -65,6 +65,17 @@
|
|
|
65
65
|
"description": "Answers people took back, oldest first (`points retract`, #3351): each answer as it was, and who retracted it, when and why. A retracted question is escalated to people again.",
|
|
66
66
|
"items": { "$ref": "#/definitions/retraction" }
|
|
67
67
|
},
|
|
68
|
+
"withdrawn": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"description": "An open question that no longer applies, such as one about a work item its author changed, which a run asks about anew (`points withdraw`, #2888): who withdrew it, when and why. A withdrawn question is not open, and nobody answers it; asking it again opens it again.",
|
|
71
|
+
"required": ["by", "on"],
|
|
72
|
+
"properties": {
|
|
73
|
+
"by": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "description": "Who withdrew it." },
|
|
74
|
+
"on": { "$ref": "#/definitions/date" },
|
|
75
|
+
"note": { "type": "string", "minLength": 1, "description": "Why it was withdrawn (`points withdraw --note`)." }
|
|
76
|
+
},
|
|
77
|
+
"additionalProperties": false
|
|
78
|
+
},
|
|
68
79
|
"asked_on": { "$ref": "#/definitions/date" },
|
|
69
80
|
"answered_on": { "$ref": "#/definitions/date" },
|
|
70
81
|
"source": {
|
|
@@ -85,6 +96,10 @@
|
|
|
85
96
|
"if": { "properties": { "state": { "const": "escalated" } }, "required": ["state"] },
|
|
86
97
|
"then": { "not": { "required": ["answer"] }, "properties": { "decider": { "properties": { "kind": { "const": "quorum" } } } } }
|
|
87
98
|
},
|
|
99
|
+
{
|
|
100
|
+
"if": { "required": ["withdrawn"] },
|
|
101
|
+
"then": { "properties": { "state": { "enum": ["escalated", "proposed"] } } }
|
|
102
|
+
},
|
|
88
103
|
{
|
|
89
104
|
"if": { "properties": { "decider": { "properties": { "kind": { "const": "model" } }, "required": ["kind"] } }, "required": ["decider"] },
|
|
90
105
|
"then": { "required": ["probabilities", "confidence", "threshold"], "properties": { "decider": { "required": ["backend", "model"] } } }
|
|
@@ -614,7 +614,7 @@ export function isReadCall(argv: readonly string[]): boolean {
|
|
|
614
614
|
case "runs":
|
|
615
615
|
return !["start", "end", "record"].includes(sub ?? "");
|
|
616
616
|
case "points":
|
|
617
|
-
return !["ask", "answer", "retract"].includes(sub ?? "");
|
|
617
|
+
return !["ask", "answer", "retract", "withdraw"].includes(sub ?? "");
|
|
618
618
|
case "work":
|
|
619
619
|
return sub === "history";
|
|
620
620
|
case "wip":
|
|
@@ -10,7 +10,7 @@ import { join } from "node:path";
|
|
|
10
10
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
11
11
|
import { runDeclarationChecks } from "./checks";
|
|
12
12
|
import { cleanScratch, commitAll, contract, REPO, repo } from "./__fixtures__/contract-repo";
|
|
13
|
-
import { answerFields, answerPoint, askPoint, reasonFields, retractAnswer, type PointsWriteDocument } from "./decide";
|
|
13
|
+
import { answerFields, answerPoint, askPoint, reasonFields, retractAnswer, withdrawQuestion, type PointsWriteDocument } from "./decide";
|
|
14
14
|
import pointAnswerSchema from "./point-answer.schema.json";
|
|
15
15
|
import { responseAsk, workspacePoints } from "./points-cli";
|
|
16
16
|
import pointsSchema from "./points.schema.json";
|
|
@@ -293,13 +293,13 @@ describe("the intent walk's points, a note on an answer, and retracting it (#335
|
|
|
293
293
|
});
|
|
294
294
|
|
|
295
295
|
test("a workspace whose answer schema predates notes and retractions refuses them, rather than dropping them", () => {
|
|
296
|
-
expect(answerFields(pointAnswerSchema)).toEqual({ note: true, retractions: true, relayedBy: true, asked: true });
|
|
296
|
+
expect(answerFields(pointAnswerSchema)).toEqual({ note: true, retractions: true, relayedBy: true, asked: true, withdrawn: true });
|
|
297
297
|
const old = JSON.parse(JSON.stringify(pointAnswerSchema));
|
|
298
298
|
delete old.properties.note;
|
|
299
299
|
delete old.properties.retractions;
|
|
300
300
|
delete old.properties.relayed_by;
|
|
301
301
|
delete old.properties.asked;
|
|
302
|
-
expect(answerFields(old)).toEqual({ note: false, retractions: false, relayedBy: false, asked: false });
|
|
302
|
+
expect(answerFields(old)).toEqual({ note: false, retractions: false, relayedBy: false, asked: false, withdrawn: true });
|
|
303
303
|
});
|
|
304
304
|
|
|
305
305
|
test("an answer relayed for a person records who relayed it, and a retraction keeps it (#3402)", async () => {
|
|
@@ -399,6 +399,90 @@ describe("an ad-hoc question whose candidates come with the ask (#3403)", () =>
|
|
|
399
399
|
});
|
|
400
400
|
});
|
|
401
401
|
|
|
402
|
+
describe("withdrawing an open question that no longer applies (#2888)", () => {
|
|
403
|
+
const item = (title: string) => ({
|
|
404
|
+
"work-item.state": "proposed",
|
|
405
|
+
"work-item.title": title,
|
|
406
|
+
"work-item.source.ask.said": "make the port configurable",
|
|
407
|
+
"work-item.source.ask.by": "alice",
|
|
408
|
+
"work-item.source.ask.via": "hud",
|
|
409
|
+
"work-item.acceptance": [],
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("the earlier question about a changed item is withdrawn, and --open leaves it out", async () => {
|
|
413
|
+
const first = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Configurable port"), subject: "W-101", on }));
|
|
414
|
+
expect(first.question).toMatchObject({ state: "escalated", open: true, withdrawn: null });
|
|
415
|
+
// The author changes the item: the next ask is another question about W-101, and both are open.
|
|
416
|
+
const second = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Make the server port configurable"), subject: "W-101", on }));
|
|
417
|
+
expect(second.id).not.toBe(first.id);
|
|
418
|
+
const openAbout = async () => {
|
|
419
|
+
const listed = await workspacePoints({ cwd: root, open: true });
|
|
420
|
+
read.expectValid(listed);
|
|
421
|
+
if ("error" in listed) throw new Error(listed.error.message);
|
|
422
|
+
return listed.questions.filter((q) => q.subject === "W-101").map((q) => q.id);
|
|
423
|
+
};
|
|
424
|
+
expect((await openAbout()).sort()).toEqual([first.id, second.id].sort());
|
|
425
|
+
|
|
426
|
+
const withdrawn = ok(await withdrawQuestion({ cwd: root, id: first.id, by: ["factory"], note: `W-101 changed; ${second.id} asks it now.`, on: "2026-09-26" }));
|
|
427
|
+
expect(withdrawn).toMatchObject({ verb: "withdraw", written: true });
|
|
428
|
+
expect(withdrawn.question).toMatchObject({ state: "escalated", open: false, answer: null, withdrawn: { by: ["factory"], on: "2026-09-26", note: `W-101 changed; ${second.id} asks it now.` } });
|
|
429
|
+
expect(withdrawn.question.title).toMatch(/: withdrawn$/);
|
|
430
|
+
expect(fm(first.path)).toContain('withdrawn:\n by:\n - "factory"');
|
|
431
|
+
expect(await openAbout()).toEqual([second.id]);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("a withdrawn question is closed: nobody answers it, and refusals", async () => {
|
|
435
|
+
const asked = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Refusals"), on }));
|
|
436
|
+
expect(refused(await withdrawQuestion({ cwd: root, id: asked.id, by: [" "] }))).toBe("write-usage-invalid");
|
|
437
|
+
expect(ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], dryRun: true }))).toMatchObject({ verb: "withdraw", written: false, question: { open: false } });
|
|
438
|
+
expect(fm(asked.path)).not.toContain("withdrawn:");
|
|
439
|
+
ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], on }));
|
|
440
|
+
expect(refused(await answerPoint({ cwd: root, id: asked.id, answer: "proceed", by: ["alice"] }))).toBe("record-closed");
|
|
441
|
+
expect(refused(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"] }))).toBe("record-closed");
|
|
442
|
+
const table = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { "work-item.fits_small": true }, on }));
|
|
443
|
+
expect(refused(await withdrawQuestion({ cwd: root, id: table.id, by: ["factory"] }))).toBe("record-closed");
|
|
444
|
+
const all = await workspacePoints({ cwd: root });
|
|
445
|
+
if ("error" in all) throw new Error(all.error.message);
|
|
446
|
+
expect(all.summary.withdrawn).toBe(all.questions.filter((q) => q.withdrawn !== null).length);
|
|
447
|
+
expect(all.summary.withdrawn).toBeGreaterThan(0);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("asking a withdrawn question again opens it again", async () => {
|
|
451
|
+
const asked = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Port, again"), subject: "W-102", on }));
|
|
452
|
+
ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], on }));
|
|
453
|
+
// The item changed back: the same inputs ask the same question.
|
|
454
|
+
const again = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Port, again"), on: "2026-09-27" }));
|
|
455
|
+
expect(again).toMatchObject({ id: asked.id, reused: false, written: true, question: { state: "escalated", open: true, withdrawn: null, subject: "W-102", askedOn: "2026-09-27" } });
|
|
456
|
+
expect(fm(asked.path)).not.toContain("withdrawn:");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("a retracted question withdrawn and asked again is open for people again, its retractions kept", async () => {
|
|
460
|
+
const inputs = { "region.id": "region:app/server.mjs:1-1", "node.id": "decision:ref-009" };
|
|
461
|
+
const asked = ok(await askPoint({ cwd: root, point: "intent-origin", inputs, on }));
|
|
462
|
+
ok(await answerPoint({ cwd: root, id: asked.id, answer: "incidental", by: ["alice"], on }));
|
|
463
|
+
ok(await retractAnswer({ cwd: root, id: asked.id, by: ["alice"], on }));
|
|
464
|
+
ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], on }));
|
|
465
|
+
const again = ok(await askPoint({ cwd: root, point: "intent-origin", inputs, on }));
|
|
466
|
+
expect(again).toMatchObject({ reused: false, question: { state: "escalated", open: true, withdrawn: null, retractions: [{ answer: "incidental" }] } });
|
|
467
|
+
expect(again.question.title).toMatch(/: open for people$/);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("a workspace whose answer schema predates withdrawn refuses to withdraw", async () => {
|
|
471
|
+
expect(answerFields(pointAnswerSchema).withdrawn).toBe(true);
|
|
472
|
+
const asked = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Old schema"), on }));
|
|
473
|
+
const schemaFile = join(root, "answers", "answer.schema.json");
|
|
474
|
+
const original = readFileSync(schemaFile, "utf-8");
|
|
475
|
+
const old = JSON.parse(original);
|
|
476
|
+
delete old.properties.withdrawn;
|
|
477
|
+
writeFileSync(schemaFile, JSON.stringify(old, null, 2));
|
|
478
|
+
try {
|
|
479
|
+
expect(refused(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"] }))).toBe("answer-field-unsupported");
|
|
480
|
+
} finally {
|
|
481
|
+
writeFileSync(schemaFile, original);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
402
486
|
describe("the points file in check and on read (#2738)", () => {
|
|
403
487
|
test("a changed point warns on its answers, and an invalid points file is WSP116 and a points-invalid source", async () => {
|
|
404
488
|
const file = join(root, "decisions", "points.json");
|