terramend 0.2.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/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- package/src/utils/workflow.ts +25 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HashiCorp terraform-mcp-server integration (P2.2).
|
|
3
|
+
*
|
|
4
|
+
* When the `terraform_mcp` input is on, the agent harness registers a SECOND
|
|
5
|
+
* MCP server next to terramend's: HashiCorp's terraform-mcp-server, run as a
|
|
6
|
+
* Docker container over stdio. It gives the fixing agent live Terraform
|
|
7
|
+
* Registry knowledge — current module versions, provider argument shapes —
|
|
8
|
+
* which directly powers module-source-aware fixes and generation.
|
|
9
|
+
*
|
|
10
|
+
* Security posture:
|
|
11
|
+
* - the image is VERSION-PINNED (`TERRAFORM_MCP_IMAGE`); bump deliberately.
|
|
12
|
+
* (P4's SHA-pinning sweep will move this to a digest pin.)
|
|
13
|
+
* - only the read-only `registry` toolset is enabled — no TFE operations,
|
|
14
|
+
* and no TFE_TOKEN is ever passed.
|
|
15
|
+
* - degrades green: docker absent → a log note, never a failed run.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import type { ResolvedPayload } from "#app/utils/payload";
|
|
20
|
+
|
|
21
|
+
/** pinned release of hashicorp/terraform-mcp-server. Bump deliberately. */
|
|
22
|
+
export const TERRAFORM_MCP_IMAGE = "hashicorp/terraform-mcp-server:0.5.2";
|
|
23
|
+
|
|
24
|
+
/** the registry name the server is registered under in agent MCP configs —
|
|
25
|
+
* matches HashiCorp's own client-config examples, so agent guidance written
|
|
26
|
+
* against "the terraform MCP server" finds it under the expected key. */
|
|
27
|
+
export const TERRAFORM_MCP_SERVER_NAME = "terraform";
|
|
28
|
+
|
|
29
|
+
/** stdio invocation, registry toolset ONLY (module/provider knowledge — the
|
|
30
|
+
* agent must never get TFE workspace operations from this surface). */
|
|
31
|
+
const TERRAFORM_MCP_DOCKER_ARGS = [
|
|
32
|
+
"run",
|
|
33
|
+
"-i",
|
|
34
|
+
"--rm",
|
|
35
|
+
TERRAFORM_MCP_IMAGE,
|
|
36
|
+
"--toolsets=registry",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
export type TerraformMcpResolution =
|
|
40
|
+
| { kind: "disabled" }
|
|
41
|
+
| { kind: "docker_missing"; note: string }
|
|
42
|
+
| { kind: "available"; command: "docker"; args: string[] };
|
|
43
|
+
|
|
44
|
+
let cachedDockerAvailable: boolean | undefined;
|
|
45
|
+
|
|
46
|
+
/** test hook — the docker probe is cached per process. */
|
|
47
|
+
export function _clearDockerProbeCache(): void {
|
|
48
|
+
cachedDockerAvailable = undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function dockerAvailable(): boolean {
|
|
52
|
+
if (cachedDockerAvailable === undefined) {
|
|
53
|
+
const probe = spawnSync("docker", ["--version"], {
|
|
54
|
+
stdio: "pipe",
|
|
55
|
+
encoding: "utf-8",
|
|
56
|
+
timeout: 10_000,
|
|
57
|
+
});
|
|
58
|
+
cachedDockerAvailable = !probe.error && probe.status === 0;
|
|
59
|
+
}
|
|
60
|
+
return cachedDockerAvailable;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decide whether the run gets the terraform-mcp-server, as a discriminated
|
|
65
|
+
* union so each harness handles all three outcomes explicitly: register the
|
|
66
|
+
* server (`available`), log the degrade-green note (`docker_missing`), or do
|
|
67
|
+
* nothing (`disabled`).
|
|
68
|
+
*/
|
|
69
|
+
export function resolveTerraformMcp(
|
|
70
|
+
payload: Pick<ResolvedPayload, "terraformMcp">,
|
|
71
|
+
): TerraformMcpResolution {
|
|
72
|
+
if (!payload.terraformMcp) return { kind: "disabled" };
|
|
73
|
+
if (!dockerAvailable()) {
|
|
74
|
+
return {
|
|
75
|
+
kind: "docker_missing",
|
|
76
|
+
note:
|
|
77
|
+
"terraform_mcp requested but docker is not available on this runner — " +
|
|
78
|
+
"continuing without Terraform Registry MCP (module/provider knowledge falls " +
|
|
79
|
+
"back to the registry HTTP lookups in terraform_version_currency)",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { kind: "available", command: "docker", args: [...TERRAFORM_MCP_DOCKER_ARGS] };
|
|
83
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isValidTimeString, parseTimeString, resolveTimeoutMs } from "#app/utils/time";
|
|
3
|
+
|
|
4
|
+
describe("parseTimeString", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
["10m", 600000], // 10 minutes
|
|
7
|
+
["1h", 3600000], // 1 hour
|
|
8
|
+
["30s", 30000], // 30 seconds
|
|
9
|
+
["1h30m", 5400000], // 1 hour 30 minutes
|
|
10
|
+
["10m12s", 612000], // 10 minutes 12 seconds
|
|
11
|
+
["1h30m45s", 5445000], // 1 hour 30 minutes 45 seconds
|
|
12
|
+
["2h", 7200000], // 2 hours
|
|
13
|
+
["90m", 5400000], // 90 minutes
|
|
14
|
+
["0m", 0], // 0 minutes (edge case)
|
|
15
|
+
["0s", 0], // 0 seconds (edge case)
|
|
16
|
+
])("parses '%s' to %d ms", (input, expected) => {
|
|
17
|
+
expect(parseTimeString(input)).toBe(expected);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it.each([
|
|
21
|
+
[""], // empty string
|
|
22
|
+
["abc"], // no numbers
|
|
23
|
+
["10"], // no unit
|
|
24
|
+
["10x"], // invalid unit
|
|
25
|
+
["h10m"], // hours without number
|
|
26
|
+
["m10"], // units before number
|
|
27
|
+
["10 m"], // space between number and unit
|
|
28
|
+
["-10m"], // negative number
|
|
29
|
+
["10.5m"], // decimal
|
|
30
|
+
["10m 30s"], // space between components
|
|
31
|
+
])("returns null for invalid input '%s'", (input) => {
|
|
32
|
+
expect(parseTimeString(input)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("isValidTimeString", () => {
|
|
37
|
+
it.each([
|
|
38
|
+
"10m",
|
|
39
|
+
"1h",
|
|
40
|
+
"30s",
|
|
41
|
+
"1h30m",
|
|
42
|
+
"10m12s",
|
|
43
|
+
"1h30m45s",
|
|
44
|
+
])("returns true for valid '%s'", (input) => {
|
|
45
|
+
expect(isValidTimeString(input)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it.each(["", "abc", "10", "10x", "-10m", "10.5m"])("returns false for invalid '%s'", (input) => {
|
|
49
|
+
expect(isValidTimeString(input)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("resolveTimeoutMs", () => {
|
|
54
|
+
it.each([
|
|
55
|
+
["1h", 3_600_000],
|
|
56
|
+
["10m", 600_000],
|
|
57
|
+
["1h30m", 5_400_000],
|
|
58
|
+
])("returns ms for valid '%s'", (input, expected) => {
|
|
59
|
+
expect(resolveTimeoutMs(input)).toBe(expected);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns null for undefined input (no timeout configured)", () => {
|
|
63
|
+
expect(resolveTimeoutMs(undefined)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each([
|
|
67
|
+
["0m"],
|
|
68
|
+
["0s"],
|
|
69
|
+
["0h"],
|
|
70
|
+
["0h0m0s"],
|
|
71
|
+
])("returns null for zero-value '%s' so the caller doesn't insta-timeout", (input) => {
|
|
72
|
+
// 0ms setTimeout fires in the same tick — without this guard, a user
|
|
73
|
+
// typo like "0m" rejected the run as "timed out after 0m" the instant
|
|
74
|
+
// it started. see also the matching payload.timeout handling in main.ts.
|
|
75
|
+
expect(resolveTimeoutMs(input)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it.each([
|
|
79
|
+
["abc"],
|
|
80
|
+
["10"],
|
|
81
|
+
["10x"],
|
|
82
|
+
["-10m"],
|
|
83
|
+
["10.5m"],
|
|
84
|
+
[""],
|
|
85
|
+
])("returns null for unparseable input '%s'", (input) => {
|
|
86
|
+
expect(resolveTimeoutMs(input)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns null for values past node's setTimeout ceiling (~24.8 days)", () => {
|
|
90
|
+
// 2^31 - 1 ms = 2147483647 ms = 596h31m23s647ms. node silently clamps any
|
|
91
|
+
// delay above that down to 1ms — a user asking for "999h" would have the
|
|
92
|
+
// run terminate with "timed out after 999h" within a single tick. reject
|
|
93
|
+
// here so the caller's warn + fallback kicks in instead.
|
|
94
|
+
expect(resolveTimeoutMs("999h")).toBeNull();
|
|
95
|
+
// 600h = 2_160_000_000 ms, safely past the cap.
|
|
96
|
+
expect(resolveTimeoutMs("600h")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("accepts the largest value that setTimeout can still honor", () => {
|
|
100
|
+
// 596h31m23s = 2_147_483_000 ms — just under 2^31-1. this must remain
|
|
101
|
+
// usable so the "reject over-max" rule doesn't accidentally reject the
|
|
102
|
+
// boundary itself.
|
|
103
|
+
expect(resolveTimeoutMs("596h31m23s")).toBe(2_147_483_000);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* time string parsing utilities for timeout configuration.
|
|
3
|
+
* supports formats like "10m", "1h30m", "10m12s", "30s".
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// special value indicating timeout is explicitly disabled via --notimeout flag
|
|
7
|
+
export const TIMEOUT_DISABLED = "none";
|
|
8
|
+
|
|
9
|
+
// time string regex: supports formats like "10m", "1h30m", "10m12s", "30s"
|
|
10
|
+
// at least one component (hours, minutes, or seconds) is required
|
|
11
|
+
const TIME_STRING_REGEX = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* parse a time string like "10m", "1h30m", "10m12s" into milliseconds.
|
|
15
|
+
* returns null if the string is not a valid time format.
|
|
16
|
+
*/
|
|
17
|
+
export function parseTimeString(input: string): number | null {
|
|
18
|
+
const match = input.match(TIME_STRING_REGEX);
|
|
19
|
+
if (!match || (!match[1] && !match[2] && !match[3])) return null;
|
|
20
|
+
|
|
21
|
+
const hours = parseInt(match[1] || "0", 10);
|
|
22
|
+
const minutes = parseInt(match[2] || "0", 10);
|
|
23
|
+
const seconds = parseInt(match[3] || "0", 10);
|
|
24
|
+
|
|
25
|
+
return (hours * 3600 + minutes * 60 + seconds) * 1000;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* check if a string is a valid time format.
|
|
30
|
+
*/
|
|
31
|
+
export function isValidTimeString(input: string): boolean {
|
|
32
|
+
return parseTimeString(input) !== null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* resolve a user-supplied timeout string into a setTimeout-safe number of
|
|
37
|
+
* milliseconds, returning null when the input is unusable.
|
|
38
|
+
*
|
|
39
|
+
* "unusable" covers three cases that all cause setTimeout to misbehave if
|
|
40
|
+
* passed through naively:
|
|
41
|
+
* - unparseable ("abc", "10x") — parseTimeString returns null.
|
|
42
|
+
* - zero ("0m", "0s") — setTimeout fires immediately, so the run would
|
|
43
|
+
* look like an insta-fail with the confusing message "timed out after 0m".
|
|
44
|
+
* - overflow (e.g. "999h") — node clamps any delay above 2^31-1 ms
|
|
45
|
+
* (~24.8 days) to 1 ms, so a user who asked for "596h" or more would
|
|
46
|
+
* get a timeout in a single tick instead of the multi-day window they
|
|
47
|
+
* requested. user almost certainly meant --notimeout.
|
|
48
|
+
*
|
|
49
|
+
* the caller should warn and fall back to its own default when this returns
|
|
50
|
+
* null; the reason is always "the input can't be honored" regardless of
|
|
51
|
+
* which branch triggered it.
|
|
52
|
+
*/
|
|
53
|
+
const TIMEOUT_MAX_MS = 2_147_483_647;
|
|
54
|
+
export function resolveTimeoutMs(input: string | undefined): number | null {
|
|
55
|
+
if (!input) return null;
|
|
56
|
+
const parsed = parseTimeString(input);
|
|
57
|
+
if (parsed === null || parsed <= 0 || parsed > TIMEOUT_MAX_MS) return null;
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ThinkingTimer, Timer } from "#app/utils/timer";
|
|
4
|
+
|
|
5
|
+
vi.mock("#app/utils/cli", () => ({
|
|
6
|
+
log: {
|
|
7
|
+
info: vi.fn(),
|
|
8
|
+
debug: vi.fn(),
|
|
9
|
+
warning: vi.fn(),
|
|
10
|
+
error: vi.fn(),
|
|
11
|
+
success: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { log } from "#app/utils/cli";
|
|
16
|
+
|
|
17
|
+
const debugMock = vi.mocked(log.debug);
|
|
18
|
+
const infoMock = vi.mocked(log.info);
|
|
19
|
+
|
|
20
|
+
let nowSpy: ReturnType<typeof vi.spyOn>;
|
|
21
|
+
let now = 0;
|
|
22
|
+
|
|
23
|
+
function setNow(value: number): void {
|
|
24
|
+
now = value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
now = 0;
|
|
30
|
+
nowSpy = vi.spyOn(performance, "now").mockImplementation(() => now);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
nowSpy.mockRestore();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("Timer", () => {
|
|
38
|
+
it("measures the first checkpoint from construction", () => {
|
|
39
|
+
setNow(100);
|
|
40
|
+
const timer = new Timer();
|
|
41
|
+
setNow(350);
|
|
42
|
+
timer.checkpoint("startup");
|
|
43
|
+
expect(debugMock).toHaveBeenCalledWith("» startup: 250ms");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("measures subsequent checkpoints from the previous one", () => {
|
|
47
|
+
setNow(0);
|
|
48
|
+
const timer = new Timer();
|
|
49
|
+
setNow(50);
|
|
50
|
+
timer.checkpoint("first");
|
|
51
|
+
setNow(80);
|
|
52
|
+
timer.checkpoint("second");
|
|
53
|
+
expect(debugMock).toHaveBeenLastCalledWith("» second: 30ms");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("ThinkingTimer", () => {
|
|
58
|
+
it("does nothing on a tool call without a prior tool result", () => {
|
|
59
|
+
const timer = new ThinkingTimer();
|
|
60
|
+
setNow(10_000);
|
|
61
|
+
timer.markToolCall();
|
|
62
|
+
expect(infoMock).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("stays silent below the thinking threshold", () => {
|
|
66
|
+
const timer = new ThinkingTimer();
|
|
67
|
+
setNow(0);
|
|
68
|
+
timer.markToolResult();
|
|
69
|
+
setNow(2999);
|
|
70
|
+
timer.markToolCall();
|
|
71
|
+
expect(infoMock).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("logs the thinking duration once the threshold is crossed", () => {
|
|
75
|
+
const timer = new ThinkingTimer();
|
|
76
|
+
setNow(0);
|
|
77
|
+
timer.markToolResult();
|
|
78
|
+
setNow(4500);
|
|
79
|
+
timer.markToolCall();
|
|
80
|
+
expect(infoMock).toHaveBeenCalledWith("» thought for 4.5 seconds");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("prefixes output with the caller-provided line formatter", () => {
|
|
84
|
+
const timer = new ThinkingTimer((line) => `[subagent] ${line}`);
|
|
85
|
+
setNow(0);
|
|
86
|
+
timer.markToolResult();
|
|
87
|
+
setNow(10_000);
|
|
88
|
+
timer.markToolCall();
|
|
89
|
+
expect(infoMock).toHaveBeenCalledWith("[subagent] » thought for 10 seconds");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { log } from "#app/utils/cli";
|
|
3
|
+
|
|
4
|
+
export class Timer {
|
|
5
|
+
private initialTimestamp: number;
|
|
6
|
+
private lastCheckpointTimestamp: number | null = null;
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
this.initialTimestamp = performance.now();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
checkpoint(name: string): void {
|
|
13
|
+
const now = performance.now();
|
|
14
|
+
const duration = this.lastCheckpointTimestamp
|
|
15
|
+
? now - this.lastCheckpointTimestamp
|
|
16
|
+
: now - this.initialTimestamp;
|
|
17
|
+
|
|
18
|
+
log.debug(`» ${name}: ${duration}ms`);
|
|
19
|
+
this.lastCheckpointTimestamp = now;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const THINKING_THRESHOLD = 3000; // ms
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Measures wall-clock gap between the last tool_result and the next tool_call,
|
|
27
|
+
* surfacing it as a "thought for Xs" log when over `THINKING_THRESHOLD`.
|
|
28
|
+
*
|
|
29
|
+
* Use one instance per logical session (orchestrator, each subagent) — sharing
|
|
30
|
+
* a single timer across sessions conflates cross-session interleaving as
|
|
31
|
+
* thinking time. The optional `formatLine` lets the caller prefix output with
|
|
32
|
+
* a session label so attribution is visible in the merged log stream.
|
|
33
|
+
*/
|
|
34
|
+
export class ThinkingTimer {
|
|
35
|
+
private readonly durationFormatter = new Intl.NumberFormat("en-US", {
|
|
36
|
+
style: "unit",
|
|
37
|
+
unit: "second",
|
|
38
|
+
unitDisplay: "long",
|
|
39
|
+
minimumFractionDigits: 0,
|
|
40
|
+
maximumFractionDigits: 1,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
private lastToolResultTimestamp: number | null = null;
|
|
44
|
+
private readonly formatLine: (line: string) => string;
|
|
45
|
+
|
|
46
|
+
// node's native TS strip-only mode does not support parameter properties,
|
|
47
|
+
// so the formatter is declared as a field and assigned in the body.
|
|
48
|
+
constructor(formatLine: (line: string) => string = (l) => l) {
|
|
49
|
+
this.formatLine = formatLine;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
markToolResult(): void {
|
|
53
|
+
this.lastToolResultTimestamp = performance.now();
|
|
54
|
+
log.debug(
|
|
55
|
+
this.formatLine(`» thinking timer: markToolResult at ${this.lastToolResultTimestamp}`),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
markToolCall(): void {
|
|
60
|
+
const now = performance.now();
|
|
61
|
+
log.debug(
|
|
62
|
+
this.formatLine(
|
|
63
|
+
`» thinking timer: markToolCall at ${now}, lastToolResult=${this.lastToolResultTimestamp}`,
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
if (this.lastToolResultTimestamp === null) return;
|
|
67
|
+
const elapsed = now - this.lastToolResultTimestamp;
|
|
68
|
+
if (elapsed < THINKING_THRESHOLD) return;
|
|
69
|
+
const seconds = elapsed / 1000;
|
|
70
|
+
log.info(this.formatLine(`» thought for ${this.durationFormatter.format(seconds)}`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createTodoTracker } from "#app/utils/todoTracking";
|
|
3
|
+
|
|
4
|
+
vi.mock("#app/utils/log", () => ({
|
|
5
|
+
log: {
|
|
6
|
+
info: vi.fn(),
|
|
7
|
+
debug: vi.fn(),
|
|
8
|
+
warning: vi.fn(),
|
|
9
|
+
error: vi.fn(),
|
|
10
|
+
success: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const DEBOUNCE_MS = 2000;
|
|
15
|
+
|
|
16
|
+
function todos(...items: { content: string; status?: string; id?: string }[]): unknown {
|
|
17
|
+
return { todos: items };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("createTodoTracker", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.useFakeTimers();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("publishes rendered markdown after the debounce window", async () => {
|
|
30
|
+
const onUpdate = vi.fn(async (_body: string) => undefined);
|
|
31
|
+
const tracker = createTodoTracker(onUpdate);
|
|
32
|
+
|
|
33
|
+
tracker.update(
|
|
34
|
+
todos(
|
|
35
|
+
{ content: "done item", status: "completed" },
|
|
36
|
+
{ content: "dropped item", status: "cancelled" },
|
|
37
|
+
{ content: "active item", status: "in_progress" },
|
|
38
|
+
{ content: "queued item", status: "pending" },
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
43
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
|
44
|
+
await tracker.settled();
|
|
45
|
+
|
|
46
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
47
|
+
const body = onUpdate.mock.calls[0]?.[0];
|
|
48
|
+
expect(body).toContain("- [x] done item");
|
|
49
|
+
expect(body).toContain("- ~~dropped item~~");
|
|
50
|
+
expect(body).toContain("active item");
|
|
51
|
+
expect(body).toContain("- [ ] queued item");
|
|
52
|
+
expect(tracker.hasPublished).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("ignores inputs that are not valid todowrite payloads", async () => {
|
|
56
|
+
const onUpdate = vi.fn(async () => undefined);
|
|
57
|
+
const tracker = createTodoTracker(onUpdate);
|
|
58
|
+
|
|
59
|
+
tracker.update(undefined);
|
|
60
|
+
tracker.update("nope");
|
|
61
|
+
tracker.update({ noTodos: true });
|
|
62
|
+
tracker.update({ todos: "not-an-array" });
|
|
63
|
+
|
|
64
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS * 2);
|
|
65
|
+
await tracker.settled();
|
|
66
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
67
|
+
expect(tracker.hasPublished).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("skips malformed entries and defaults id/status", async () => {
|
|
71
|
+
const onUpdate = vi.fn(async (_body: string) => undefined);
|
|
72
|
+
const tracker = createTodoTracker(onUpdate);
|
|
73
|
+
|
|
74
|
+
tracker.update({
|
|
75
|
+
todos: [
|
|
76
|
+
null,
|
|
77
|
+
"string entry",
|
|
78
|
+
{ noContent: true },
|
|
79
|
+
{ content: "no status entry" },
|
|
80
|
+
{ content: "bad status entry", status: "exploded" },
|
|
81
|
+
{ content: "with id", id: "custom", status: "completed" },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
|
86
|
+
await tracker.settled();
|
|
87
|
+
|
|
88
|
+
const body = onUpdate.mock.calls[0]?.[0];
|
|
89
|
+
expect(body).toBe("- [ ] no status entry\n- [ ] bad status entry\n- [x] with id");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("replaces state by default and merges when merge=true", () => {
|
|
93
|
+
const onUpdate = vi.fn(async () => undefined);
|
|
94
|
+
const tracker = createTodoTracker(onUpdate);
|
|
95
|
+
|
|
96
|
+
tracker.update({ todos: [{ content: "first", id: "a" }] });
|
|
97
|
+
tracker.update({ todos: [{ content: "second", id: "b" }] });
|
|
98
|
+
expect(tracker.renderCollapsible()).toContain("second");
|
|
99
|
+
expect(tracker.renderCollapsible()).not.toContain("first");
|
|
100
|
+
|
|
101
|
+
tracker.update({
|
|
102
|
+
todos: [{ content: "merged", id: "a", status: "completed" }],
|
|
103
|
+
merge: true,
|
|
104
|
+
});
|
|
105
|
+
const rendered = tracker.renderCollapsible();
|
|
106
|
+
expect(rendered).toContain("merged");
|
|
107
|
+
expect(rendered).toContain("second");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not publish when the debounce fires with an empty state", async () => {
|
|
111
|
+
const onUpdate = vi.fn(async () => undefined);
|
|
112
|
+
const tracker = createTodoTracker(onUpdate);
|
|
113
|
+
|
|
114
|
+
tracker.update({ todos: [] });
|
|
115
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
|
116
|
+
await tracker.settled();
|
|
117
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("flush publishes immediately and clears the pending debounce", async () => {
|
|
121
|
+
const onUpdate = vi.fn(async () => undefined);
|
|
122
|
+
const tracker = createTodoTracker(onUpdate);
|
|
123
|
+
|
|
124
|
+
tracker.update(todos({ content: "task" }));
|
|
125
|
+
await tracker.flush();
|
|
126
|
+
|
|
127
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
128
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS * 2);
|
|
129
|
+
await tracker.settled();
|
|
130
|
+
// the pending debounce was cleared — no second publish
|
|
131
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("flush is a no-op when state is empty", async () => {
|
|
135
|
+
const onUpdate = vi.fn(async () => undefined);
|
|
136
|
+
const tracker = createTodoTracker(onUpdate);
|
|
137
|
+
await tracker.flush();
|
|
138
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("swallows onUpdate failures on flush and stays unpublished", async () => {
|
|
142
|
+
const onUpdate = vi.fn(async () => {
|
|
143
|
+
throw new Error("boom");
|
|
144
|
+
});
|
|
145
|
+
const tracker = createTodoTracker(onUpdate);
|
|
146
|
+
|
|
147
|
+
tracker.update(todos({ content: "task" }));
|
|
148
|
+
await tracker.flush();
|
|
149
|
+
|
|
150
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(tracker.hasPublished).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("swallows onUpdate failures from the debounced path", async () => {
|
|
155
|
+
const onUpdate = vi.fn(async () => {
|
|
156
|
+
throw new Error("boom");
|
|
157
|
+
});
|
|
158
|
+
const tracker = createTodoTracker(onUpdate);
|
|
159
|
+
|
|
160
|
+
tracker.update(todos({ content: "task" }));
|
|
161
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
|
162
|
+
await tracker.settled();
|
|
163
|
+
|
|
164
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(tracker.hasPublished).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("cancel disables the tracker and clears the pending debounce", async () => {
|
|
169
|
+
const onUpdate = vi.fn(async () => undefined);
|
|
170
|
+
const tracker = createTodoTracker(onUpdate);
|
|
171
|
+
|
|
172
|
+
tracker.update(todos({ content: "task" }));
|
|
173
|
+
expect(tracker.enabled).toBe(true);
|
|
174
|
+
tracker.cancel();
|
|
175
|
+
expect(tracker.enabled).toBe(false);
|
|
176
|
+
|
|
177
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS * 2);
|
|
178
|
+
await tracker.settled();
|
|
179
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
180
|
+
|
|
181
|
+
// updates and flushes after cancel are no-ops
|
|
182
|
+
tracker.update(todos({ content: "late" }));
|
|
183
|
+
await tracker.flush();
|
|
184
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
185
|
+
|
|
186
|
+
// cancel again exercises the no-pending-timer path
|
|
187
|
+
tracker.cancel();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("completeInProgress flips in_progress items to completed in state", async () => {
|
|
191
|
+
const onUpdate = vi.fn(async () => undefined);
|
|
192
|
+
const tracker = createTodoTracker(onUpdate);
|
|
193
|
+
|
|
194
|
+
tracker.update(
|
|
195
|
+
todos({ content: "active", status: "in_progress" }, { content: "queued", status: "pending" }),
|
|
196
|
+
);
|
|
197
|
+
tracker.completeInProgress();
|
|
198
|
+
|
|
199
|
+
const rendered = tracker.renderCollapsible();
|
|
200
|
+
expect(rendered).toContain("- [x] active");
|
|
201
|
+
expect(rendered).toContain("- [ ] queued");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("renderCollapsible returns empty string with no state", () => {
|
|
205
|
+
const tracker = createTodoTracker(vi.fn(async () => undefined));
|
|
206
|
+
expect(tracker.renderCollapsible()).toBe("");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("renderCollapsible can complete in-progress items without mutating state", () => {
|
|
210
|
+
const tracker = createTodoTracker(vi.fn(async () => undefined));
|
|
211
|
+
tracker.update(
|
|
212
|
+
todos({ content: "active", status: "in_progress" }, { content: "done", status: "completed" }),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const completed = tracker.renderCollapsible({ completeInProgress: true });
|
|
216
|
+
expect(completed).toContain("Task list (2/2 completed)");
|
|
217
|
+
expect(completed).toContain("- [x] active");
|
|
218
|
+
|
|
219
|
+
// state itself stays in_progress
|
|
220
|
+
const plain = tracker.renderCollapsible();
|
|
221
|
+
expect(plain).toContain("Task list (1/2 completed)");
|
|
222
|
+
});
|
|
223
|
+
});
|