@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,684 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import type { Store } from '../store.ts'
|
|
3
|
+
import type { Context, Page, Theme } from './layout.ts'
|
|
4
|
+
import type { FormState } from './forms.ts'
|
|
5
|
+
import type { EditState } from './edit.ts'
|
|
6
|
+
import { createServer } from 'node:http'
|
|
7
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { closeStore, openReadOnlyStore, openStore } from '../store.ts'
|
|
11
|
+
import { adoptGaps, answerGap, approvePlan, editPlan, planCandidate, rejectPlan } from '../plans.ts'
|
|
12
|
+
import { exportPlan, exportRun } from '../render.ts'
|
|
13
|
+
import { acceptResult, rejectResult } from '../runs.ts'
|
|
14
|
+
import { changesAfter, evidenceBytes, inbox, operationLog, planDetail, runDetail } from '../views.ts'
|
|
15
|
+
import { defaultActor } from '../actor.ts'
|
|
16
|
+
import { html } from './html.ts'
|
|
17
|
+
import { heading, layout } from './layout.ts'
|
|
18
|
+
import { emptyState } from './forms.ts'
|
|
19
|
+
import { inboxCount, inboxItems, inboxPage, logPage, plansPage, runsPage } from './lists.ts'
|
|
20
|
+
import { gapPage, planHistoryList, planPage } from './plan.ts'
|
|
21
|
+
import { candidateFromForm, candidateJson, editPage } from './edit.ts'
|
|
22
|
+
import type { AdoptError } from './run.ts'
|
|
23
|
+
import { runPage } from './run.ts'
|
|
24
|
+
|
|
25
|
+
export type UiOptions = { workspace: string; port?: number; defaultActor?: string }
|
|
26
|
+
export type Ui = { url: string; close(): Promise<void> }
|
|
27
|
+
|
|
28
|
+
type Request = { req: IncomingMessage; res: ServerResponse; url: URL; cookies: Record<string, string> }
|
|
29
|
+
type Client = { res: ServerResponse; subjects: Set<string>; last: number }
|
|
30
|
+
type Change = ReturnType<typeof changesAfter>[number]
|
|
31
|
+
|
|
32
|
+
const id = '[A-Za-z0-9][A-Za-z0-9_-]{0,95}'
|
|
33
|
+
const routes = {
|
|
34
|
+
plan: new RegExp(`^/plans/(${id})@([1-9][0-9]{0,8})$`),
|
|
35
|
+
latestPlan: new RegExp(`^/plans/(${id})$`),
|
|
36
|
+
gap: new RegExp(`^/plans/(${id})@([1-9][0-9]{0,8})/gaps/(${id})$`),
|
|
37
|
+
edit: new RegExp(`^/plans/(${id})@([1-9][0-9]{0,8})/edit$`),
|
|
38
|
+
history: new RegExp(`^/plans/(${id})@([1-9][0-9]{0,8})/history$`),
|
|
39
|
+
planExport: new RegExp(`^/plans/(${id})@([1-9][0-9]{0,8})/export\\.(html|md|json)$`),
|
|
40
|
+
run: new RegExp(`^/runs/(${id})$`),
|
|
41
|
+
runGap: new RegExp(`^/runs/(${id})/gaps/(${id})$`),
|
|
42
|
+
report: new RegExp(`^/runs/(${id})/report\\.(html|md|json)$`),
|
|
43
|
+
evidence: /^\/evidence\/([0-9A-Z]{26})$/,
|
|
44
|
+
font: /^\/assets\/fonts\/(inter|space-grotesk|jetbrains-mono)\.woff2$/,
|
|
45
|
+
}
|
|
46
|
+
const contentSecurityPolicy =
|
|
47
|
+
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; form-action 'self'"
|
|
48
|
+
const inlineImages = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
|
|
49
|
+
const documentTypes: Record<string, string> = {
|
|
50
|
+
html: 'text/html; charset=utf-8',
|
|
51
|
+
md: 'text/markdown; charset=utf-8',
|
|
52
|
+
json: 'application/json; charset=utf-8',
|
|
53
|
+
}
|
|
54
|
+
const themes: Theme[] = ['system', 'light', 'dark']
|
|
55
|
+
const maxForm = 256 * 1024
|
|
56
|
+
|
|
57
|
+
function directory(candidates: string[], file: string) {
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
const path = fileURLToPath(new URL(candidate, import.meta.url))
|
|
60
|
+
if (existsSync(path + file)) return path
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`Checkmate UI files are missing: ${file} not found in ${candidates.join(', ')}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function assets() {
|
|
66
|
+
const files = directory(['./assets/', '../src/ui/assets/', '../plugin/src/ui/assets/'], 'style.css')
|
|
67
|
+
const fonts = `${files}fonts/`
|
|
68
|
+
const read = (name: string) => readFileSync(files + name)
|
|
69
|
+
return {
|
|
70
|
+
'/assets/style.css': { type: 'text/css; charset=utf-8', body: read('style.css') },
|
|
71
|
+
'/assets/client.js': { type: 'text/javascript; charset=utf-8', body: read('client.js') },
|
|
72
|
+
'/assets/favicon.svg': { type: 'image/svg+xml', body: read('favicon.svg') },
|
|
73
|
+
font: (name: string) => readFileSync(`${fonts}${name}.woff2`),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function securityHeaders(): Record<string, string> {
|
|
78
|
+
return {
|
|
79
|
+
'Content-Security-Policy': contentSecurityPolicy,
|
|
80
|
+
'X-Content-Type-Options': 'nosniff',
|
|
81
|
+
'Referrer-Policy': 'same-origin',
|
|
82
|
+
'X-Frame-Options': 'DENY',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function send(
|
|
87
|
+
res: ServerResponse,
|
|
88
|
+
status: number,
|
|
89
|
+
type: string,
|
|
90
|
+
body: string | Uint8Array,
|
|
91
|
+
headers: Record<string, string | string[]> = {}
|
|
92
|
+
) {
|
|
93
|
+
res.writeHead(status, { ...securityHeaders(), 'Content-Type': type, 'Cache-Control': 'no-store', ...headers })
|
|
94
|
+
res.end(body)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function redirect(res: ServerResponse, location: string, cookies: string[] = []) {
|
|
98
|
+
res.writeHead(303, { ...securityHeaders(), Location: location, 'Set-Cookie': cookies })
|
|
99
|
+
res.end()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseCookies(header: string | undefined) {
|
|
103
|
+
const cookies: Record<string, string> = {}
|
|
104
|
+
for (const part of (header ?? '').split(';')) {
|
|
105
|
+
const index = part.indexOf('=')
|
|
106
|
+
if (index < 0) continue
|
|
107
|
+
try {
|
|
108
|
+
cookies[part.slice(0, index).trim()] = decodeURIComponent(part.slice(index + 1).trim())
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
return cookies
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cookie(name: string, value: string, maxAge = 60 * 60 * 24 * 365) {
|
|
115
|
+
return `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Strict`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readForm(req: IncomingMessage): Promise<URLSearchParams> {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const chunks: Buffer[] = []
|
|
121
|
+
let length = 0
|
|
122
|
+
req.on('data', (chunk: Buffer) => {
|
|
123
|
+
length += chunk.length
|
|
124
|
+
if (length > maxForm) {
|
|
125
|
+
reject(new Error('The form is too large.'))
|
|
126
|
+
req.destroy()
|
|
127
|
+
} else chunks.push(chunk)
|
|
128
|
+
})
|
|
129
|
+
req.on('end', () => resolve(new URLSearchParams(Buffer.concat(chunks).toString('utf8'))))
|
|
130
|
+
req.on('error', reject)
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function same(a: string, b: string) {
|
|
135
|
+
const x = Buffer.from(a)
|
|
136
|
+
const y = Buffer.from(b)
|
|
137
|
+
return x.length === y.length && timingSafeEqual(x, y)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function safeBack(value: string | null) {
|
|
141
|
+
return value && value.startsWith('/') && !value.startsWith('//') ? value : '/'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function attachmentName(name: string) {
|
|
145
|
+
const plain = name.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
146
|
+
return `filename="${plain}"; filename*=UTF-8''${encodeURIComponent(name)}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function notFoundError(message: string) {
|
|
150
|
+
return /^Unknown|has no revision|not found/i.test(message)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function plainMessage(error: unknown) {
|
|
154
|
+
return error instanceof Error ? error.message : String(error)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function startUi(options: UiOptions): Promise<Ui> {
|
|
158
|
+
const files = assets()
|
|
159
|
+
const store = openStore(options.workspace)
|
|
160
|
+
const watch = openReadOnlyStore(options.workspace)
|
|
161
|
+
const csrf = randomBytes(24).toString('hex')
|
|
162
|
+
const fallbackActor = options.defaultActor ?? defaultActor(options.workspace, process.env) ?? ''
|
|
163
|
+
const clients = new Set<Client>()
|
|
164
|
+
let port = 0
|
|
165
|
+
|
|
166
|
+
const latestRowid = (source: Store) => operationLog(source, { limit: 1 })[0]?.rowid ?? 0
|
|
167
|
+
const dataVersion = () => Number(watch.db.prepare('PRAGMA data_version').get()?.data_version ?? 0)
|
|
168
|
+
let version = dataVersion()
|
|
169
|
+
let cursor = latestRowid(watch)
|
|
170
|
+
|
|
171
|
+
function deliver(client: Client, change: Change) {
|
|
172
|
+
if (change.rowid <= client.last) return
|
|
173
|
+
const matched = client.subjects.has('*')
|
|
174
|
+
? change.subjects
|
|
175
|
+
: change.subjects.filter((subject) => client.subjects.has(subject))
|
|
176
|
+
if (!matched.length && !client.subjects.has('*')) return
|
|
177
|
+
client.last = change.rowid
|
|
178
|
+
client.res.write(`id: ${change.rowid}\ndata: ${matched.join(',') || '*'}\n\n`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const poll = setInterval(() => {
|
|
182
|
+
const current = dataVersion()
|
|
183
|
+
if (current === version) return
|
|
184
|
+
version = current
|
|
185
|
+
const changes = changesAfter(watch, cursor)
|
|
186
|
+
if (!changes.length) return
|
|
187
|
+
cursor = changes[changes.length - 1].rowid
|
|
188
|
+
for (const client of clients) for (const change of changes) deliver(client, change)
|
|
189
|
+
}, 250)
|
|
190
|
+
const heartbeat = setInterval(() => {
|
|
191
|
+
for (const client of clients) client.res.write(': heartbeat\n\n')
|
|
192
|
+
}, 15_000)
|
|
193
|
+
|
|
194
|
+
function context(request: Request, path = request.url.pathname + request.url.search): Context {
|
|
195
|
+
const theme = themes.includes(request.cookies.checkmate_theme as Theme)
|
|
196
|
+
? (request.cookies.checkmate_theme as Theme)
|
|
197
|
+
: 'system'
|
|
198
|
+
return {
|
|
199
|
+
path,
|
|
200
|
+
csrf,
|
|
201
|
+
actor: request.cookies.checkmate_actor?.trim() || fallbackActor || null,
|
|
202
|
+
theme,
|
|
203
|
+
toast:
|
|
204
|
+
(request.req.headers['sec-fetch-mode'] ?? 'navigate') === 'navigate'
|
|
205
|
+
? request.cookies.checkmate_toast || null
|
|
206
|
+
: null,
|
|
207
|
+
inboxCount: inboxCount(inbox(store)),
|
|
208
|
+
cursor: latestRowid(store),
|
|
209
|
+
workspace: options.workspace,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function render(request: Request, page: Page, extraCookies: string[] = []) {
|
|
214
|
+
const shown = context(request)
|
|
215
|
+
const cookies = [...extraCookies]
|
|
216
|
+
if (shown.toast) cookies.push(cookie('checkmate_toast', '', 0))
|
|
217
|
+
send(request.res, page.status ?? 200, 'text/html; charset=utf-8', layout(shown, page).html, {
|
|
218
|
+
'Set-Cookie': cookies,
|
|
219
|
+
Vary: 'Cookie',
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function errorPage(request: Request, status: number, title: string, message: string) {
|
|
224
|
+
render(request, {
|
|
225
|
+
title,
|
|
226
|
+
section: null,
|
|
227
|
+
subjects: ['inbox'],
|
|
228
|
+
status,
|
|
229
|
+
body: html`${heading(title, message)}
|
|
230
|
+
<p class="actions">
|
|
231
|
+
<a class="btn primary" href="/">Back to inbox</a> <a class="btn" href="/plans">Plans</a>
|
|
232
|
+
<a class="btn" href="/runs">Runs</a>
|
|
233
|
+
</p>`,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function nextItem(flash: string, actorCookie: string[]) {
|
|
238
|
+
const next = inboxItems(inbox(store))[0] ?? '/'
|
|
239
|
+
return { location: next, cookies: [...actorCookie, cookie('checkmate_toast', flash, 60)] }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function serveEvidence(request: Request, evidenceId: string) {
|
|
243
|
+
let evidence
|
|
244
|
+
try {
|
|
245
|
+
evidence = evidenceBytes(store, evidenceId)
|
|
246
|
+
} catch {
|
|
247
|
+
return errorPage(request, 404, 'Evidence not found', `Checkmate has no evidence ${evidenceId}.`)
|
|
248
|
+
}
|
|
249
|
+
const inline = inlineImages.includes(evidence.mediaType) || evidence.mediaType === 'text/plain'
|
|
250
|
+
send(request.res, 200, evidence.mediaType, evidence.bytes, {
|
|
251
|
+
'Content-Disposition': `${inline ? 'inline' : 'attachment'}; ${attachmentName(evidence.name)}`,
|
|
252
|
+
'Cache-Control': 'private, max-age=31536000, immutable',
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function download(request: Request, body: string, format: string, name: string) {
|
|
257
|
+
send(request.res, 200, documentTypes[format], body, {
|
|
258
|
+
'Content-Disposition': `attachment; ${attachmentName(name)}`,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function events(request: Request) {
|
|
263
|
+
const subjects = new Set(
|
|
264
|
+
(request.url.searchParams.get('subjects') ?? '')
|
|
265
|
+
.split(',')
|
|
266
|
+
.map((subject) => subject.trim())
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
)
|
|
269
|
+
const header = request.req.headers['last-event-id']
|
|
270
|
+
const after = Number(header ?? request.url.searchParams.get('after') ?? Number.NaN)
|
|
271
|
+
request.res.writeHead(200, {
|
|
272
|
+
...securityHeaders(),
|
|
273
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
274
|
+
'Cache-Control': 'no-store',
|
|
275
|
+
Connection: 'keep-alive',
|
|
276
|
+
})
|
|
277
|
+
request.res.write('retry: 2000\n\n')
|
|
278
|
+
const client: Client = { res: request.res, subjects, last: -1 }
|
|
279
|
+
if (Number.isInteger(after) && after >= 0) {
|
|
280
|
+
client.last = after
|
|
281
|
+
for (const change of changesAfter(watch, after)) deliver(client, change)
|
|
282
|
+
} else client.last = cursor
|
|
283
|
+
clients.add(client)
|
|
284
|
+
request.req.on('close', () => clients.delete(client))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function get(request: Request) {
|
|
288
|
+
const { pathname, searchParams } = request.url
|
|
289
|
+
const asset = files[pathname as keyof typeof files]
|
|
290
|
+
if (asset && typeof asset === 'object')
|
|
291
|
+
return send(request.res, 200, asset.type, asset.body, { 'Cache-Control': 'no-cache' })
|
|
292
|
+
const font = routes.font.exec(pathname)
|
|
293
|
+
if (font)
|
|
294
|
+
return send(request.res, 200, 'font/woff2', files.font(font[1]), {
|
|
295
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
296
|
+
})
|
|
297
|
+
if (pathname === '/events') return events(request)
|
|
298
|
+
const evidence = routes.evidence.exec(pathname)
|
|
299
|
+
if (evidence) return serveEvidence(request, evidence[1])
|
|
300
|
+
const shown = context(request)
|
|
301
|
+
if (pathname === '/') return render(request, inboxPage(store))
|
|
302
|
+
if (pathname === '/plans') return render(request, plansPage(store, searchParams))
|
|
303
|
+
if (pathname === '/runs') return render(request, runsPage(store, searchParams))
|
|
304
|
+
if (pathname === '/log') return render(request, logPage(store, searchParams))
|
|
305
|
+
if (pathname === '/actor') return render(request, actorPage(shown, safeBack(searchParams.get('back'))))
|
|
306
|
+
const latest = routes.latestPlan.exec(pathname)
|
|
307
|
+
if (latest) {
|
|
308
|
+
const detail = planDetail(store, latest[1])
|
|
309
|
+
return redirect(request.res, `/plans/${latest[1]}@${detail.latestRevision}`)
|
|
310
|
+
}
|
|
311
|
+
const plan = routes.plan.exec(pathname)
|
|
312
|
+
if (plan)
|
|
313
|
+
return render(
|
|
314
|
+
request,
|
|
315
|
+
planPage(store, shown, plan[1], Number(plan[2]), null, searchParams.get('history') === 'all')
|
|
316
|
+
)
|
|
317
|
+
const edit = routes.edit.exec(pathname)
|
|
318
|
+
if (edit) return render(request, editPage(store, shown, edit[1], Number(edit[2])))
|
|
319
|
+
const history = routes.history.exec(pathname)
|
|
320
|
+
if (history) {
|
|
321
|
+
const detail = planDetail(store, history[1], Number(history[2]))
|
|
322
|
+
return send(request.res, 200, 'text/html; charset=utf-8', planHistoryList(store, detail, true).html)
|
|
323
|
+
}
|
|
324
|
+
const planExport = routes.planExport.exec(pathname)
|
|
325
|
+
if (planExport) {
|
|
326
|
+
const [, planId, revision, format] = planExport
|
|
327
|
+
const body = exportPlan(store, `${planId}@${revision}`, format)
|
|
328
|
+
return download(request, body, format, `${planId}-${revision}-plan.${format}`)
|
|
329
|
+
}
|
|
330
|
+
const report = routes.report.exec(pathname)
|
|
331
|
+
if (report) {
|
|
332
|
+
const [, runId, format] = report
|
|
333
|
+
if (!runDetail(store, runId).result)
|
|
334
|
+
return errorPage(
|
|
335
|
+
request,
|
|
336
|
+
409,
|
|
337
|
+
'Report not ready',
|
|
338
|
+
`${runId} is in progress. The report is ready when it finishes.`
|
|
339
|
+
)
|
|
340
|
+
return download(request, exportRun(store, runId, format), format, `${runId}-report.${format}`)
|
|
341
|
+
}
|
|
342
|
+
const gap = routes.gap.exec(pathname)
|
|
343
|
+
if (gap) return render(request, gapPage(store, shown, gap[1], Number(gap[2]), gap[3]))
|
|
344
|
+
const run = routes.run.exec(pathname)
|
|
345
|
+
if (run) return render(request, runPage(store, shown, run[1]))
|
|
346
|
+
return errorPage(request, 404, 'Page not found', `Checkmate has no page at ${pathname}. Start from the inbox.`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function actorPage(shown: Context, back: string): Page {
|
|
350
|
+
return {
|
|
351
|
+
title: 'Your name',
|
|
352
|
+
section: null,
|
|
353
|
+
subjects: ['inbox'],
|
|
354
|
+
body: html`${heading('Your name', 'Checkmate records this name with each decision you make in this browser.')}
|
|
355
|
+
<form method="post" action="/actor" class="panel narrow">
|
|
356
|
+
<input type="hidden" name="csrf" value="${csrf}" />
|
|
357
|
+
<input type="hidden" name="back" value="${back}" />
|
|
358
|
+
<label class="lab" for="actor-name">Name</label>
|
|
359
|
+
<input id="actor-name" name="actor" required autocomplete="name" value="${shown.actor ?? ''}" />
|
|
360
|
+
<div class="btns">
|
|
361
|
+
<button class="btn primary">Save name</button> <a class="btn" href="${back}">Cancel</a>
|
|
362
|
+
</div>
|
|
363
|
+
</form>`,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function actorFrom(request: Request, form: URLSearchParams) {
|
|
368
|
+
const typed = form.get('actor')?.trim() ?? ''
|
|
369
|
+
const actor = typed || request.cookies.checkmate_actor?.trim() || fallbackActor
|
|
370
|
+
return { actor, cookies: typed ? [cookie('checkmate_actor', typed)] : [] }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function values(form: URLSearchParams, names: string[]) {
|
|
374
|
+
return Object.fromEntries(names.map((name) => [name, form.get(name) ?? '']))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function refuse(request: Request, page: () => Page, cookies: string[] = []) {
|
|
378
|
+
const shown = page()
|
|
379
|
+
render(request, { ...shown, status: shown.status ?? 422 }, cookies)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function answer(request: Request, form: URLSearchParams, planId: string, revision: number, gapId: string) {
|
|
383
|
+
const state: FormState = {
|
|
384
|
+
...emptyState(),
|
|
385
|
+
values: values(form, ['kind', 'value', 'source', 'rationale', 'actor']),
|
|
386
|
+
}
|
|
387
|
+
const { actor, cookies } = actorFrom(request, form)
|
|
388
|
+
const kind = state.values.kind
|
|
389
|
+
const page = () => gapPage(store, context(request), planId, revision, gapId, state)
|
|
390
|
+
const check = (condition: boolean, message: string) => {
|
|
391
|
+
if (!condition) state.error ??= message
|
|
392
|
+
}
|
|
393
|
+
check(Boolean(actor), 'Enter your name. Checkmate records it with your answer.')
|
|
394
|
+
check(['clarify', 'accept_assumption', 'defer'].includes(kind), 'Choose Clarify, Accept assumption, or Defer.')
|
|
395
|
+
check(Boolean(state.values.value.trim()), 'Write the answer.')
|
|
396
|
+
check(
|
|
397
|
+
kind !== 'clarify' || Boolean(state.values.source.trim()),
|
|
398
|
+
'A clarification needs a source. Name where the answer comes from.'
|
|
399
|
+
)
|
|
400
|
+
check(Boolean(state.values.rationale.trim()), 'Write a rationale. Checkmate records it with your answer.')
|
|
401
|
+
if (state.error) return refuse(request, page, cookies)
|
|
402
|
+
try {
|
|
403
|
+
answerGap(store, actor, {
|
|
404
|
+
planId,
|
|
405
|
+
gapId,
|
|
406
|
+
kind,
|
|
407
|
+
value: state.values.value.trim(),
|
|
408
|
+
source: kind === 'clarify' ? state.values.source.trim() : null,
|
|
409
|
+
rationale: state.values.rationale.trim(),
|
|
410
|
+
expectSha256: form.get('sha') ?? '',
|
|
411
|
+
})
|
|
412
|
+
} catch (error) {
|
|
413
|
+
const latest = planDetail(store, planId)
|
|
414
|
+
if (latest.sha256 !== form.get('sha'))
|
|
415
|
+
state.stale = html`<strong>${planId} has a newer revision (${latest.latestRevision}).</strong> Your
|
|
416
|
+
answer was not recorded. Your text is kept below.
|
|
417
|
+
<a href="/plans/${planId}@${latest.latestRevision}/gaps/${gapId}"
|
|
418
|
+
>Review revision ${latest.latestRevision}</a
|
|
419
|
+
>, then send again.`
|
|
420
|
+
else state.error = plainMessage(error)
|
|
421
|
+
return refuse(request, page, cookies)
|
|
422
|
+
}
|
|
423
|
+
const next = nextItem(`Answered ${planId} · ${gapId}`, cookies)
|
|
424
|
+
redirect(request.res, next.location, next.cookies)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function verdictState(form: URLSearchParams) {
|
|
428
|
+
return { ...emptyState(), values: values(form, ['verdict', 'rationale', 'actor']) }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function decide(
|
|
432
|
+
request: Request,
|
|
433
|
+
form: URLSearchParams,
|
|
434
|
+
choices: string[],
|
|
435
|
+
page: (state: FormState) => Page,
|
|
436
|
+
record: (actor: string, verdict: string, rationale: string, sha: string) => string,
|
|
437
|
+
staleCheck: (sha: string) => FormState['stale']
|
|
438
|
+
) {
|
|
439
|
+
const state = verdictState(form)
|
|
440
|
+
const { actor, cookies } = actorFrom(request, form)
|
|
441
|
+
const view = () => page(state)
|
|
442
|
+
if (form.get('back')) return refuse(request, () => ({ ...view(), status: 200 }), cookies)
|
|
443
|
+
if (!choices.includes(state.values.verdict)) state.error = 'Choose a decision.'
|
|
444
|
+
else if (!actor) state.error = 'Enter your name. Checkmate records it with your decision.'
|
|
445
|
+
else if (!state.values.rationale.trim())
|
|
446
|
+
state.error = 'Write a rationale. Checkmate records it with your decision.'
|
|
447
|
+
if (state.error) return refuse(request, view, cookies)
|
|
448
|
+
if (form.get('confirmed') !== '1') {
|
|
449
|
+
state.confirm = true
|
|
450
|
+
return refuse(request, () => ({ ...view(), status: 200 }), cookies)
|
|
451
|
+
}
|
|
452
|
+
const sha = form.get('sha') ?? ''
|
|
453
|
+
let flash: string
|
|
454
|
+
try {
|
|
455
|
+
flash = record(actor, state.values.verdict, state.values.rationale.trim(), sha)
|
|
456
|
+
} catch (error) {
|
|
457
|
+
state.stale = staleCheck(sha)
|
|
458
|
+
if (!state.stale) state.error = plainMessage(error)
|
|
459
|
+
return refuse(request, view, cookies)
|
|
460
|
+
}
|
|
461
|
+
const next = nextItem(flash, cookies)
|
|
462
|
+
redirect(request.res, next.location, next.cookies)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function decidePlan(request: Request, form: URLSearchParams, planId: string, revision: number) {
|
|
466
|
+
const subject = `${planId}@${revision}`
|
|
467
|
+
decide(
|
|
468
|
+
request,
|
|
469
|
+
form,
|
|
470
|
+
['approve', 'reject'],
|
|
471
|
+
(state) => planPage(store, context(request), planId, revision, state),
|
|
472
|
+
(actor, verdict, rationale, sha) => {
|
|
473
|
+
const input = { reference: subject, rationale, expectSha256: sha }
|
|
474
|
+
if (verdict === 'approve') approvePlan(store, actor, input)
|
|
475
|
+
else rejectPlan(store, actor, input)
|
|
476
|
+
return `${verdict === 'approve' ? 'Approved' : 'Rejected'} ${subject}`
|
|
477
|
+
},
|
|
478
|
+
(sha) => {
|
|
479
|
+
const detail = planDetail(store, planId, revision)
|
|
480
|
+
if (detail.sha256 === sha) return null
|
|
481
|
+
return html`<strong>${subject} has another digest now.</strong> Your decision was not recorded. Your
|
|
482
|
+
text is kept below. Review the plan, then send again.`
|
|
483
|
+
}
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function decideRun(request: Request, form: URLSearchParams, runId: string) {
|
|
488
|
+
decide(
|
|
489
|
+
request,
|
|
490
|
+
form,
|
|
491
|
+
['accept', 'reject'],
|
|
492
|
+
(state) => runPage(store, context(request), runId, state),
|
|
493
|
+
(actor, verdict, rationale, sha) => {
|
|
494
|
+
const input = { runId, rationale, expectSha256: sha }
|
|
495
|
+
if (verdict === 'accept') acceptResult(store, actor, input)
|
|
496
|
+
else rejectResult(store, actor, input)
|
|
497
|
+
return `${verdict === 'accept' ? 'Accepted' : 'Rejected'} ${runId}`
|
|
498
|
+
},
|
|
499
|
+
(sha) => {
|
|
500
|
+
const result = runDetail(store, runId).result
|
|
501
|
+
if (result?.sha256 === sha) return null
|
|
502
|
+
return html`<strong>${runId} has a new result.</strong> Your decision was not recorded. Your text is
|
|
503
|
+
kept below. Review the result, then send again.`
|
|
504
|
+
}
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function adopt(request: Request, form: URLSearchParams, runId: string, gapId: string) {
|
|
509
|
+
const { actor, cookies } = actorFrom(request, form)
|
|
510
|
+
const planId = runDetail(store, runId).planId
|
|
511
|
+
const sha = form.get('sha') ?? ''
|
|
512
|
+
const page = (message: AdoptError['message']) => () =>
|
|
513
|
+
runPage(store, context(request), runId, null, { gapId, message })
|
|
514
|
+
if (!actor) return refuse(request, page('Enter your name. Checkmate records it with the change.'), cookies)
|
|
515
|
+
const latest = planDetail(store, planId)
|
|
516
|
+
let adopted
|
|
517
|
+
try {
|
|
518
|
+
adopted = adoptGaps(store, actor, {
|
|
519
|
+
reference: `${planId}@${latest.latestRevision}`,
|
|
520
|
+
runId,
|
|
521
|
+
gapIds: [gapId],
|
|
522
|
+
expectSha256: sha,
|
|
523
|
+
})
|
|
524
|
+
} catch (error) {
|
|
525
|
+
const message =
|
|
526
|
+
latest.sha256 === sha
|
|
527
|
+
? plainMessage(error)
|
|
528
|
+
: html`<strong>${planId} changed after you opened this page.</strong> ${gapId} was not brought into
|
|
529
|
+
the plan. Review the plan, then send again.`
|
|
530
|
+
return refuse(request, page(message), cookies)
|
|
531
|
+
}
|
|
532
|
+
redirect(request.res, `/runs/${runId}#${gapId}`, [
|
|
533
|
+
...cookies,
|
|
534
|
+
cookie('checkmate_toast', `Brought ${gapId} into ${adopted.subject}`, 60),
|
|
535
|
+
])
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function saveEdit(request: Request, form: URLSearchParams, planId: string, revision: number) {
|
|
539
|
+
const { actor, cookies } = actorFrom(request, form)
|
|
540
|
+
const subject = `${planId}@${revision}`
|
|
541
|
+
const detail = planDetail(store, planId, revision)
|
|
542
|
+
const base = planCandidate(detail.plan)
|
|
543
|
+
const mode = form.get('mode') === 'json' ? 'json' : 'form'
|
|
544
|
+
const sha = form.get('sha') ?? ''
|
|
545
|
+
const state: EditState = {
|
|
546
|
+
mode,
|
|
547
|
+
candidate: base,
|
|
548
|
+
json: form.get('json') ?? '',
|
|
549
|
+
actor: form.get('actor') ?? '',
|
|
550
|
+
error: null,
|
|
551
|
+
stale: null,
|
|
552
|
+
}
|
|
553
|
+
const page = () => editPage(store, context(request), planId, revision, state)
|
|
554
|
+
let candidate: unknown
|
|
555
|
+
if (mode === 'json') {
|
|
556
|
+
try {
|
|
557
|
+
candidate = JSON.parse(state.json)
|
|
558
|
+
} catch (error) {
|
|
559
|
+
state.error = `The JSON is not valid: ${plainMessage(error)}`
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
state.candidate = candidateFromForm(base, form)
|
|
563
|
+
state.json = candidateJson(state.candidate)
|
|
564
|
+
candidate = state.candidate
|
|
565
|
+
}
|
|
566
|
+
if (!actor) state.error ??= 'Enter your name. Checkmate records it with the edit.'
|
|
567
|
+
if (state.error) return refuse(request, page, cookies)
|
|
568
|
+
let saved
|
|
569
|
+
try {
|
|
570
|
+
saved = editPlan(store, actor, { reference: subject, candidate, expectSha256: sha }).data
|
|
571
|
+
} catch (error) {
|
|
572
|
+
const latest = planDetail(store, planId)
|
|
573
|
+
if (latest.latestRevision !== revision || latest.sha256 !== sha)
|
|
574
|
+
state.stale = html`<strong>${planId} changed after you opened the editor.</strong> Your edits were not
|
|
575
|
+
saved. Your text is kept below.
|
|
576
|
+
<a href="/plans/${planId}@${latest.latestRevision}/edit">Open the latest version</a>, then edit
|
|
577
|
+
again.`
|
|
578
|
+
else state.error = plainMessage(error)
|
|
579
|
+
return refuse(request, page, cookies)
|
|
580
|
+
}
|
|
581
|
+
const saving = `${planId}@${saved.revision}`
|
|
582
|
+
const flash =
|
|
583
|
+
saved.edit === 'in_place'
|
|
584
|
+
? `Saved ${saving} in place`
|
|
585
|
+
: `Saved ${saving}${planDetail(store, planId).approval.policy === 'none' ? '' : '. It needs approval.'}`
|
|
586
|
+
redirect(request.res, `/plans/${saving}`, [...cookies, cookie('checkmate_toast', flash, 60)])
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function post(request: Request) {
|
|
590
|
+
const origin = request.req.headers.origin
|
|
591
|
+
if (origin !== undefined && origin !== `http://${request.req.headers.host}`)
|
|
592
|
+
return send(
|
|
593
|
+
request.res,
|
|
594
|
+
403,
|
|
595
|
+
'text/plain; charset=utf-8',
|
|
596
|
+
'Refused: the request comes from another origin.'
|
|
597
|
+
)
|
|
598
|
+
const form = await readForm(request.req)
|
|
599
|
+
if (!same(form.get('csrf') ?? '', csrf))
|
|
600
|
+
return errorPage(
|
|
601
|
+
request,
|
|
602
|
+
403,
|
|
603
|
+
'Form expired',
|
|
604
|
+
'This form came from an earlier start of the UI. Load the page again, then send the form again.'
|
|
605
|
+
)
|
|
606
|
+
const { pathname } = request.url
|
|
607
|
+
if (pathname === '/theme') {
|
|
608
|
+
const theme = form.get('theme') as Theme
|
|
609
|
+
return redirect(request.res, safeBack(form.get('back')), [
|
|
610
|
+
cookie('checkmate_theme', themes.includes(theme) ? theme : 'system'),
|
|
611
|
+
])
|
|
612
|
+
}
|
|
613
|
+
if (pathname === '/actor') {
|
|
614
|
+
const name = form.get('actor')?.trim() ?? ''
|
|
615
|
+
if (!name)
|
|
616
|
+
return errorPage(request, 422, 'Your name', 'Enter a name. Checkmate records it with each decision.')
|
|
617
|
+
return redirect(request.res, safeBack(form.get('back')), [cookie('checkmate_actor', name)])
|
|
618
|
+
}
|
|
619
|
+
const edit = routes.edit.exec(pathname)
|
|
620
|
+
if (edit) return saveEdit(request, form, edit[1], Number(edit[2]))
|
|
621
|
+
const gap = routes.gap.exec(pathname)
|
|
622
|
+
if (gap) return answer(request, form, gap[1], Number(gap[2]), gap[3])
|
|
623
|
+
const plan = routes.plan.exec(pathname)
|
|
624
|
+
if (plan) return decidePlan(request, form, plan[1], Number(plan[2]))
|
|
625
|
+
const run = routes.run.exec(pathname)
|
|
626
|
+
if (run) return decideRun(request, form, run[1])
|
|
627
|
+
const runGap = routes.runGap.exec(pathname)
|
|
628
|
+
if (runGap) return adopt(request, form, runGap[1], runGap[2])
|
|
629
|
+
return errorPage(request, 404, 'Page not found', `Checkmate has no form at ${pathname}.`)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function handle(req: IncomingMessage, res: ServerResponse) {
|
|
633
|
+
const host = req.headers.host
|
|
634
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)
|
|
635
|
+
return send(
|
|
636
|
+
res,
|
|
637
|
+
421,
|
|
638
|
+
'text/plain; charset=utf-8',
|
|
639
|
+
'Refused: unknown host. Open the URL that checkmate ui printed.'
|
|
640
|
+
)
|
|
641
|
+
const request: Request = {
|
|
642
|
+
req,
|
|
643
|
+
res,
|
|
644
|
+
url: new URL(req.url ?? '/', `http://${host}`),
|
|
645
|
+
cookies: parseCookies(req.headers.cookie),
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
if (req.method === 'GET' || req.method === 'HEAD') return get(request)
|
|
649
|
+
if (req.method === 'POST') return await post(request)
|
|
650
|
+
send(res, 405, 'text/plain; charset=utf-8', 'Method not allowed.', { Allow: 'GET, POST' })
|
|
651
|
+
} catch (error) {
|
|
652
|
+
const message = plainMessage(error)
|
|
653
|
+
if (res.headersSent) return res.end()
|
|
654
|
+
if (notFoundError(message)) return errorPage(request, 404, 'Not found', message)
|
|
655
|
+
errorPage(request, 500, 'Something went wrong', `Checkmate could not finish the request: ${message}`)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const server = createServer((req, res) => {
|
|
660
|
+
void handle(req, res)
|
|
661
|
+
})
|
|
662
|
+
await new Promise<void>((resolve, reject) => {
|
|
663
|
+
server.once('error', reject)
|
|
664
|
+
server.listen(options.port ?? 0, '127.0.0.1', () => resolve())
|
|
665
|
+
})
|
|
666
|
+
const address = server.address()
|
|
667
|
+
port = typeof address === 'object' && address ? address.port : 0
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
url: `http://127.0.0.1:${port}/`,
|
|
671
|
+
close: async () => {
|
|
672
|
+
clearInterval(poll)
|
|
673
|
+
clearInterval(heartbeat)
|
|
674
|
+
for (const client of clients) client.res.end()
|
|
675
|
+
clients.clear()
|
|
676
|
+
await new Promise<void>((resolve) => {
|
|
677
|
+
server.close(() => resolve())
|
|
678
|
+
server.closeAllConnections()
|
|
679
|
+
})
|
|
680
|
+
closeStore(watch)
|
|
681
|
+
closeStore(store)
|
|
682
|
+
},
|
|
683
|
+
}
|
|
684
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type Validator = ((value: unknown) => boolean) & {
|
|
2
|
+
errors?: Array<{ instancePath: string; message?: string }> | null
|
|
3
|
+
}
|
|
4
|
+
export const finding: Validator
|
|
5
|
+
export const gap: Validator
|
|
6
|
+
export const plan: Validator
|
|
7
|
+
export const planCandidate: Validator
|
|
8
|
+
export const planDelta: Validator
|
|
9
|
+
export const planPatch: Validator
|
|
10
|
+
export const planRequest: Validator
|
|
11
|
+
export const profile: Validator
|
|
12
|
+
export const response: Validator
|
|
13
|
+
export const result: Validator
|