@xoxoai/checkmate 0.6.6 → 0.7.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/checkmate.js +13375 -0
- package/package.json +14 -100
- package/plugin/.claude-plugin/plugin.json +10 -0
- package/plugin/bin/checkmate.ts +3 -0
- package/plugin/contracts/finding.schema.json +78 -0
- package/plugin/contracts/gap.schema.json +56 -0
- package/plugin/contracts/plan-candidate.schema.json +103 -0
- package/plugin/contracts/plan-delta.schema.json +63 -0
- package/plugin/contracts/plan-patch.schema.json +116 -0
- package/plugin/contracts/plan-request.schema.json +108 -0
- package/plugin/contracts/plan.schema.json +313 -0
- package/plugin/contracts/profile.schema.json +154 -0
- package/plugin/contracts/response.schema.json +25 -0
- package/plugin/contracts/result.schema.json +410 -0
- package/plugin/dist/checkmate.js +13012 -0
- package/plugin/package.json +4 -0
- package/plugin/references/browser.md +35 -0
- package/plugin/references/http.md +30 -0
- package/plugin/references/workflow.md +122 -0
- package/plugin/skills/execute/SKILL.md +92 -0
- package/plugin/skills/plan/SKILL.md +94 -0
- package/plugin/skills/report/SKILL.md +28 -0
- package/plugin/skills/revise/SKILL.md +43 -0
- package/plugin/skills/setup/SKILL.md +82 -0
- package/plugin/skills/status/SKILL.md +20 -0
- package/plugin/skills/ui/SKILL.md +26 -0
- package/plugin/src/actor.ts +21 -0
- package/plugin/src/cli.ts +1133 -0
- package/plugin/src/contracts.ts +342 -0
- package/plugin/src/index.ts +52 -0
- package/plugin/src/plans.ts +1028 -0
- package/plugin/src/render.ts +544 -0
- package/plugin/src/runs.ts +868 -0
- package/plugin/src/store.ts +384 -0
- package/plugin/src/targets.ts +205 -0
- package/plugin/src/types.ts +319 -0
- package/plugin/src/ui/assets/client.js +501 -0
- package/plugin/src/ui/assets/favicon.svg +1 -0
- package/plugin/src/ui/assets/fonts/inter-OFL.txt +93 -0
- package/plugin/src/ui/assets/fonts/inter.woff2 +0 -0
- package/plugin/src/ui/assets/fonts/jetbrains-mono-OFL.txt +93 -0
- package/plugin/src/ui/assets/fonts/jetbrains-mono.woff2 +0 -0
- package/plugin/src/ui/assets/fonts/space-grotesk-OFL.txt +93 -0
- package/plugin/src/ui/assets/fonts/space-grotesk.woff2 +0 -0
- package/plugin/src/ui/assets/style.css +1926 -0
- package/plugin/src/ui/edit.ts +305 -0
- package/plugin/src/ui/forms.ts +209 -0
- package/plugin/src/ui/html.ts +130 -0
- package/plugin/src/ui/layout.ts +198 -0
- package/plugin/src/ui/lists.ts +645 -0
- package/plugin/src/ui/plan.ts +577 -0
- package/plugin/src/ui/run.ts +489 -0
- package/plugin/src/ui/server.ts +684 -0
- package/plugin/src/validators.d.ts +13 -0
- package/plugin/src/validators.js +1 -0
- package/plugin/src/views.ts +839 -0
- package/LICENSE +0 -21
- package/README.md +0 -294
- package/bin/checkmate.js +0 -13
- package/dist/ai/client.d.ts +0 -64
- package/dist/ai/client.d.ts.map +0 -1
- package/dist/ai/client.js +0 -313
- package/dist/ai/client.js.map +0 -1
- package/dist/ai/message-handler.d.ts +0 -9
- package/dist/ai/message-handler.d.ts.map +0 -1
- package/dist/ai/message-handler.js +0 -50
- package/dist/ai/message-handler.js.map +0 -1
- package/dist/ai/message-history.d.ts +0 -17
- package/dist/ai/message-history.d.ts.map +0 -1
- package/dist/ai/message-history.js +0 -45
- package/dist/ai/message-history.js.map +0 -1
- package/dist/ai/prompts.d.ts +0 -4
- package/dist/ai/prompts.d.ts.map +0 -1
- package/dist/ai/prompts.js +0 -24
- package/dist/ai/prompts.js.map +0 -1
- package/dist/ai/rate-limit-policy.d.ts +0 -9
- package/dist/ai/rate-limit-policy.d.ts.map +0 -1
- package/dist/ai/rate-limit-policy.js +0 -34
- package/dist/ai/rate-limit-policy.js.map +0 -1
- package/dist/ai/tool-response-handler.d.ts +0 -16
- package/dist/ai/tool-response-handler.d.ts.map +0 -1
- package/dist/ai/tool-response-handler.js +0 -85
- package/dist/ai/tool-response-handler.js.map +0 -1
- package/dist/ai/turn-processor.d.ts +0 -34
- package/dist/ai/turn-processor.d.ts.map +0 -1
- package/dist/ai/turn-processor.js +0 -107
- package/dist/ai/turn-processor.js.map +0 -1
- package/dist/api/allocate-run-identity.d.ts +0 -8
- package/dist/api/allocate-run-identity.d.ts.map +0 -1
- package/dist/api/allocate-run-identity.js +0 -10
- package/dist/api/allocate-run-identity.js.map +0 -1
- package/dist/api/execute-prepared-run.d.ts +0 -22
- package/dist/api/execute-prepared-run.d.ts.map +0 -1
- package/dist/api/execute-prepared-run.js +0 -184
- package/dist/api/execute-prepared-run.js.map +0 -1
- package/dist/api/index.d.ts +0 -25
- package/dist/api/index.d.ts.map +0 -1
- package/dist/api/index.js +0 -69
- package/dist/api/index.js.map +0 -1
- package/dist/api/prepare-run.d.ts +0 -44
- package/dist/api/prepare-run.d.ts.map +0 -1
- package/dist/api/prepare-run.js +0 -261
- package/dist/api/prepare-run.js.map +0 -1
- package/dist/cli/diagnostic-relay.d.ts +0 -6
- package/dist/cli/diagnostic-relay.d.ts.map +0 -1
- package/dist/cli/diagnostic-relay.js +0 -6
- package/dist/cli/diagnostic-relay.js.map +0 -1
- package/dist/cli/main.d.ts +0 -21
- package/dist/cli/main.d.ts.map +0 -1
- package/dist/cli/main.js +0 -166
- package/dist/cli/main.js.map +0 -1
- package/dist/cli/protocol.d.ts +0 -57
- package/dist/cli/protocol.d.ts.map +0 -1
- package/dist/cli/protocol.js +0 -116
- package/dist/cli/protocol.js.map +0 -1
- package/dist/cli/reconcile-result.d.ts +0 -13
- package/dist/cli/reconcile-result.d.ts.map +0 -1
- package/dist/cli/reconcile-result.js +0 -300
- package/dist/cli/reconcile-result.js.map +0 -1
- package/dist/cli/run-parent.d.ts +0 -37
- package/dist/cli/run-parent.d.ts.map +0 -1
- package/dist/cli/run-parent.js +0 -491
- package/dist/cli/run-parent.js.map +0 -1
- package/dist/cli/signals.d.ts +0 -36
- package/dist/cli/signals.d.ts.map +0 -1
- package/dist/cli/signals.js +0 -85
- package/dist/cli/signals.js.map +0 -1
- package/dist/cli/static-commands.d.ts +0 -11
- package/dist/cli/static-commands.d.ts.map +0 -1
- package/dist/cli/static-commands.js +0 -83
- package/dist/cli/static-commands.js.map +0 -1
- package/dist/cli/worker-stream-guard.d.ts +0 -20
- package/dist/cli/worker-stream-guard.d.ts.map +0 -1
- package/dist/cli/worker-stream-guard.js +0 -41
- package/dist/cli/worker-stream-guard.js.map +0 -1
- package/dist/cli/worker.d.ts +0 -3
- package/dist/cli/worker.d.ts.map +0 -1
- package/dist/cli/worker.js +0 -111
- package/dist/cli/worker.js.map +0 -1
- package/dist/config/ingestion.d.ts +0 -58
- package/dist/config/ingestion.d.ts.map +0 -1
- package/dist/config/ingestion.js +0 -283
- package/dist/config/ingestion.js.map +0 -1
- package/dist/config/manifest.d.ts +0 -20
- package/dist/config/manifest.d.ts.map +0 -1
- package/dist/config/manifest.js +0 -91
- package/dist/config/manifest.js.map +0 -1
- package/dist/config/model-egress.d.ts +0 -10
- package/dist/config/model-egress.d.ts.map +0 -1
- package/dist/config/model-egress.js +0 -62
- package/dist/config/model-egress.js.map +0 -1
- package/dist/config/package-resolution.d.ts +0 -4
- package/dist/config/package-resolution.d.ts.map +0 -1
- package/dist/config/package-resolution.js +0 -24
- package/dist/config/package-resolution.js.map +0 -1
- package/dist/config/policy.d.ts +0 -44
- package/dist/config/policy.d.ts.map +0 -1
- package/dist/config/policy.js +0 -141
- package/dist/config/policy.js.map +0 -1
- package/dist/config/record.d.ts +0 -4
- package/dist/config/record.d.ts.map +0 -1
- package/dist/config/record.js +0 -10
- package/dist/config/record.js.map +0 -1
- package/dist/config/secrets.d.ts +0 -11
- package/dist/config/secrets.d.ts.map +0 -1
- package/dist/config/secrets.js +0 -42
- package/dist/config/secrets.js.map +0 -1
- package/dist/contracts/diagnostics.d.ts +0 -4
- package/dist/contracts/diagnostics.d.ts.map +0 -1
- package/dist/contracts/diagnostics.js +0 -31
- package/dist/contracts/diagnostics.js.map +0 -1
- package/dist/contracts/serialize.d.ts +0 -2
- package/dist/contracts/serialize.d.ts.map +0 -1
- package/dist/contracts/serialize.js +0 -90
- package/dist/contracts/serialize.js.map +0 -1
- package/dist/contracts/types.d.ts +0 -316
- package/dist/contracts/types.d.ts.map +0 -1
- package/dist/contracts/types.js +0 -74
- package/dist/contracts/types.js.map +0 -1
- package/dist/contracts/validator.d.ts +0 -8
- package/dist/contracts/validator.d.ts.map +0 -1
- package/dist/contracts/validator.js +0 -71
- package/dist/contracts/validator.js.map +0 -1
- package/dist/driver.d.ts +0 -97
- package/dist/driver.d.ts.map +0 -1
- package/dist/driver.js +0 -24
- package/dist/driver.js.map +0 -1
- package/dist/drivers/descriptor.d.ts +0 -15
- package/dist/drivers/descriptor.d.ts.map +0 -1
- package/dist/drivers/descriptor.js +0 -122
- package/dist/drivers/descriptor.js.map +0 -1
- package/dist/drivers/loader.d.ts +0 -18
- package/dist/drivers/loader.d.ts.map +0 -1
- package/dist/drivers/loader.js +0 -97
- package/dist/drivers/loader.js.map +0 -1
- package/dist/drivers/web/checkmate-driver.json +0 -47
- package/dist/drivers/web/index.d.ts +0 -6
- package/dist/drivers/web/index.d.ts.map +0 -1
- package/dist/drivers/web/index.js +0 -27
- package/dist/drivers/web/index.js.map +0 -1
- package/dist/drivers/web/session.d.ts +0 -25
- package/dist/drivers/web/session.d.ts.map +0 -1
- package/dist/drivers/web/session.js +0 -132
- package/dist/drivers/web/session.js.map +0 -1
- package/dist/drivers/web/tools/network-request-recorder.d.ts +0 -46
- package/dist/drivers/web/tools/network-request-recorder.d.ts.map +0 -1
- package/dist/drivers/web/tools/network-request-recorder.js +0 -164
- package/dist/drivers/web/tools/network-request-recorder.js.map +0 -1
- package/dist/drivers/web/tools/screenshot-service.d.ts +0 -10
- package/dist/drivers/web/tools/screenshot-service.d.ts.map +0 -1
- package/dist/drivers/web/tools/screenshot-service.js +0 -23
- package/dist/drivers/web/tools/screenshot-service.js.map +0 -1
- package/dist/drivers/web/tools/snapshot-filter/index.d.ts +0 -4
- package/dist/drivers/web/tools/snapshot-filter/index.d.ts.map +0 -1
- package/dist/drivers/web/tools/snapshot-filter/index.js +0 -4
- package/dist/drivers/web/tools/snapshot-filter/index.js.map +0 -1
- package/dist/drivers/web/tools/snapshot-filter/semantic-scorer.d.ts +0 -15
- package/dist/drivers/web/tools/snapshot-filter/semantic-scorer.d.ts.map +0 -1
- package/dist/drivers/web/tools/snapshot-filter/semantic-scorer.js +0 -99
- package/dist/drivers/web/tools/snapshot-filter/semantic-scorer.js.map +0 -1
- package/dist/drivers/web/tools/snapshot-filter/snapshot-filter.d.ts +0 -5
- package/dist/drivers/web/tools/snapshot-filter/snapshot-filter.d.ts.map +0 -1
- package/dist/drivers/web/tools/snapshot-filter/snapshot-filter.js +0 -56
- package/dist/drivers/web/tools/snapshot-filter/snapshot-filter.js.map +0 -1
- package/dist/drivers/web/tools/snapshot-filter/tree-reconstructor.d.ts +0 -3
- package/dist/drivers/web/tools/snapshot-filter/tree-reconstructor.d.ts.map +0 -1
- package/dist/drivers/web/tools/snapshot-filter/tree-reconstructor.js +0 -85
- package/dist/drivers/web/tools/snapshot-filter/tree-reconstructor.js.map +0 -1
- package/dist/drivers/web/tools/snapshot-service.d.ts +0 -31
- package/dist/drivers/web/tools/snapshot-service.d.ts.map +0 -1
- package/dist/drivers/web/tools/snapshot-service.js +0 -96
- package/dist/drivers/web/tools/snapshot-service.js.map +0 -1
- package/dist/drivers/web/tools/tool.d.ts +0 -76
- package/dist/drivers/web/tools/tool.d.ts.map +0 -1
- package/dist/drivers/web/tools/tool.js +0 -562
- package/dist/drivers/web/tools/tool.js.map +0 -1
- package/dist/drivers/web/tools/transient-state-tracker.d.ts +0 -26
- package/dist/drivers/web/tools/transient-state-tracker.d.ts.map +0 -1
- package/dist/drivers/web/tools/transient-state-tracker.js +0 -301
- package/dist/drivers/web/tools/transient-state-tracker.js.map +0 -1
- package/dist/drivers/web/tools/types.d.ts +0 -11
- package/dist/drivers/web/tools/types.d.ts.map +0 -1
- package/dist/drivers/web/tools/types.js +0 -2
- package/dist/drivers/web/tools/types.js.map +0 -1
- package/dist/evidence/atomic-file.d.ts +0 -23
- package/dist/evidence/atomic-file.d.ts.map +0 -1
- package/dist/evidence/atomic-file.js +0 -104
- package/dist/evidence/atomic-file.js.map +0 -1
- package/dist/evidence/layout.d.ts +0 -30
- package/dist/evidence/layout.d.ts.map +0 -1
- package/dist/evidence/layout.js +0 -152
- package/dist/evidence/layout.js.map +0 -1
- package/dist/evidence/retention.d.ts +0 -5
- package/dist/evidence/retention.d.ts.map +0 -1
- package/dist/evidence/retention.js +0 -10
- package/dist/evidence/retention.js.map +0 -1
- package/dist/evidence/store.d.ts +0 -101
- package/dist/evidence/store.d.ts.map +0 -1
- package/dist/evidence/store.js +0 -375
- package/dist/evidence/store.js.map +0 -1
- package/dist/evidence/terminal-finalizer.d.ts +0 -19
- package/dist/evidence/terminal-finalizer.d.ts.map +0 -1
- package/dist/evidence/terminal-finalizer.js +0 -56
- package/dist/evidence/terminal-finalizer.js.map +0 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -12
- package/dist/index.js.map +0 -1
- package/dist/logging/invocation-logger.d.ts +0 -4
- package/dist/logging/invocation-logger.d.ts.map +0 -1
- package/dist/logging/invocation-logger.js +0 -25
- package/dist/logging/invocation-logger.js.map +0 -1
- package/dist/logging/level-logger.d.ts +0 -11
- package/dist/logging/level-logger.d.ts.map +0 -1
- package/dist/logging/level-logger.js +0 -82
- package/dist/logging/level-logger.js.map +0 -1
- package/dist/logging/types.d.ts +0 -8
- package/dist/logging/types.d.ts.map +0 -1
- package/dist/logging/types.js +0 -7
- package/dist/logging/types.js.map +0 -1
- package/dist/redaction/diagnostic-sanitizer.d.ts +0 -7
- package/dist/redaction/diagnostic-sanitizer.d.ts.map +0 -1
- package/dist/redaction/diagnostic-sanitizer.js +0 -42
- package/dist/redaction/diagnostic-sanitizer.js.map +0 -1
- package/dist/redaction/redactor.d.ts +0 -40
- package/dist/redaction/redactor.d.ts.map +0 -1
- package/dist/redaction/redactor.js +0 -176
- package/dist/redaction/redactor.js.map +0 -1
- package/dist/redaction/scrub.d.ts +0 -29
- package/dist/redaction/scrub.d.ts.map +0 -1
- package/dist/redaction/scrub.js +0 -54
- package/dist/redaction/scrub.js.map +0 -1
- package/dist/runtime/config.d.ts +0 -16
- package/dist/runtime/config.d.ts.map +0 -1
- package/dist/runtime/config.js +0 -2
- package/dist/runtime/config.js.map +0 -1
- package/dist/runtime/driver-boundary.d.ts +0 -16
- package/dist/runtime/driver-boundary.d.ts.map +0 -1
- package/dist/runtime/driver-boundary.js +0 -71
- package/dist/runtime/driver-boundary.js.map +0 -1
- package/dist/runtime/driver-session.d.ts +0 -9
- package/dist/runtime/driver-session.d.ts.map +0 -1
- package/dist/runtime/driver-session.js +0 -64
- package/dist/runtime/driver-session.js.map +0 -1
- package/dist/runtime/internal-step-evidence.d.ts +0 -30
- package/dist/runtime/internal-step-evidence.d.ts.map +0 -1
- package/dist/runtime/internal-step-evidence.js +0 -82
- package/dist/runtime/internal-step-evidence.js.map +0 -1
- package/dist/runtime/result-adapter.d.ts +0 -9
- package/dist/runtime/result-adapter.d.ts.map +0 -1
- package/dist/runtime/result-adapter.js +0 -77
- package/dist/runtime/result-adapter.js.map +0 -1
- package/dist/runtime/runner.d.ts +0 -43
- package/dist/runtime/runner.d.ts.map +0 -1
- package/dist/runtime/runner.js +0 -72
- package/dist/runtime/runner.js.map +0 -1
- package/dist/runtime/scenario-control.d.ts +0 -40
- package/dist/runtime/scenario-control.d.ts.map +0 -1
- package/dist/runtime/scenario-control.js +0 -94
- package/dist/runtime/scenario-control.js.map +0 -1
- package/dist/runtime/scenario-runner.d.ts +0 -41
- package/dist/runtime/scenario-runner.d.ts.map +0 -1
- package/dist/runtime/scenario-runner.js +0 -274
- package/dist/runtime/scenario-runner.js.map +0 -1
- package/dist/runtime/scenario-state.d.ts +0 -55
- package/dist/runtime/scenario-state.d.ts.map +0 -1
- package/dist/runtime/scenario-state.js +0 -128
- package/dist/runtime/scenario-state.js.map +0 -1
- package/dist/runtime/step-execution.d.ts +0 -27
- package/dist/runtime/step-execution.d.ts.map +0 -1
- package/dist/runtime/step-execution.js +0 -143
- package/dist/runtime/step-execution.js.map +0 -1
- package/dist/runtime/text.d.ts +0 -10
- package/dist/runtime/text.d.ts.map +0 -1
- package/dist/runtime/text.js +0 -25
- package/dist/runtime/text.js.map +0 -1
- package/dist/runtime/types.d.ts +0 -53
- package/dist/runtime/types.d.ts.map +0 -1
- package/dist/runtime/types.js +0 -2
- package/dist/runtime/types.js.map +0 -1
- package/dist/runtime/usage-tracker.d.ts +0 -37
- package/dist/runtime/usage-tracker.d.ts.map +0 -1
- package/dist/runtime/usage-tracker.js +0 -110
- package/dist/runtime/usage-tracker.js.map +0 -1
- package/dist/tools/define-agent-tool.d.ts +0 -20
- package/dist/tools/define-agent-tool.d.ts.map +0 -1
- package/dist/tools/define-agent-tool.js +0 -30
- package/dist/tools/define-agent-tool.js.map +0 -1
- package/dist/tools/dispatcher.d.ts +0 -19
- package/dist/tools/dispatcher.d.ts.map +0 -1
- package/dist/tools/dispatcher.js +0 -91
- package/dist/tools/dispatcher.js.map +0 -1
- package/dist/tools/loop-detector.d.ts +0 -27
- package/dist/tools/loop-detector.d.ts.map +0 -1
- package/dist/tools/loop-detector.js +0 -76
- package/dist/tools/loop-detector.js.map +0 -1
- package/dist/tools/registry.d.ts +0 -19
- package/dist/tools/registry.d.ts.map +0 -1
- package/dist/tools/registry.js +0 -54
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/step/result-tool.d.ts +0 -7
- package/dist/tools/step/result-tool.d.ts.map +0 -1
- package/dist/tools/step/result-tool.js +0 -34
- package/dist/tools/step/result-tool.js.map +0 -1
- package/dist/tools/tool-contract.d.ts +0 -3
- package/dist/tools/tool-contract.d.ts.map +0 -1
- package/dist/tools/tool-contract.js +0 -2
- package/dist/tools/tool-contract.js.map +0 -1
- package/dist/tools/types.d.ts +0 -210
- package/dist/tools/types.d.ts.map +0 -1
- package/dist/tools/types.js +0 -4
- package/dist/tools/types.js.map +0 -1
- package/docs/CLI.md +0 -59
- package/docs/CONFIGURATION.md +0 -105
- package/docs/DRIVERS.md +0 -79
- package/docs/EVIDENCE.md +0 -64
- package/schemas/checkmate-config.v1.json +0 -176
- package/schemas/describe-result.v1.json +0 -414
- package/schemas/driver-descriptor.v1.json +0 -66
- package/schemas/run-request.v1.json +0 -67
- package/schemas/run-result.v1.json +0 -333
- package/schemas/validation-result.v1.json +0 -86
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
import type { Gap, Plan, PlanCandidate, PlanDelta, PlanPatch, PlanRequest, Profile } from './types.ts'
|
|
2
|
+
import type { Change, Reply, Store } from './store.ts'
|
|
3
|
+
import { all, canonical, completed, digest, execute, one, ulid, write } from './store.ts'
|
|
4
|
+
import type { PlanDraft, Test } from './contracts.ts'
|
|
5
|
+
import {
|
|
6
|
+
approvalPolicy,
|
|
7
|
+
dependencyCheck,
|
|
8
|
+
derivePlan,
|
|
9
|
+
expectDigest,
|
|
10
|
+
gapsAffecting,
|
|
11
|
+
index,
|
|
12
|
+
planCheck,
|
|
13
|
+
planTargetCheck,
|
|
14
|
+
references,
|
|
15
|
+
requireChoice,
|
|
16
|
+
requireCondition,
|
|
17
|
+
requireIdentifier,
|
|
18
|
+
requireText,
|
|
19
|
+
revisionReference,
|
|
20
|
+
same,
|
|
21
|
+
testReference,
|
|
22
|
+
validate,
|
|
23
|
+
} from './contracts.ts'
|
|
24
|
+
import { targetRevision, toolCheck } from './targets.ts'
|
|
25
|
+
|
|
26
|
+
export type PlanRevision = { plan: Plan; sha256: string }
|
|
27
|
+
export type PlanTool = Omit<Profile['tools'][number], 'reachable'> & { reachable?: boolean }
|
|
28
|
+
export type GapAnswer = {
|
|
29
|
+
planId: string
|
|
30
|
+
gapId: string
|
|
31
|
+
kind: string
|
|
32
|
+
value: string
|
|
33
|
+
source?: string | null
|
|
34
|
+
rationale: string
|
|
35
|
+
expectSha256?: string
|
|
36
|
+
}
|
|
37
|
+
export type Verdict = { reference: string; rationale: string; expectSha256?: string }
|
|
38
|
+
export type GateInput = {
|
|
39
|
+
reference: string
|
|
40
|
+
tests?: string[]
|
|
41
|
+
build: string
|
|
42
|
+
partial?: string
|
|
43
|
+
retestOf?: string | null
|
|
44
|
+
}
|
|
45
|
+
export type PatchOperation = { op: 'add' | 'remove' | 'replace'; path: string; value?: unknown }
|
|
46
|
+
type RequestRow = { plan_id: string; target_id: string; target_revision: number; body: string }
|
|
47
|
+
type DecisionRow = {
|
|
48
|
+
id: string
|
|
49
|
+
gap_id: string
|
|
50
|
+
kind: 'clarify' | 'accept_assumption' | 'defer'
|
|
51
|
+
value: string
|
|
52
|
+
source: string | null
|
|
53
|
+
rationale: string
|
|
54
|
+
actor: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const decisionKinds = ['clarify', 'accept_assumption', 'defer'] as const
|
|
58
|
+
const partialModes = ['wait_for_resolution', 'ready_only'] as const
|
|
59
|
+
|
|
60
|
+
export function openPlan(
|
|
61
|
+
store: Store,
|
|
62
|
+
actor: string | undefined,
|
|
63
|
+
input: { planId: string; target: string; request: unknown }
|
|
64
|
+
) {
|
|
65
|
+
return write(store, { actor, command: 'plan open', subject: input.planId, input }, (operationId) => {
|
|
66
|
+
const planId = requireIdentifier(input.planId, 'plan ID')
|
|
67
|
+
const target = revisionReference(requireText(input.target, 'target (--target)'), 'target reference')
|
|
68
|
+
requireCondition(target.revision !== null, `Name the target revision: --target ${target.id}@<revision>`)
|
|
69
|
+
const profile = targetRevision(store, target.id, target.revision).profile
|
|
70
|
+
const request = validate('plan-request', input.request)
|
|
71
|
+
requireCondition(
|
|
72
|
+
request.requirements?.length || request.context?.sources.length,
|
|
73
|
+
'A plan request needs requirements or context sources'
|
|
74
|
+
)
|
|
75
|
+
index(request.requirements ?? [], 'id', 'requirement')
|
|
76
|
+
for (const tool of request.tools ?? [])
|
|
77
|
+
if (typeof tool === 'string')
|
|
78
|
+
requireCondition(
|
|
79
|
+
profile.tools.some((known) => known.name === tool),
|
|
80
|
+
`Unknown tool ${tool}. The target profile has: ${profile.tools.map((known) => known.name).join(', ') || 'no tools'}.`
|
|
81
|
+
)
|
|
82
|
+
else {
|
|
83
|
+
toolCheck(tool)
|
|
84
|
+
requireCondition(
|
|
85
|
+
!profile.tools.some((known) => known.name === tool.name),
|
|
86
|
+
`Tool ${tool.name} is in the target profile. Name it as a string.`
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
const names = planTools(request, profile).map((tool) => tool.name)
|
|
90
|
+
requireCondition(new Set(names).size === names.length, 'Duplicate tool name in the plan request')
|
|
91
|
+
const body = canonical(request)
|
|
92
|
+
const data = { planId, target: `${target.id}@${target.revision}` }
|
|
93
|
+
const existing = one<RequestRow>(store, 'SELECT * FROM plan_requests WHERE plan_id = ?', planId)
|
|
94
|
+
if (existing) {
|
|
95
|
+
requireCondition(
|
|
96
|
+
existing.target_id === target.id &&
|
|
97
|
+
existing.target_revision === target.revision &&
|
|
98
|
+
existing.body === body,
|
|
99
|
+
`Plan ${planId} is open with another request. Use a new plan ID.`
|
|
100
|
+
)
|
|
101
|
+
return { reply: completed(planId, { ...data, recorded: false }), subjects: [] }
|
|
102
|
+
}
|
|
103
|
+
execute(
|
|
104
|
+
store,
|
|
105
|
+
'INSERT INTO plan_requests (plan_id, target_id, target_revision, body, operation_id) VALUES (?, ?, ?, ?, ?)',
|
|
106
|
+
planId,
|
|
107
|
+
target.id,
|
|
108
|
+
target.revision,
|
|
109
|
+
body,
|
|
110
|
+
operationId
|
|
111
|
+
)
|
|
112
|
+
return { reply: completed(planId, { ...data, recorded: true }), subjects: [`plan:${planId}`, 'inbox'] }
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function planRequest(store: Store, planId: string) {
|
|
117
|
+
const row = one<RequestRow>(store, 'SELECT * FROM plan_requests WHERE plan_id = ?', planId)
|
|
118
|
+
requireCondition(row, `Unknown plan: ${planId}. Run plan open first.`)
|
|
119
|
+
const request: PlanRequest = JSON.parse(row.body)
|
|
120
|
+
return { request, target: targetRevision(store, row.target_id, row.target_revision) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function planTools(request: PlanRequest, profile: Profile): PlanTool[] {
|
|
124
|
+
const named = (request.tools ?? []).filter((tool) => typeof tool === 'string')
|
|
125
|
+
const added = (request.tools ?? []).filter((tool) => typeof tool !== 'string')
|
|
126
|
+
const kept = named.length ? profile.tools.filter((tool) => named.includes(tool.name)) : profile.tools
|
|
127
|
+
return [...kept, ...added]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function showPlanRequest(store: Store, planId: string) {
|
|
131
|
+
const { request, target } = planRequest(store, planId)
|
|
132
|
+
return completed(planId, {
|
|
133
|
+
planId,
|
|
134
|
+
target: `${target.profile.targetId}@${target.revision}`,
|
|
135
|
+
objective: request.objective,
|
|
136
|
+
requirements: request.requirements ?? [],
|
|
137
|
+
sourceContext: request.sourceContext ?? [],
|
|
138
|
+
context: request.context ?? { sources: [] },
|
|
139
|
+
tools: planTools(request, target.profile),
|
|
140
|
+
capabilities: target.profile.capabilities,
|
|
141
|
+
allowedOperations: target.profile.allowedOperations,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function planRevision(store: Store, planId: string, revision: number | null = null): PlanRevision {
|
|
146
|
+
const row = one<{ body: string; sha256: string }>(
|
|
147
|
+
store,
|
|
148
|
+
revision === null
|
|
149
|
+
? 'SELECT body, sha256 FROM latest_plans WHERE plan_id = ?'
|
|
150
|
+
: 'SELECT body, sha256 FROM plan_revisions WHERE plan_id = ? AND revision = ?',
|
|
151
|
+
planId,
|
|
152
|
+
...(revision === null ? [] : [revision])
|
|
153
|
+
)
|
|
154
|
+
if (!row) {
|
|
155
|
+
planRequest(store, planId)
|
|
156
|
+
throw new Error(
|
|
157
|
+
revision === null
|
|
158
|
+
? `Plan ${planId} has no revision. Run plan save first.`
|
|
159
|
+
: `Plan ${planId} has no revision ${revision}`
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
return { plan: JSON.parse(row.body), sha256: row.sha256 }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function latestPlans(store: Store) {
|
|
166
|
+
const rows = all<{ plan_id: string; body: string }>(
|
|
167
|
+
store,
|
|
168
|
+
'SELECT plan_id, body FROM latest_plans ORDER BY plan_id'
|
|
169
|
+
)
|
|
170
|
+
return new Map(rows.map((row) => [row.plan_id, JSON.parse(row.body) as Plan]))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function checkedRevision(store: Store, draft: PlanDraft) {
|
|
174
|
+
const plan = planCheck(derivePlan(draft))
|
|
175
|
+
planTargetCheck(plan, targetRevision(store, plan.target.targetId, plan.target.revision).profile)
|
|
176
|
+
const others = latestPlans(store)
|
|
177
|
+
others.delete(plan.planId)
|
|
178
|
+
dependencyCheck(plan, others)
|
|
179
|
+
const body = canonical(plan)
|
|
180
|
+
return { plan, body, sha256: digest(body) }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function revisionChange(plan: Plan, sha256: string): Change {
|
|
184
|
+
const blockers = plan.gaps.filter((gap) => gap.status === 'open' && gap.severity === 'blocking')
|
|
185
|
+
return {
|
|
186
|
+
reply: {
|
|
187
|
+
status: blockers.length ? 'needs_decision' : 'completed',
|
|
188
|
+
subject: `${plan.planId}@${plan.revision}`,
|
|
189
|
+
reasons: blockers.map((gap) => `${gap.id}: ${gap.question}`),
|
|
190
|
+
data: { planId: plan.planId, revision: plan.revision, sha256 },
|
|
191
|
+
},
|
|
192
|
+
subjects: [`plan:${plan.planId}`, 'inbox'],
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function storeRevision(store: Store, draft: PlanDraft, operationId: string): Change {
|
|
197
|
+
const { plan, body, sha256 } = checkedRevision(store, draft)
|
|
198
|
+
execute(
|
|
199
|
+
store,
|
|
200
|
+
'INSERT INTO plan_revisions (plan_id, revision, previous_revision, sha256, body, operation_id) VALUES (?, ?, ?, ?, ?, ?)',
|
|
201
|
+
plan.planId,
|
|
202
|
+
plan.revision,
|
|
203
|
+
plan.previousRevision,
|
|
204
|
+
sha256,
|
|
205
|
+
body,
|
|
206
|
+
operationId
|
|
207
|
+
)
|
|
208
|
+
return revisionChange(plan, sha256)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function planCandidate(plan: Plan): Required<PlanCandidate> {
|
|
212
|
+
return {
|
|
213
|
+
requirements: plan.requirements,
|
|
214
|
+
tests: plan.tests.map(({ readiness, gapIds, ...test }) => test),
|
|
215
|
+
gaps: plan.gaps,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function frozenRevision(store: Store, planId: string, revision: number) {
|
|
220
|
+
if (one(store, 'SELECT 1 FROM runs WHERE plan_id = ? AND plan_revision = ?', planId, revision)) return 'run'
|
|
221
|
+
const approved = one(
|
|
222
|
+
store,
|
|
223
|
+
"SELECT 1 FROM approvals WHERE subject_kind = 'plan' AND subject_id = ? AND verdict = 'approved'",
|
|
224
|
+
`${planId}@${revision}`
|
|
225
|
+
)
|
|
226
|
+
return approved ? 'approved' : null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function editPlan(
|
|
230
|
+
store: Store,
|
|
231
|
+
actor: string | undefined,
|
|
232
|
+
input: { reference: string; candidate: unknown; expectSha256?: string }
|
|
233
|
+
) {
|
|
234
|
+
return write(store, { actor, command: 'plan edit', subject: input.reference, input }, (operationId) =>
|
|
235
|
+
storeEdit(store, operationId, editableRevision(store, input.reference, input.expectSha256), input.candidate)
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function patchPlan(
|
|
240
|
+
store: Store,
|
|
241
|
+
actor: string | undefined,
|
|
242
|
+
input: { reference: string; patch: unknown; expectSha256?: string }
|
|
243
|
+
) {
|
|
244
|
+
return write(store, { actor, command: 'plan edit', subject: input.reference, input }, (operationId) => {
|
|
245
|
+
const base = editableRevision(store, input.reference, input.expectSha256)
|
|
246
|
+
const patch = validate('plan-patch', input.patch)
|
|
247
|
+
return storeEdit(store, operationId, base, patchedCandidate(base.plan, patch))
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function adoptGaps(
|
|
252
|
+
store: Store,
|
|
253
|
+
actor: string | undefined,
|
|
254
|
+
input: { reference: string; runId: string; gapIds?: string[]; expectSha256?: string }
|
|
255
|
+
) {
|
|
256
|
+
return write(store, { actor, command: 'plan adopt-gaps', subject: input.reference, input }, (operationId) => {
|
|
257
|
+
const base = editableRevision(store, input.reference, input.expectSha256)
|
|
258
|
+
const planId = base.plan.planId
|
|
259
|
+
const run = one<{ plan_id: string }>(store, 'SELECT plan_id FROM runs WHERE run_id = ?', input.runId)
|
|
260
|
+
requireCondition(run, `Unknown run: ${input.runId}`)
|
|
261
|
+
requireCondition(run.plan_id === planId, `Run ${input.runId} tests plan ${run.plan_id}, not ${planId}`)
|
|
262
|
+
const recorded = planRunGaps(store, planId).filter((item) => item.runId === input.runId)
|
|
263
|
+
const gaps =
|
|
264
|
+
input.gapIds === undefined
|
|
265
|
+
? recorded.filter((item) => !item.adopted).map((item) => item.gap)
|
|
266
|
+
: input.gapIds.map((id) => {
|
|
267
|
+
const item = recorded.find((item) => item.gap.id === id)
|
|
268
|
+
requireCondition(item, `Run ${input.runId} has no gap ${id}`)
|
|
269
|
+
requireCondition(!item.adopted, `Gap ${id} of run ${input.runId} is in plan ${planId}`)
|
|
270
|
+
return item.gap
|
|
271
|
+
})
|
|
272
|
+
requireCondition(gaps.length, `Run ${input.runId} has no gap to adopt`)
|
|
273
|
+
return adoption(store, operationId, base, input.runId, gaps)
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function adoption(store: Store, operationId: string, base: PlanRevision, runId: string, gaps: Gap[]): Change {
|
|
278
|
+
const candidate = planCandidate(base.plan)
|
|
279
|
+
const change = storeEdit(store, operationId, base, { ...candidate, gaps: [...candidate.gaps, ...gaps] })
|
|
280
|
+
change.reply.data.runId = runId
|
|
281
|
+
change.reply.data.adopted = gaps.map((gap) => gap.id)
|
|
282
|
+
change.subjects.push(`run:${runId}`)
|
|
283
|
+
return change
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export type RunGap = { runId: string; gap: Gap; adopted: boolean; at: string }
|
|
287
|
+
|
|
288
|
+
export function planRunGaps(store: Store, planId: string): RunGap[] {
|
|
289
|
+
return all<{ run_id: string; body: string; adopted: number; at: string }>(
|
|
290
|
+
store,
|
|
291
|
+
`SELECT g.run_id, g.body, o.at, EXISTS (SELECT 1 FROM plan_gaps p
|
|
292
|
+
WHERE p.plan_id = r.plan_id AND p.revision > r.plan_revision AND p.gap_id = g.id) AS adopted
|
|
293
|
+
FROM run_gaps g JOIN runs r ON r.run_id = g.run_id JOIN operations o ON o.id = g.operation_id
|
|
294
|
+
WHERE r.plan_id = ? ORDER BY g.rowid`,
|
|
295
|
+
planId
|
|
296
|
+
).map((row) => ({ runId: row.run_id, gap: JSON.parse(row.body), adopted: row.adopted === 1, at: row.at }))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function editableRevision(store: Store, text: string, expectSha256: string | undefined) {
|
|
300
|
+
const reference = revisionReference(requireText(text, 'plan reference'), 'plan reference')
|
|
301
|
+
requireCondition(reference.revision !== null, `Name the plan revision: ${reference.id}@<revision>`)
|
|
302
|
+
const base = planRevision(store, reference.id, reference.revision)
|
|
303
|
+
const subject = `${reference.id}@${reference.revision}`
|
|
304
|
+
const latest = planRevision(store, reference.id).plan.revision
|
|
305
|
+
requireCondition(
|
|
306
|
+
latest === reference.revision,
|
|
307
|
+
`${subject} is not the latest revision. Edit ${reference.id}@${latest}.`
|
|
308
|
+
)
|
|
309
|
+
expectDigest(base.sha256, expectSha256, subject)
|
|
310
|
+
return base
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function storeEdit(store: Store, operationId: string, base: PlanRevision, value: unknown): Change {
|
|
314
|
+
const candidate = validate('plan-candidate', value)
|
|
315
|
+
const planId = base.plan.planId
|
|
316
|
+
const frozen = frozenRevision(store, planId, base.plan.revision)
|
|
317
|
+
const { coverage, tests, ...kept } = structuredClone(base.plan)
|
|
318
|
+
const draft: PlanDraft = {
|
|
319
|
+
...kept,
|
|
320
|
+
...(frozen ? { revision: base.plan.revision + 1, previousRevision: base.plan.revision } : {}),
|
|
321
|
+
requirements: candidate.requirements ?? kept.requirements,
|
|
322
|
+
tests: candidate.tests,
|
|
323
|
+
gaps: candidate.gaps,
|
|
324
|
+
}
|
|
325
|
+
for (const decision of pendingDecisions(store, planId))
|
|
326
|
+
requireCondition(
|
|
327
|
+
draft.gaps.some((gap) => gap.id === decision.gap_id && gap.status === 'open'),
|
|
328
|
+
`Gap ${decision.gap_id} has a pending decision ${decision.id}. Keep the gap open.`
|
|
329
|
+
)
|
|
330
|
+
for (const test of draft.tests) {
|
|
331
|
+
const before = base.plan.tests.find((item) => item.id === test.id)?.assertions ?? []
|
|
332
|
+
const used = usedAssertionIds(store, planId, test.id)
|
|
333
|
+
for (const assertion of test.assertions)
|
|
334
|
+
requireCondition(
|
|
335
|
+
before.some((item) => item.id === assertion.id) || !used.includes(assertion.id),
|
|
336
|
+
`Assertion ID ${test.id}.${assertion.id} was used in an earlier revision. Use a new ID.`
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
if (frozen) {
|
|
340
|
+
const change = storeRevision(store, draft, operationId)
|
|
341
|
+
change.reply.data.edit = 'new_revision'
|
|
342
|
+
change.reply.data.reason = frozen
|
|
343
|
+
return change
|
|
344
|
+
}
|
|
345
|
+
const { plan, body, sha256 } = checkedRevision(store, draft)
|
|
346
|
+
execute(
|
|
347
|
+
store,
|
|
348
|
+
'UPDATE plan_revisions SET sha256 = ?, body = ?, operation_id = ? WHERE plan_id = ? AND revision = ?',
|
|
349
|
+
sha256,
|
|
350
|
+
body,
|
|
351
|
+
operationId,
|
|
352
|
+
plan.planId,
|
|
353
|
+
plan.revision
|
|
354
|
+
)
|
|
355
|
+
const change = revisionChange(plan, sha256)
|
|
356
|
+
change.reply.data.edit = 'in_place'
|
|
357
|
+
change.reply.data.previousSha256 = base.sha256
|
|
358
|
+
return change
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function patchedCandidate(plan: Plan, patch: PlanPatch): Required<PlanCandidate> {
|
|
362
|
+
const candidate = planCandidate(structuredClone(plan))
|
|
363
|
+
return {
|
|
364
|
+
requirements: patchList(candidate.requirements, patch.requirements, 'requirement', (item, id, change) =>
|
|
365
|
+
item ? { ...item, ...change } : { id, ...newFields(`requirement ${id}`, change, ['text', 'source']) }
|
|
366
|
+
),
|
|
367
|
+
tests: patchList(candidate.tests, patch.tests, 'test', (item, id, change) => {
|
|
368
|
+
const { assertions, ...fields } = change
|
|
369
|
+
const test = item
|
|
370
|
+
? { ...item, ...fields }
|
|
371
|
+
: {
|
|
372
|
+
id,
|
|
373
|
+
assertions: [],
|
|
374
|
+
...newFields(
|
|
375
|
+
`test ${id}`,
|
|
376
|
+
fields,
|
|
377
|
+
['title', 'requirementIds', 'capability', 'operations', 'action'],
|
|
378
|
+
{ dependsOn: [], preconditions: [] }
|
|
379
|
+
),
|
|
380
|
+
}
|
|
381
|
+
test.assertions = patchList(test.assertions, assertions, `assertion in test ${id}`, (old, key, value) =>
|
|
382
|
+
old
|
|
383
|
+
? { ...old, ...value }
|
|
384
|
+
: { id: key, ...newFields(`assertion ${id}.${key}`, value, ['check', 'checkKind']) }
|
|
385
|
+
)
|
|
386
|
+
return test
|
|
387
|
+
}),
|
|
388
|
+
gaps: patchList(candidate.gaps, patch.gaps, 'gap', (item, id, change) =>
|
|
389
|
+
item
|
|
390
|
+
? { ...item, ...change }
|
|
391
|
+
: {
|
|
392
|
+
id,
|
|
393
|
+
status: 'open',
|
|
394
|
+
decisionId: null,
|
|
395
|
+
...newFields(`gap ${id}`, change, [
|
|
396
|
+
'severity',
|
|
397
|
+
'requirementIds',
|
|
398
|
+
'testIds',
|
|
399
|
+
'question',
|
|
400
|
+
'consequence',
|
|
401
|
+
]),
|
|
402
|
+
}
|
|
403
|
+
),
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function patchList<T extends { id: string }, C extends object>(
|
|
408
|
+
items: T[],
|
|
409
|
+
changes: Record<string, C | null> | undefined,
|
|
410
|
+
name: string,
|
|
411
|
+
apply: (item: T | undefined, id: string, change: C) => T
|
|
412
|
+
) {
|
|
413
|
+
const result = [...items]
|
|
414
|
+
for (const [id, change] of Object.entries(changes ?? {})) {
|
|
415
|
+
const position = result.findIndex((item) => item.id === id)
|
|
416
|
+
if (change === null) {
|
|
417
|
+
requireCondition(position >= 0, `Cannot remove unknown ${name} ${id}`)
|
|
418
|
+
result.splice(position, 1)
|
|
419
|
+
} else if (position >= 0) result[position] = apply(result[position], id, change)
|
|
420
|
+
else result.push(apply(undefined, id, change))
|
|
421
|
+
}
|
|
422
|
+
return result
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function newFields<C extends object>(name: string, change: C, required: string[], defaults: Partial<C> = {}) {
|
|
426
|
+
const missing = required.filter((field) => !(field in change))
|
|
427
|
+
requireCondition(!missing.length, `New ${name} needs ${missing.join(', ')}`)
|
|
428
|
+
return { ...defaults, ...change } as Required<C>
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function savePlan(store: Store, actor: string | undefined, input: { planId: string; candidate: unknown }) {
|
|
432
|
+
return write(store, { actor, command: 'plan save', subject: input.planId, input }, (operationId) => {
|
|
433
|
+
const { request, target } = planRequest(store, input.planId)
|
|
434
|
+
requireCondition(
|
|
435
|
+
!one(store, 'SELECT 1 FROM plan_revisions WHERE plan_id = ?', input.planId),
|
|
436
|
+
`Plan ${input.planId} has revision 1. Use plan edit, or gap answer and plan revise, to change it.`
|
|
437
|
+
)
|
|
438
|
+
const candidate = validate('plan-candidate', input.candidate)
|
|
439
|
+
const requirements = candidateRequirements(request, candidate.requirements)
|
|
440
|
+
return storeRevision(
|
|
441
|
+
store,
|
|
442
|
+
{
|
|
443
|
+
contractVersion: 'toolkit-2',
|
|
444
|
+
planId: input.planId,
|
|
445
|
+
revision: 1,
|
|
446
|
+
previousRevision: null,
|
|
447
|
+
target: { targetId: target.profile.targetId, revision: target.revision, sha256: target.sha256 },
|
|
448
|
+
objective: request.objective,
|
|
449
|
+
requirements,
|
|
450
|
+
tests: candidate.tests,
|
|
451
|
+
gaps: candidate.gaps,
|
|
452
|
+
assumptions: [],
|
|
453
|
+
decisions: [],
|
|
454
|
+
},
|
|
455
|
+
operationId
|
|
456
|
+
)
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function candidateRequirements(request: PlanRequest, drafted: Plan['requirements'] | undefined) {
|
|
461
|
+
if (request.requirements?.length) {
|
|
462
|
+
requireCondition(!drafted, 'The plan request names the requirements. Do not add requirements to the candidate.')
|
|
463
|
+
return request.requirements
|
|
464
|
+
}
|
|
465
|
+
const sources = request.context?.sources ?? []
|
|
466
|
+
requireCondition(
|
|
467
|
+
drafted?.length,
|
|
468
|
+
`The plan request has no requirements. Draft them from the context sources: ${sources.join(', ')}`
|
|
469
|
+
)
|
|
470
|
+
index(drafted, 'id', 'requirement')
|
|
471
|
+
for (const requirement of drafted)
|
|
472
|
+
requireCondition(
|
|
473
|
+
sources.some((source) => requirement.source.startsWith(source)),
|
|
474
|
+
`Requirement ${requirement.id} cites ${requirement.source}, which is not a context source. Use one of: ${sources.join(', ')}`
|
|
475
|
+
)
|
|
476
|
+
return drafted
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function pendingDecisions(store: Store, planId: string) {
|
|
480
|
+
return all<DecisionRow>(
|
|
481
|
+
store,
|
|
482
|
+
'SELECT * FROM decisions WHERE plan_id = ? AND applied_revision IS NULL ORDER BY rowid',
|
|
483
|
+
planId
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function answerGap(store: Store, actor: string | undefined, input: GapAnswer) {
|
|
488
|
+
return write(store, { actor, command: 'gap answer', subject: input.planId, input }, (operationId, at, actor) => {
|
|
489
|
+
const kind = requireChoice(input.kind, decisionKinds, 'decision kind (--kind)')
|
|
490
|
+
const value = requireText(input.value, 'decision value (--value)')
|
|
491
|
+
const rationale = requireText(input.rationale, 'rationale (--rationale)')
|
|
492
|
+
const source = input.source ?? null
|
|
493
|
+
if (source !== null) requireText(source, 'source (--source)')
|
|
494
|
+
requireCondition(kind !== 'clarify' || source, 'A clarification needs a source (--source)')
|
|
495
|
+
const answered = planRevision(store, input.planId)
|
|
496
|
+
expectDigest(answered.sha256, input.expectSha256, `${input.planId}@${answered.plan.revision}`)
|
|
497
|
+
const adopted = answered.plan.gaps.some((gap) => gap.id === input.gapId)
|
|
498
|
+
? null
|
|
499
|
+
: adoptRunGap(store, operationId, answered, input.gapId)
|
|
500
|
+
const latest = adopted ? planRevision(store, input.planId) : answered
|
|
501
|
+
const subject = `${input.planId}@${latest.plan.revision}`
|
|
502
|
+
const gap = latest.plan.gaps.find((gap) => gap.id === input.gapId)!
|
|
503
|
+
requireCondition(
|
|
504
|
+
gap.status === 'open',
|
|
505
|
+
`Gap ${gap.id} is ${gap.status} in ${subject}. Only an open gap takes a decision.`
|
|
506
|
+
)
|
|
507
|
+
const pending = pendingDecisions(store, input.planId).find((decision) => decision.gap_id === gap.id)
|
|
508
|
+
requireCondition(
|
|
509
|
+
!pending,
|
|
510
|
+
`Gap ${gap.id} has a pending decision ${pending?.id}. Run plan revise to apply it first.`
|
|
511
|
+
)
|
|
512
|
+
const id = ulid()
|
|
513
|
+
execute(
|
|
514
|
+
store,
|
|
515
|
+
'INSERT INTO decisions (id, plan_id, base_revision, gap_id, kind, value, source, rationale, actor, operation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
516
|
+
id,
|
|
517
|
+
input.planId,
|
|
518
|
+
latest.plan.revision,
|
|
519
|
+
gap.id,
|
|
520
|
+
kind,
|
|
521
|
+
value,
|
|
522
|
+
source,
|
|
523
|
+
rationale,
|
|
524
|
+
actor,
|
|
525
|
+
operationId
|
|
526
|
+
)
|
|
527
|
+
return {
|
|
528
|
+
reply: completed(input.planId, {
|
|
529
|
+
decisionId: id,
|
|
530
|
+
planId: input.planId,
|
|
531
|
+
baseRevision: latest.plan.revision,
|
|
532
|
+
gapId: gap.id,
|
|
533
|
+
kind,
|
|
534
|
+
...(adopted
|
|
535
|
+
? {
|
|
536
|
+
adopted: {
|
|
537
|
+
runId: adopted.reply.data.runId,
|
|
538
|
+
revision: latest.plan.revision,
|
|
539
|
+
edit: adopted.reply.data.edit,
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
: {}),
|
|
543
|
+
}),
|
|
544
|
+
subjects: adopted ? adopted.subjects : [`plan:${input.planId}`, 'inbox'],
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function adoptRunGap(store: Store, operationId: string, base: PlanRevision, gapId: string) {
|
|
550
|
+
const subject = `${base.plan.planId}@${base.plan.revision}`
|
|
551
|
+
const found = planRunGaps(store, base.plan.planId).filter((item) => item.gap.id === gapId && !item.adopted)
|
|
552
|
+
requireCondition(found.length, `Unknown gap ${gapId} in ${subject}`)
|
|
553
|
+
requireCondition(
|
|
554
|
+
found.length === 1,
|
|
555
|
+
`Gap ${gapId} is in runs ${found.map((item) => item.runId).join(', ')}. Run plan adopt-gaps ${subject} --run <runId> --gaps ${gapId} first.`
|
|
556
|
+
)
|
|
557
|
+
return adoption(store, operationId, base, found[0].runId, [found[0].gap])
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function affectedTests(plan: Plan, gapIds: string[]) {
|
|
561
|
+
const gaps = plan.gaps.filter((gap) => gapIds.includes(gap.id))
|
|
562
|
+
return plan.tests.filter((test) => gapsAffecting(gaps, test).length > 0)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function showPending(store: Store, planId: string) {
|
|
566
|
+
const latest = planRevision(store, planId)
|
|
567
|
+
const decisions = pendingDecisions(store, planId)
|
|
568
|
+
const gapIds = decisions.map((decision) => decision.gap_id)
|
|
569
|
+
return completed(`${planId}@${latest.plan.revision}`, {
|
|
570
|
+
planId,
|
|
571
|
+
revision: latest.plan.revision,
|
|
572
|
+
sha256: latest.sha256,
|
|
573
|
+
decisions: decisions.map(decisionSnapshot),
|
|
574
|
+
gaps: latest.plan.gaps.filter((gap) => gapIds.includes(gap.id)),
|
|
575
|
+
tests: affectedTests(latest.plan, gapIds),
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function decisionSnapshot(row: DecisionRow): Plan['decisions'][number] {
|
|
580
|
+
return {
|
|
581
|
+
id: row.id,
|
|
582
|
+
gapId: row.gap_id,
|
|
583
|
+
kind: row.kind,
|
|
584
|
+
actor: row.actor,
|
|
585
|
+
rationale: row.rationale,
|
|
586
|
+
value: row.value,
|
|
587
|
+
source: row.source,
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function revisePlan(
|
|
592
|
+
store: Store,
|
|
593
|
+
actor: string | undefined,
|
|
594
|
+
input: { planId: string; delta: unknown; expectSha256?: string }
|
|
595
|
+
) {
|
|
596
|
+
return write(store, { actor, command: 'plan revise', subject: input.planId, input }, (operationId) => {
|
|
597
|
+
const base = planRevision(store, input.planId)
|
|
598
|
+
expectDigest(base.sha256, input.expectSha256, `${input.planId}@${base.plan.revision}`)
|
|
599
|
+
const decisions = pendingDecisions(store, input.planId)
|
|
600
|
+
requireCondition(
|
|
601
|
+
decisions.length > 0,
|
|
602
|
+
`Plan ${input.planId} has no pending decision. Record one with gap answer first.`
|
|
603
|
+
)
|
|
604
|
+
const delta = validate('plan-delta', input.delta)
|
|
605
|
+
const { coverage, ...draft } = structuredClone(base.plan)
|
|
606
|
+
draft.revision = base.plan.revision + 1
|
|
607
|
+
draft.previousRevision = base.plan.revision
|
|
608
|
+
for (const decision of decisions) {
|
|
609
|
+
const gap = draft.gaps.find((gap) => gap.id === decision.gap_id)!
|
|
610
|
+
gap.status = decision.kind === 'defer' ? 'deferred' : 'resolved'
|
|
611
|
+
gap.decisionId = decision.id
|
|
612
|
+
draft.decisions.push(decisionSnapshot(decision))
|
|
613
|
+
if (decision.kind === 'accept_assumption')
|
|
614
|
+
draft.assumptions.push({ decisionId: decision.id, text: decision.value })
|
|
615
|
+
}
|
|
616
|
+
const affected = affectedTests(
|
|
617
|
+
base.plan,
|
|
618
|
+
decisions.map((decision) => decision.gap_id)
|
|
619
|
+
).map((test) => test.id)
|
|
620
|
+
for (const [testId, change] of Object.entries(delta.tests)) {
|
|
621
|
+
const test = draft.tests.find((test) => test.id === testId)
|
|
622
|
+
requireCondition(test, `Unknown test in delta: ${testId}`)
|
|
623
|
+
requireCondition(
|
|
624
|
+
affected.includes(testId),
|
|
625
|
+
`Test ${testId} is not affected by a pending decision. A revision changes only affected tests.`
|
|
626
|
+
)
|
|
627
|
+
applyTestChange(test, change, usedAssertionIds(store, input.planId, testId))
|
|
628
|
+
}
|
|
629
|
+
const change = storeRevision(store, draft, operationId)
|
|
630
|
+
for (const decision of decisions)
|
|
631
|
+
execute(store, 'UPDATE decisions SET applied_revision = ? WHERE id = ?', draft.revision, decision.id)
|
|
632
|
+
change.reply.data.applied = decisions.map((decision) => decision.id)
|
|
633
|
+
return change
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function usedAssertionIds(store: Store, planId: string, testId: string) {
|
|
638
|
+
return all<{ id: string }>(
|
|
639
|
+
store,
|
|
640
|
+
"SELECT DISTINCT a.value ->> '$.id' AS id FROM plan_tests t, json_each(t.test, '$.assertions') a WHERE t.plan_id = ? AND t.test_id = ?",
|
|
641
|
+
planId,
|
|
642
|
+
testId
|
|
643
|
+
).map((row) => row.id)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function applyTestChange(test: Test, change: PlanDelta['tests'][string], used: string[]) {
|
|
647
|
+
if (change.action !== undefined) test.action = change.action
|
|
648
|
+
if (change.preconditions !== undefined) test.preconditions = change.preconditions
|
|
649
|
+
for (const [id, assertion] of Object.entries(change.assertions ?? {})) {
|
|
650
|
+
const position = test.assertions.findIndex((existing) => existing.id === id)
|
|
651
|
+
if (assertion === null) {
|
|
652
|
+
requireCondition(position >= 0, `Cannot remove unknown assertion ${test.id}.${id}`)
|
|
653
|
+
test.assertions.splice(position, 1)
|
|
654
|
+
} else if (position >= 0) test.assertions[position] = { ...test.assertions[position], ...assertion }
|
|
655
|
+
else {
|
|
656
|
+
requireCondition(
|
|
657
|
+
!used.includes(id),
|
|
658
|
+
`Assertion ID ${test.id}.${id} was used in an earlier revision. Use a new ID.`
|
|
659
|
+
)
|
|
660
|
+
requireCondition(
|
|
661
|
+
assertion.check !== undefined && assertion.checkKind !== undefined,
|
|
662
|
+
`New assertion ${test.id}.${id} needs check and checkKind`
|
|
663
|
+
)
|
|
664
|
+
test.assertions.push({ id, check: assertion.check, checkKind: assertion.checkKind })
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function approvePlan(store: Store, actor: string | undefined, input: Verdict) {
|
|
670
|
+
return decidePlan(store, actor, input, 'approved')
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export function rejectPlan(store: Store, actor: string | undefined, input: Verdict) {
|
|
674
|
+
return decidePlan(store, actor, input, 'rejected')
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function decidePlan(store: Store, actor: string | undefined, input: Verdict, verdict: 'approved' | 'rejected') {
|
|
678
|
+
const command = verdict === 'approved' ? 'plan approve' : 'plan reject'
|
|
679
|
+
return write(store, { actor, command, subject: input.reference, input }, (operationId, at, actor) => {
|
|
680
|
+
const reference = revisionReference(requireText(input.reference, 'plan reference'), 'plan reference')
|
|
681
|
+
requireCondition(reference.revision !== null, `Name the plan revision: ${reference.id}@<revision>`)
|
|
682
|
+
const revision = planRevision(store, reference.id, reference.revision)
|
|
683
|
+
const subject = `${reference.id}@${reference.revision}`
|
|
684
|
+
expectDigest(revision.sha256, input.expectSha256, subject)
|
|
685
|
+
return {
|
|
686
|
+
reply: recordApproval(store, actor, operationId, {
|
|
687
|
+
kind: 'plan',
|
|
688
|
+
subject,
|
|
689
|
+
sha256: revision.sha256,
|
|
690
|
+
verdict,
|
|
691
|
+
rationale: input.rationale,
|
|
692
|
+
}),
|
|
693
|
+
subjects: [`plan:${reference.id}`, 'inbox'],
|
|
694
|
+
}
|
|
695
|
+
})
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export function recordApproval(
|
|
699
|
+
store: Store,
|
|
700
|
+
actor: string,
|
|
701
|
+
operationId: string,
|
|
702
|
+
input: {
|
|
703
|
+
kind: 'plan' | 'result'
|
|
704
|
+
subject: string
|
|
705
|
+
sha256: string
|
|
706
|
+
verdict: 'approved' | 'rejected'
|
|
707
|
+
rationale: string
|
|
708
|
+
}
|
|
709
|
+
): Reply {
|
|
710
|
+
const rationale = requireText(input.rationale, 'rationale (--rationale)')
|
|
711
|
+
const id = ulid()
|
|
712
|
+
execute(
|
|
713
|
+
store,
|
|
714
|
+
'INSERT INTO approvals (id, subject_kind, subject_id, subject_sha256, verdict, rationale, actor, operation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
715
|
+
id,
|
|
716
|
+
input.kind,
|
|
717
|
+
input.subject,
|
|
718
|
+
input.sha256,
|
|
719
|
+
input.verdict,
|
|
720
|
+
rationale,
|
|
721
|
+
actor,
|
|
722
|
+
operationId
|
|
723
|
+
)
|
|
724
|
+
return completed(input.subject, { approvalId: id, verdict: input.verdict, sha256: input.sha256 })
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export type Approval = {
|
|
728
|
+
verdict: 'approved' | 'rejected'
|
|
729
|
+
sha256: string
|
|
730
|
+
actor: string
|
|
731
|
+
rationale: string
|
|
732
|
+
at: string
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function latestApproval(store: Store, kind: 'plan' | 'result', subject: string) {
|
|
736
|
+
return one<Approval>(
|
|
737
|
+
store,
|
|
738
|
+
`SELECT a.verdict, a.subject_sha256 AS sha256, a.actor, a.rationale, o.at FROM approvals a
|
|
739
|
+
JOIN operations o ON o.id = a.operation_id
|
|
740
|
+
WHERE a.subject_kind = ? AND a.subject_id = ? ORDER BY a.rowid DESC LIMIT 1`,
|
|
741
|
+
kind,
|
|
742
|
+
subject
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export function approvalState(
|
|
747
|
+
store: Store,
|
|
748
|
+
kind: 'plan' | 'result',
|
|
749
|
+
subject: string,
|
|
750
|
+
sha256: string,
|
|
751
|
+
policy: 'required' | 'none'
|
|
752
|
+
) {
|
|
753
|
+
const latest = latestApproval(store, kind, subject) ?? null
|
|
754
|
+
const current = latest?.sha256 === sha256
|
|
755
|
+
return {
|
|
756
|
+
policy,
|
|
757
|
+
latest,
|
|
758
|
+
approved: current && latest?.verdict === 'approved',
|
|
759
|
+
rejected: current && latest?.verdict === 'rejected',
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
export function planApproval(store: Store, plan: Plan, sha256: string) {
|
|
764
|
+
const policy = approvalPolicy(targetRevision(store, plan.target.targetId, plan.target.revision).profile).plan
|
|
765
|
+
return approvalState(store, 'plan', `${plan.planId}@${plan.revision}`, sha256, policy)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function gate(store: Store, input: GateInput) {
|
|
769
|
+
const reference = revisionReference(requireText(input.reference, 'plan reference'), 'plan reference')
|
|
770
|
+
requireCondition(reference.revision !== null, `Name the plan revision: ${reference.id}@<revision>`)
|
|
771
|
+
const revision = planRevision(store, reference.id, reference.revision)
|
|
772
|
+
const { plan } = revision
|
|
773
|
+
const subject = `${plan.planId}@${plan.revision}`
|
|
774
|
+
const target = targetRevision(store, plan.target.targetId, plan.target.revision)
|
|
775
|
+
const build = requireText(input.build, 'application build (--build)')
|
|
776
|
+
const partial = requireChoice(input.partial ?? 'wait_for_resolution', partialModes, 'partial execution (--partial)')
|
|
777
|
+
const tests = index(plan.tests, 'id', 'test')
|
|
778
|
+
const selection =
|
|
779
|
+
input.tests === undefined && partial === 'ready_only'
|
|
780
|
+
? readySelection(store, plan, build)
|
|
781
|
+
: givenSelection(input.tests, tests, subject)
|
|
782
|
+
const selected = selection.tests
|
|
783
|
+
if (input.retestOf) retestCheck(store, input.retestOf, plan, build)
|
|
784
|
+
const reasons: string[] = []
|
|
785
|
+
if (!selected.length) reasons.push(`No test is ready in ${subject}`)
|
|
786
|
+
if (!target.profile.readyForLiveExecution)
|
|
787
|
+
reasons.push(`Target prerequisites are not ready: ${plan.target.targetId}@${plan.target.revision}`)
|
|
788
|
+
const approval = planApproval(store, plan, revision.sha256)
|
|
789
|
+
if (approval.policy === 'required' && !approval.approved) reasons.push(`Plan ${subject} is not approved`)
|
|
790
|
+
if (partial === 'wait_for_resolution')
|
|
791
|
+
for (const gap of plan.gaps)
|
|
792
|
+
if (gap.status === 'open' && gap.severity === 'blocking') reasons.push(`Open blocking gap: ${gap.id}`)
|
|
793
|
+
for (const id of selected) {
|
|
794
|
+
const test = tests.get(id)!
|
|
795
|
+
if (test.readiness !== 'ready') reasons.push(`Test is not ready: ${id}`)
|
|
796
|
+
if (!target.profile.capabilities.includes(test.capability))
|
|
797
|
+
reasons.push(`Missing capability: ${test.capability}`)
|
|
798
|
+
if (!test.operations.every((operation) => target.profile.allowedOperations.includes(operation)))
|
|
799
|
+
reasons.push(`Operation is not permitted: ${id}`)
|
|
800
|
+
if (!test.assertions.length) reasons.push(`Test has no assertions: ${id}`)
|
|
801
|
+
for (const dependency of test.dependsOn)
|
|
802
|
+
if (!dependency.includes(':') && !selected.includes(dependency))
|
|
803
|
+
reasons.push(`Missing prerequisite: ${dependency}`)
|
|
804
|
+
}
|
|
805
|
+
for (const id of selected)
|
|
806
|
+
for (const dependency of tests.get(id)!.dependsOn.filter((dependency) => dependency.includes(':'))) {
|
|
807
|
+
const cause = dependencyBlock(store, dependency, build)
|
|
808
|
+
if (cause) reasons.push(`Blocked by ${dependency}: ${cause}`)
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
reply: {
|
|
812
|
+
status: reasons.length ? ('needs_decision' as const) : ('completed' as const),
|
|
813
|
+
subject,
|
|
814
|
+
reasons,
|
|
815
|
+
data: {
|
|
816
|
+
planId: plan.planId,
|
|
817
|
+
revision: plan.revision,
|
|
818
|
+
sha256: revision.sha256,
|
|
819
|
+
tests: selected,
|
|
820
|
+
leftOut: selection.leftOut,
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
tests: selected,
|
|
824
|
+
revision,
|
|
825
|
+
target,
|
|
826
|
+
build,
|
|
827
|
+
partial,
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
type LeftOut = { testId: string; reason: string }
|
|
832
|
+
|
|
833
|
+
function givenSelection(selected: string[] | undefined, tests: Map<string, Test>, subject: string) {
|
|
834
|
+
requireCondition(
|
|
835
|
+
Array.isArray(selected) && selected.length > 0,
|
|
836
|
+
'Select one or more tests with --tests, or add --partial ready_only'
|
|
837
|
+
)
|
|
838
|
+
requireCondition(new Set(selected).size === selected.length, 'Duplicate test in --tests')
|
|
839
|
+
references(selected, tests, `test in ${subject}`)
|
|
840
|
+
return { tests: selected, leftOut: [] as LeftOut[] }
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function readySelection(store: Store, plan: Plan, build: string) {
|
|
844
|
+
const leftOut: LeftOut[] = []
|
|
845
|
+
for (const test of plan.tests) {
|
|
846
|
+
const reason = notReadyReason(store, plan, test, build)
|
|
847
|
+
if (reason) leftOut.push({ testId: test.id, reason })
|
|
848
|
+
}
|
|
849
|
+
let selected = plan.tests.map((test) => test.id).filter((id) => !leftOut.some((item) => item.testId === id))
|
|
850
|
+
for (let changed = true; changed;) {
|
|
851
|
+
changed = false
|
|
852
|
+
for (const test of plan.tests.filter((test) => selected.includes(test.id))) {
|
|
853
|
+
const missing = test.dependsOn.find((id) => !id.includes(':') && !selected.includes(id))
|
|
854
|
+
if (!missing) continue
|
|
855
|
+
leftOut.push({ testId: test.id, reason: `needs ${missing}, which is left out` })
|
|
856
|
+
selected = selected.filter((id) => id !== test.id)
|
|
857
|
+
changed = true
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const order = plan.tests.map((test) => test.id)
|
|
861
|
+
leftOut.sort((a, b) => order.indexOf(a.testId) - order.indexOf(b.testId))
|
|
862
|
+
return { tests: selected, leftOut }
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function notReadyReason(store: Store, plan: Plan, test: Test, build: string) {
|
|
866
|
+
if (test.readiness !== 'ready') {
|
|
867
|
+
const gaps = plan.gaps.filter(
|
|
868
|
+
(gap) =>
|
|
869
|
+
test.gapIds.includes(gap.id) &&
|
|
870
|
+
(test.readiness === 'deferred'
|
|
871
|
+
? gap.status === 'deferred' && gap.severity === 'blocking'
|
|
872
|
+
: gap.status === 'open' && gap.severity === 'blocking')
|
|
873
|
+
)
|
|
874
|
+
return `${test.readiness} by ${gaps.map((gap) => gap.id).join(', ')}`
|
|
875
|
+
}
|
|
876
|
+
for (const dependency of test.dependsOn.filter((id) => id.includes(':'))) {
|
|
877
|
+
const cause = dependencyBlock(store, dependency, build)
|
|
878
|
+
if (cause) return `blocked by ${dependency}: ${cause}`
|
|
879
|
+
}
|
|
880
|
+
return null
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function retestCheck(store: Store, runId: string, plan: Plan, build: string) {
|
|
884
|
+
const previous = one<{
|
|
885
|
+
plan_id: string
|
|
886
|
+
target_id: string
|
|
887
|
+
target_revision: number
|
|
888
|
+
application_build: string
|
|
889
|
+
finished: number
|
|
890
|
+
}>(
|
|
891
|
+
store,
|
|
892
|
+
'SELECT r.plan_id, r.target_id, r.target_revision, r.application_build, s.run_id IS NOT NULL AS finished FROM runs r LEFT JOIN results s ON s.run_id = r.run_id WHERE r.run_id = ?',
|
|
893
|
+
runId
|
|
894
|
+
)
|
|
895
|
+
requireCondition(previous, `Unknown run to retest: ${runId}`)
|
|
896
|
+
requireCondition(previous.finished, `Run ${runId} is not finished. Retest a finished run.`)
|
|
897
|
+
requireCondition(
|
|
898
|
+
previous.plan_id === plan.planId &&
|
|
899
|
+
previous.target_id === plan.target.targetId &&
|
|
900
|
+
previous.target_revision === plan.target.revision,
|
|
901
|
+
`Retest scope changed: ${runId} ran plan ${previous.plan_id} on ${previous.target_id}@${previous.target_revision}`
|
|
902
|
+
)
|
|
903
|
+
requireCondition(
|
|
904
|
+
previous.application_build !== build,
|
|
905
|
+
`Retest needs a new build. Run ${runId} used build ${build}.`
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export function dependencyBlock(store: Store, reference: string, build: string | null): string | null {
|
|
910
|
+
const { planId, testId } = testReference(reference)
|
|
911
|
+
const result = one<{ run_id: string; target_id: string; target_revision: number; sha256: string; outcome: string }>(
|
|
912
|
+
store,
|
|
913
|
+
`SELECT r.run_id, r.target_id, r.target_revision, s.sha256,
|
|
914
|
+
(SELECT t.value ->> '$.outcome' FROM json_each(s.body, '$.tests') t WHERE t.value ->> '$.testId' = ?) AS outcome
|
|
915
|
+
FROM runs r JOIN results s ON s.run_id = r.run_id
|
|
916
|
+
WHERE r.plan_id = ? AND (? IS NULL OR r.application_build = ?)
|
|
917
|
+
AND EXISTS (SELECT 1 FROM json_each(s.body, '$.selectedTestIds') WHERE value = ?)
|
|
918
|
+
ORDER BY s.rowid DESC LIMIT 1`,
|
|
919
|
+
testId,
|
|
920
|
+
planId,
|
|
921
|
+
build,
|
|
922
|
+
build,
|
|
923
|
+
testId
|
|
924
|
+
)
|
|
925
|
+
if (!result) return build === null ? 'no finalized result' : `no finalized result for build ${build}`
|
|
926
|
+
if (result.outcome !== 'passed') return `latest result ${result.run_id} shows ${result.outcome}`
|
|
927
|
+
const policy = approvalPolicy(targetRevision(store, result.target_id, result.target_revision).profile).result
|
|
928
|
+
if (policy === 'required' && !approvalState(store, 'result', result.run_id, result.sha256, policy).approved)
|
|
929
|
+
return `result ${result.run_id} is not accepted`
|
|
930
|
+
for (const [owner, plan] of latestPlans(store)) {
|
|
931
|
+
const open = plan.gaps.filter((gap) => gap.status === 'open' && gap.severity === 'blocking')
|
|
932
|
+
const test = plan.tests.find((test) => test.id === testId)
|
|
933
|
+
const gap =
|
|
934
|
+
owner === planId
|
|
935
|
+
? test && gapsAffecting(open, test)[0]
|
|
936
|
+
: open.find((gap) => gap.testIds.includes(reference))
|
|
937
|
+
if (gap) return `open blocking gap ${gap.id} in ${owner}`
|
|
938
|
+
}
|
|
939
|
+
return null
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export function planChanges(before: Plan, after: Plan): string[] {
|
|
943
|
+
const lines: string[] = []
|
|
944
|
+
const fields = (prefix: string, a: object, b: object, skip: string[] = []) => {
|
|
945
|
+
const x = a as Record<string, unknown>
|
|
946
|
+
const y = b as Record<string, unknown>
|
|
947
|
+
for (const key of [...new Set([...Object.keys(x), ...Object.keys(y)])].sort())
|
|
948
|
+
if (!skip.includes(key) && !same(x[key], y[key]))
|
|
949
|
+
lines.push(`${prefix} ${key}: ${JSON.stringify(x[key])} → ${JSON.stringify(y[key])}`)
|
|
950
|
+
}
|
|
951
|
+
for (const test of after.tests) {
|
|
952
|
+
const old = before.tests.find((item) => item.id === test.id)
|
|
953
|
+
if (!old) {
|
|
954
|
+
lines.push(`${test.id} added`)
|
|
955
|
+
continue
|
|
956
|
+
}
|
|
957
|
+
fields(test.id, old, test, ['assertions'])
|
|
958
|
+
for (const assertion of old.assertions)
|
|
959
|
+
if (!test.assertions.some((item) => item.id === assertion.id))
|
|
960
|
+
lines.push(`${test.id}.${assertion.id} removed`)
|
|
961
|
+
for (const assertion of test.assertions) {
|
|
962
|
+
const previous = old.assertions.find((item) => item.id === assertion.id)
|
|
963
|
+
if (previous) fields(`${test.id}.${assertion.id}`, previous, assertion)
|
|
964
|
+
else lines.push(`${test.id}.${assertion.id} added: ${JSON.stringify(assertion.check)}`)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
for (const gap of after.gaps) {
|
|
968
|
+
const old = before.gaps.find((item) => item.id === gap.id)
|
|
969
|
+
if (old) fields(gap.id, old, gap)
|
|
970
|
+
else lines.push(`${gap.id} added`)
|
|
971
|
+
}
|
|
972
|
+
for (const decision of after.decisions.filter((item) => !before.decisions.some((old) => old.id === item.id)))
|
|
973
|
+
lines.push(`decision ${decision.id} ${decision.kind} ${decision.gapId}: ${JSON.stringify(decision.value)}`)
|
|
974
|
+
for (const assumption of after.assumptions.filter(
|
|
975
|
+
(item) => !before.assumptions.some((old) => old.decisionId === item.decisionId)
|
|
976
|
+
))
|
|
977
|
+
lines.push(`assumption ${assumption.decisionId}: ${JSON.stringify(assumption.text)}`)
|
|
978
|
+
for (const row of after.coverage) {
|
|
979
|
+
const old = before.coverage.find((item) => item.requirementId === row.requirementId)
|
|
980
|
+
if (old && old.status !== row.status)
|
|
981
|
+
lines.push(`${row.requirementId} coverage: ${JSON.stringify(old.status)} → ${JSON.stringify(row.status)}`)
|
|
982
|
+
}
|
|
983
|
+
return lines
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function jsonPatch(before: unknown, after: unknown, path = ''): PatchOperation[] {
|
|
987
|
+
if (same(before, after)) return []
|
|
988
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
989
|
+
const common = Math.min(before.length, after.length)
|
|
990
|
+
const operations: PatchOperation[] = []
|
|
991
|
+
for (let i = 0; i < common; i++) operations.push(...jsonPatch(before[i], after[i], `${path}/${i}`))
|
|
992
|
+
for (let i = common; i < after.length; i++)
|
|
993
|
+
operations.push({ op: 'add', path: `${path}/${i}`, value: after[i] })
|
|
994
|
+
for (let i = before.length - 1; i >= after.length; i--) operations.push({ op: 'remove', path: `${path}/${i}` })
|
|
995
|
+
return operations
|
|
996
|
+
}
|
|
997
|
+
if (isRecord(before) && isRecord(after)) {
|
|
998
|
+
const operations: PatchOperation[] = []
|
|
999
|
+
const pointer = (key: string) => `${path}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`
|
|
1000
|
+
for (const key of Object.keys(before).sort())
|
|
1001
|
+
if (!(key in after)) operations.push({ op: 'remove', path: pointer(key) })
|
|
1002
|
+
for (const key of Object.keys(after).sort())
|
|
1003
|
+
if (key in before) operations.push(...jsonPatch(before[key], after[key], pointer(key)))
|
|
1004
|
+
else operations.push({ op: 'add', path: pointer(key), value: after[key] })
|
|
1005
|
+
return operations
|
|
1006
|
+
}
|
|
1007
|
+
return [{ op: 'replace', path, value: after }]
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1011
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export function diffPlan(store: Store, input: { planId: string; from: string; to: string }) {
|
|
1015
|
+
const revision = (text: string) => {
|
|
1016
|
+
requireCondition(/^[1-9][0-9]*$/.test(text), `Invalid revision: ${text}. Use a positive integer.`)
|
|
1017
|
+
return planRevision(store, input.planId, Number(text)).plan
|
|
1018
|
+
}
|
|
1019
|
+
const before = revision(input.from)
|
|
1020
|
+
const after = revision(input.to)
|
|
1021
|
+
return completed(`${input.planId}@${before.revision}..${after.revision}`, {
|
|
1022
|
+
planId: input.planId,
|
|
1023
|
+
from: before.revision,
|
|
1024
|
+
to: after.revision,
|
|
1025
|
+
patch: jsonPatch(before, after),
|
|
1026
|
+
changes: planChanges(before, after),
|
|
1027
|
+
})
|
|
1028
|
+
}
|