@mandujs/core 0.54.32 → 0.55.0-beta.1
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/README.md +654 -654
- package/package.json +106 -210
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +15 -15
- package/src/a11y/types.ts +125 -125
- package/src/agent/__tests__/apply.test.ts +224 -0
- package/src/agent/__tests__/context.test.ts +97 -98
- package/src/agent/apply.ts +1031 -0
- package/src/agent/context.ts +552 -552
- package/src/agent/index.ts +4 -3
- package/src/agent/plan.ts +161 -243
- package/src/agent/repair.ts +239 -162
- package/src/agent/sync.ts +197 -198
- package/src/agent/types.ts +242 -94
- package/src/agent/verify.ts +55 -55
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +15 -5
- package/src/auth/__tests__/reset.test.ts +2 -2
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +14 -2
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
- package/src/bundler/__tests__/build-runner.ts +166 -166
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +49 -20
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
- package/src/bundler/__tests__/generation.test.ts +447 -0
- package/src/bundler/__tests__/hdr.test.ts +24 -18
- package/src/bundler/__tests__/hmr-client.test.ts +62 -24
- package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
- package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +898 -915
- package/src/bundler/build.ts +113 -24
- package/src/bundler/client-boundary-transform.ts +977 -977
- package/src/bundler/css.ts +65 -44
- package/src/bundler/dev.ts +105 -73
- package/src/bundler/fast-refresh-preamble.ts +47 -47
- package/src/bundler/generation.ts +602 -0
- package/src/bundler/index.ts +3 -3
- package/src/bundler/manifest-schema.ts +55 -40
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +16 -15
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/prerender.ts +29 -9
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +1 -1
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +58 -56
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/props-serialization.test.ts +37 -37
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/island.ts +79 -79
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/props-serialization.ts +233 -233
- package/src/client/runtime-entry.ts +598 -598
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -50
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image-compat.ts +3 -0
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +1 -1
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +9 -9
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/generate-types.ts +1 -1
- package/src/content/index.ts +2 -2
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +9 -9
- package/src/db/index.ts +2 -2
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +82 -82
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/smoke.test.ts +2 -2
- package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
- package/src/desktop/__tests__/window.test.ts +20 -99
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/webview-fallback.ts +17 -4
- package/src/desktop/window.ts +17 -8
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/diagnose/__tests__/checks.test.ts +136 -136
- package/src/diagnose/checks.ts +185 -185
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +10 -10
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +5 -1
- package/src/filling/auth.ts +308 -308
- package/src/filling/body-parse.test.ts +60 -60
- package/src/filling/cookie-codec.ts +5 -3
- package/src/filling/deps.ts +265 -265
- package/src/filling/head-method.test.ts +154 -154
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.test.ts +48 -48
- package/src/generator/templates.ts +219 -219
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +39 -24
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/index.ts +11 -11
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/__tests__/id.test.ts +3 -3
- package/src/id/index.ts +105 -105
- package/src/index.ts +9 -48
- package/src/internal/client-boundary.ts +266 -266
- package/src/internal/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -779
- package/src/kitchen/api/bundle-inspector.ts +179 -0
- package/src/kitchen/api/errors-grouping.ts +126 -126
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/kitchen/kitchen-handler.ts +36 -0
- package/src/kitchen/kitchen-ui.ts +456 -0
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/perf/user-marks.ts +1 -1
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +7 -7
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +227 -227
- package/src/router/client-entry.ts +218 -218
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.test.ts +532 -532
- package/src/router/fs-routes.ts +53 -53
- package/src/router/fs-scanner.ts +216 -216
- package/src/router/fs-types.ts +19 -19
- package/src/router/route-source-analyzer.ts +521 -521
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +517 -517
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/fast-refresh-types.ts +1 -1
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +30 -11
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/page-render-response.ts +287 -274
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/rate-limit.ts +1 -1
- package/src/runtime/redirect.ts +1 -1
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -10
- package/src/runtime/server.ts +127 -211
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +43 -17
- package/src/runtime/static-files.ts +369 -289
- package/src/runtime/streaming-ssr.ts +219 -180
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/__tests__/scheduler.test.ts +12 -11
- package/src/scheduler/index.ts +11 -5
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/spec/schema.ts +35 -35
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/index.ts +9 -3
- package/src/testing/lcov.ts +192 -0
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { buildAgentPlan, readAgentPlan } from "./plan";
|
|
5
|
+
import { buildAgentVerifyReport } from "./verify";
|
|
6
|
+
import type {
|
|
7
|
+
AgentApplyAction,
|
|
8
|
+
AgentApplyReport,
|
|
9
|
+
AgentOperation,
|
|
10
|
+
AgentOperationInput,
|
|
11
|
+
AgentOperationReceipt,
|
|
12
|
+
AgentPlan,
|
|
13
|
+
AgentRollbackReceipt,
|
|
14
|
+
AgentRollbackResult,
|
|
15
|
+
AgentVerificationStep,
|
|
16
|
+
ApplyReceipt,
|
|
17
|
+
BuildAgentApplyOptions,
|
|
18
|
+
BuildExecutableAgentPlanOptions,
|
|
19
|
+
} from "./types";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_PLAN_PATH = ".mandu/agent-plan.json";
|
|
22
|
+
const APPLY_REPORT_PATH = ".mandu/agent-apply.json";
|
|
23
|
+
const RECEIPTS_DIR = ".mandu/agent-receipts";
|
|
24
|
+
const SNAPSHOTS_DIR = ".mandu/agent-snapshots";
|
|
25
|
+
const LOCKS_DIR = ".mandu/agent-locks";
|
|
26
|
+
const MAX_OPERATION_BYTES = 2 * 1024 * 1024;
|
|
27
|
+
const CREATE_KINDS = new Set<AgentOperation["kind"]>([
|
|
28
|
+
"file.create",
|
|
29
|
+
"route.create",
|
|
30
|
+
"api.create",
|
|
31
|
+
]);
|
|
32
|
+
const MODIFY_KINDS = new Set<AgentOperation["kind"]>([
|
|
33
|
+
"file.patch-with-hash",
|
|
34
|
+
"contract.update",
|
|
35
|
+
"guard.safe-fix",
|
|
36
|
+
]);
|
|
37
|
+
const OPERATION_KINDS = new Set<AgentOperation["kind"]>([
|
|
38
|
+
...CREATE_KINDS,
|
|
39
|
+
...MODIFY_KINDS,
|
|
40
|
+
"generated.refresh",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
interface FileState {
|
|
44
|
+
exists: boolean;
|
|
45
|
+
hash: string | null;
|
|
46
|
+
content: Buffer | null;
|
|
47
|
+
mode: number | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface AgentSnapshotFile {
|
|
51
|
+
path: string;
|
|
52
|
+
existed: boolean;
|
|
53
|
+
contentBase64: string | null;
|
|
54
|
+
mode: number | null;
|
|
55
|
+
expectedAfterHash: string;
|
|
56
|
+
createdDirectories: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface AgentSnapshot {
|
|
60
|
+
schemaVersion: 1;
|
|
61
|
+
id: string;
|
|
62
|
+
planId: string;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
files: AgentSnapshotFile[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class ApplyFailure extends Error {
|
|
68
|
+
constructor(
|
|
69
|
+
message: string,
|
|
70
|
+
readonly operationId?: string,
|
|
71
|
+
) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "ApplyFailure";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sha256(value: string | Buffer): string {
|
|
78
|
+
return createHash("sha256").update(value).digest("hex");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function hashAgentContent(content: string | Buffer): string {
|
|
82
|
+
return sha256(content);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toPosix(value: string): string {
|
|
86
|
+
return value.replace(/\\/g, "/");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isSensitiveTarget(target: string): boolean {
|
|
90
|
+
const first = target.split("/")[0]?.toLowerCase();
|
|
91
|
+
if (first === ".git" || first === "node_modules") return true;
|
|
92
|
+
if (first === ".env" || first?.startsWith(".env.")) return true;
|
|
93
|
+
return [
|
|
94
|
+
".mandu/agent-plan.json",
|
|
95
|
+
".mandu/agent-apply.json",
|
|
96
|
+
".mandu/agent-receipts",
|
|
97
|
+
".mandu/agent-snapshots",
|
|
98
|
+
".mandu/agent-locks",
|
|
99
|
+
].some((blocked) => target === blocked || target.startsWith(`${blocked}/`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeAgentTarget(value: string): string {
|
|
103
|
+
if (!value || value.includes("\0")) {
|
|
104
|
+
throw new Error("operation target must be a non-empty relative path");
|
|
105
|
+
}
|
|
106
|
+
const slashed = toPosix(value);
|
|
107
|
+
if (path.posix.isAbsolute(slashed) || /^[A-Za-z]:\//.test(slashed)) {
|
|
108
|
+
throw new Error(`operation target must be relative: ${value}`);
|
|
109
|
+
}
|
|
110
|
+
const normalized = path.posix.normalize(slashed).replace(/^\.\//, "");
|
|
111
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
112
|
+
throw new Error(`operation target escapes the project root: ${value}`);
|
|
113
|
+
}
|
|
114
|
+
if (isSensitiveTarget(normalized)) {
|
|
115
|
+
throw new Error(`operation target is protected: ${normalized}`);
|
|
116
|
+
}
|
|
117
|
+
return normalized;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveInside(rootDir: string, relativePath: string): string {
|
|
121
|
+
const root = path.resolve(rootDir);
|
|
122
|
+
const resolved = path.resolve(root, relativePath);
|
|
123
|
+
const comparableRoot = process.platform === "win32" ? root.toLowerCase() : root;
|
|
124
|
+
const comparableResolved = process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
125
|
+
if (
|
|
126
|
+
comparableResolved !== comparableRoot &&
|
|
127
|
+
!comparableResolved.startsWith(`${comparableRoot}${path.sep}`)
|
|
128
|
+
) {
|
|
129
|
+
throw new Error("agent path must stay inside the project root");
|
|
130
|
+
}
|
|
131
|
+
return resolved;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function assertNoSymlinkTraversal(rootDir: string, relativePath: string): Promise<void> {
|
|
135
|
+
const segments = toPosix(relativePath).split("/").filter(Boolean);
|
|
136
|
+
let current = path.resolve(rootDir);
|
|
137
|
+
for (const segment of segments) {
|
|
138
|
+
current = path.join(current, segment);
|
|
139
|
+
try {
|
|
140
|
+
const stat = await fs.lstat(current);
|
|
141
|
+
if (stat.isSymbolicLink()) {
|
|
142
|
+
throw new Error(`symbolic links are not writable apply targets: ${relativePath}`);
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function fileState(rootDir: string, target: string): Promise<FileState> {
|
|
152
|
+
await assertNoSymlinkTraversal(rootDir, target);
|
|
153
|
+
const absolute = resolveInside(rootDir, target);
|
|
154
|
+
try {
|
|
155
|
+
const stat = await fs.lstat(absolute);
|
|
156
|
+
if (!stat.isFile()) {
|
|
157
|
+
throw new Error(`operation target must be a regular file: ${target}`);
|
|
158
|
+
}
|
|
159
|
+
const content = await fs.readFile(absolute);
|
|
160
|
+
return {
|
|
161
|
+
exists: true,
|
|
162
|
+
hash: sha256(content),
|
|
163
|
+
content,
|
|
164
|
+
mode: stat.mode,
|
|
165
|
+
};
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
168
|
+
return { exists: false, hash: null, content: null, mode: null };
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function computeAgentBaseRevision(
|
|
175
|
+
rootDir: string,
|
|
176
|
+
scope: string[],
|
|
177
|
+
): Promise<string> {
|
|
178
|
+
const normalized = [...new Set(scope.map(normalizeAgentTarget))].sort();
|
|
179
|
+
const hash = createHash("sha256");
|
|
180
|
+
for (const target of normalized) {
|
|
181
|
+
const state = await fileState(rootDir, target);
|
|
182
|
+
hash.update(target);
|
|
183
|
+
hash.update("\0");
|
|
184
|
+
hash.update(state.exists ? state.hash! : "<absent>");
|
|
185
|
+
hash.update("\0");
|
|
186
|
+
}
|
|
187
|
+
return hash.digest("hex");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function defaultTypedVerification(): AgentVerificationStep[] {
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
id: "agent-verify",
|
|
194
|
+
kind: "agent.verify",
|
|
195
|
+
command: "mandu agent verify --changed --json --write",
|
|
196
|
+
reason: "Canonical post-apply architecture and contract verification.",
|
|
197
|
+
required: true,
|
|
198
|
+
changedOnly: true,
|
|
199
|
+
includeDiagnose: false,
|
|
200
|
+
includeGit: true,
|
|
201
|
+
includeGuard: true,
|
|
202
|
+
includeContract: true,
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function assertEffect(input: AgentOperationInput): void {
|
|
208
|
+
if (!OPERATION_KINDS.has(input.kind)) {
|
|
209
|
+
throw new Error(`${input.id ?? input.target}: unsupported operation kind`);
|
|
210
|
+
}
|
|
211
|
+
if (input.effect?.type !== "write" || input.effect.encoding !== "utf8") {
|
|
212
|
+
throw new Error(`${input.id ?? input.target}: only UTF-8 write effects are supported`);
|
|
213
|
+
}
|
|
214
|
+
if (typeof input.effect.content !== "string") {
|
|
215
|
+
throw new Error(`${input.id ?? input.target}: effect.content must be a string`);
|
|
216
|
+
}
|
|
217
|
+
if (Buffer.byteLength(input.effect.content, "utf8") > MAX_OPERATION_BYTES) {
|
|
218
|
+
throw new Error(`${input.id ?? input.target}: operation content exceeds 2 MiB`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function buildExecutableAgentPlan(
|
|
223
|
+
rootDir: string,
|
|
224
|
+
options: BuildExecutableAgentPlanOptions,
|
|
225
|
+
): Promise<AgentPlan> {
|
|
226
|
+
if (options.operations.length === 0) {
|
|
227
|
+
throw new Error("an executable agent plan requires at least one typed operation");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const preview = buildAgentPlan({ intent: options.intent });
|
|
231
|
+
const operations: AgentOperation[] = [];
|
|
232
|
+
const seenTargets = new Set<string>();
|
|
233
|
+
const seenIds = new Set<string>();
|
|
234
|
+
|
|
235
|
+
for (const [index, input] of options.operations.entries()) {
|
|
236
|
+
assertEffect(input);
|
|
237
|
+
const target = normalizeAgentTarget(input.target);
|
|
238
|
+
const id = input.id?.trim() || `op-${index + 1}`;
|
|
239
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(id)) {
|
|
240
|
+
throw new Error(`invalid operation id: ${id}`);
|
|
241
|
+
}
|
|
242
|
+
if (seenIds.has(id)) throw new Error(`duplicate operation id: ${id}`);
|
|
243
|
+
if (seenTargets.has(target)) throw new Error(`duplicate operation target: ${target}`);
|
|
244
|
+
seenIds.add(id);
|
|
245
|
+
seenTargets.add(target);
|
|
246
|
+
|
|
247
|
+
const state = await fileState(rootDir, target);
|
|
248
|
+
if (CREATE_KINDS.has(input.kind) && state.exists) {
|
|
249
|
+
throw new Error(`${input.kind} requires an absent target: ${target}`);
|
|
250
|
+
}
|
|
251
|
+
if (MODIFY_KINDS.has(input.kind) && !state.exists) {
|
|
252
|
+
throw new Error(`${input.kind} requires an existing target: ${target}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
operations.push({
|
|
256
|
+
id,
|
|
257
|
+
kind: input.kind,
|
|
258
|
+
target,
|
|
259
|
+
precondition: {
|
|
260
|
+
exists: state.exists,
|
|
261
|
+
...(state.hash ? { contentHash: state.hash } : {}),
|
|
262
|
+
},
|
|
263
|
+
effect: input.effect,
|
|
264
|
+
rollback: { strategy: "restore-snapshot" },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const scope = operations.map((operation) => operation.target).sort();
|
|
269
|
+
const baseRevision = await computeAgentBaseRevision(rootDir, scope);
|
|
270
|
+
const idempotencyKey = options.idempotencyKey?.trim() || randomUUID();
|
|
271
|
+
const rollbackPolicy = options.rollbackPolicy ?? "automatic";
|
|
272
|
+
const permissions = scope.map((target) => ({ kind: "write" as const, path: target }));
|
|
273
|
+
const verification = options.verification ?? defaultTypedVerification();
|
|
274
|
+
const identity = sha256(JSON.stringify({
|
|
275
|
+
intent: options.intent.trim(),
|
|
276
|
+
baseRevision,
|
|
277
|
+
idempotencyKey,
|
|
278
|
+
scope,
|
|
279
|
+
permissions,
|
|
280
|
+
operations,
|
|
281
|
+
rollbackPolicy,
|
|
282
|
+
verification,
|
|
283
|
+
})).slice(0, 24);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
...preview,
|
|
287
|
+
generatedAt: new Date().toISOString(),
|
|
288
|
+
id: `plan-${identity}`,
|
|
289
|
+
baseRevision,
|
|
290
|
+
idempotencyKey,
|
|
291
|
+
scope,
|
|
292
|
+
permissions,
|
|
293
|
+
operations,
|
|
294
|
+
rollbackPolicy,
|
|
295
|
+
filesToCreate: operations
|
|
296
|
+
.filter((operation) => !operation.precondition.exists)
|
|
297
|
+
.map((operation) => operation.target),
|
|
298
|
+
filesToModify: operations
|
|
299
|
+
.filter((operation) => operation.precondition.exists)
|
|
300
|
+
.map((operation) => operation.target),
|
|
301
|
+
verification,
|
|
302
|
+
notes: [
|
|
303
|
+
"Executable typed plan: apply enforces scope, revision, per-file hashes, snapshot rollback, and idempotency.",
|
|
304
|
+
"Arbitrary shell commands are never executed by typed apply.",
|
|
305
|
+
],
|
|
306
|
+
executable: true,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function kindTargetIssue(operation: AgentOperation): string | null {
|
|
311
|
+
if (operation.kind === "route.create" && !/^app\/(?:.+\/)?page\.tsx?$/.test(operation.target)) {
|
|
312
|
+
return `${operation.id}: route.create target must be app/**/page.ts or page.tsx`;
|
|
313
|
+
}
|
|
314
|
+
if (operation.kind === "api.create" && !/^app\/api\/(?:.*\/)?route\.tsx?$/.test(operation.target)) {
|
|
315
|
+
return `${operation.id}: api.create target must be app/api/**/route.ts or route.tsx`;
|
|
316
|
+
}
|
|
317
|
+
if (
|
|
318
|
+
operation.kind === "contract.update" &&
|
|
319
|
+
!/^spec\/contracts\/.+\.contract\.ts$/.test(operation.target)
|
|
320
|
+
) {
|
|
321
|
+
return `${operation.id}: contract.update target must be spec/contracts/*.contract.ts`;
|
|
322
|
+
}
|
|
323
|
+
if (
|
|
324
|
+
operation.kind === "guard.safe-fix" &&
|
|
325
|
+
!/\.(?:[cm]?[jt]sx?|json)$/.test(operation.target)
|
|
326
|
+
) {
|
|
327
|
+
return `${operation.id}: guard.safe-fix target must be a JS/TS/JSON source file`;
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function validateExecutablePlan(plan: AgentPlan): string[] {
|
|
333
|
+
const issues: string[] = [];
|
|
334
|
+
if (!plan.executable) issues.push("plan is not executable");
|
|
335
|
+
if (!/^plan-[a-f0-9]{24}$/.test(plan.id ?? "")) issues.push("plan.id is invalid");
|
|
336
|
+
if (!/^[a-f0-9]{64}$/.test(plan.baseRevision ?? "")) issues.push("plan.baseRevision is invalid");
|
|
337
|
+
if (!plan.idempotencyKey || plan.idempotencyKey.length > 128) {
|
|
338
|
+
issues.push("plan.idempotencyKey is required and must be at most 128 characters");
|
|
339
|
+
}
|
|
340
|
+
if (plan.rollbackPolicy !== "automatic" && plan.rollbackPolicy !== "manual") {
|
|
341
|
+
issues.push("plan.rollbackPolicy is invalid");
|
|
342
|
+
}
|
|
343
|
+
if (plan.baseRevision && plan.idempotencyKey) {
|
|
344
|
+
const expectedPlanId = `plan-${sha256(JSON.stringify({
|
|
345
|
+
intent: plan.intent,
|
|
346
|
+
baseRevision: plan.baseRevision,
|
|
347
|
+
idempotencyKey: plan.idempotencyKey,
|
|
348
|
+
scope: plan.scope,
|
|
349
|
+
permissions: plan.permissions,
|
|
350
|
+
operations: plan.operations,
|
|
351
|
+
rollbackPolicy: plan.rollbackPolicy,
|
|
352
|
+
verification: plan.verification,
|
|
353
|
+
})).slice(0, 24)}`;
|
|
354
|
+
if (plan.id !== expectedPlanId) {
|
|
355
|
+
issues.push("plan content does not match plan.id; bind a fresh executable plan");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (!Array.isArray(plan.operations) || plan.operations.length === 0) {
|
|
359
|
+
issues.push("plan.operations must contain at least one operation");
|
|
360
|
+
return issues;
|
|
361
|
+
}
|
|
362
|
+
if (plan.operations.length > 100) issues.push("plan.operations exceeds the 100 operation limit");
|
|
363
|
+
|
|
364
|
+
const scope = new Set<string>();
|
|
365
|
+
for (const value of plan.scope ?? []) {
|
|
366
|
+
try {
|
|
367
|
+
scope.add(normalizeAgentTarget(value));
|
|
368
|
+
} catch (error) {
|
|
369
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const writePermissions = new Set(
|
|
373
|
+
(plan.permissions ?? [])
|
|
374
|
+
.filter((permission) => permission.kind === "write")
|
|
375
|
+
.map((permission) => {
|
|
376
|
+
try {
|
|
377
|
+
return normalizeAgentTarget(permission.path);
|
|
378
|
+
} catch {
|
|
379
|
+
return "<invalid>";
|
|
380
|
+
}
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
const ids = new Set<string>();
|
|
384
|
+
const targets = new Set<string>();
|
|
385
|
+
|
|
386
|
+
for (const operation of plan.operations) {
|
|
387
|
+
let target: string;
|
|
388
|
+
try {
|
|
389
|
+
target = normalizeAgentTarget(operation.target);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (target !== operation.target) issues.push(`${operation.id}: target must be normalized`);
|
|
395
|
+
if (!OPERATION_KINDS.has(operation.kind)) {
|
|
396
|
+
issues.push(`${operation.id}: unsupported operation kind`);
|
|
397
|
+
}
|
|
398
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(operation.id ?? "")) {
|
|
399
|
+
issues.push(`invalid operation id: ${operation.id}`);
|
|
400
|
+
}
|
|
401
|
+
if (ids.has(operation.id)) issues.push(`duplicate operation id: ${operation.id}`);
|
|
402
|
+
if (targets.has(target)) issues.push(`duplicate operation target: ${target}`);
|
|
403
|
+
ids.add(operation.id);
|
|
404
|
+
targets.add(target);
|
|
405
|
+
if (!scope.has(target)) issues.push(`${operation.id}: target is outside plan scope`);
|
|
406
|
+
if (!writePermissions.has(target)) issues.push(`${operation.id}: target lacks write permission`);
|
|
407
|
+
if (operation.rollback?.strategy !== "restore-snapshot") {
|
|
408
|
+
issues.push(`${operation.id}: unsupported rollback strategy`);
|
|
409
|
+
}
|
|
410
|
+
if (operation.effect?.type !== "write" || operation.effect.encoding !== "utf8") {
|
|
411
|
+
issues.push(`${operation.id}: unsupported operation effect`);
|
|
412
|
+
} else if (typeof operation.effect.content !== "string") {
|
|
413
|
+
issues.push(`${operation.id}: effect.content must be a string`);
|
|
414
|
+
} else if (Buffer.byteLength(operation.effect.content, "utf8") > MAX_OPERATION_BYTES) {
|
|
415
|
+
issues.push(`${operation.id}: operation content exceeds 2 MiB`);
|
|
416
|
+
}
|
|
417
|
+
if (typeof operation.precondition?.exists !== "boolean") {
|
|
418
|
+
issues.push(`${operation.id}: precondition.exists is required`);
|
|
419
|
+
}
|
|
420
|
+
if (
|
|
421
|
+
operation.precondition?.contentHash !== undefined &&
|
|
422
|
+
!/^[a-f0-9]{64}$/.test(operation.precondition.contentHash)
|
|
423
|
+
) {
|
|
424
|
+
issues.push(`${operation.id}: precondition.contentHash is invalid`);
|
|
425
|
+
}
|
|
426
|
+
if (operation.precondition?.exists === true && !operation.precondition.contentHash) {
|
|
427
|
+
issues.push(`${operation.id}: existing targets require a contentHash precondition`);
|
|
428
|
+
}
|
|
429
|
+
if (CREATE_KINDS.has(operation.kind) && operation.precondition?.exists !== false) {
|
|
430
|
+
issues.push(`${operation.id}: create operation requires exists=false`);
|
|
431
|
+
}
|
|
432
|
+
if (
|
|
433
|
+
MODIFY_KINDS.has(operation.kind) &&
|
|
434
|
+
(operation.precondition?.exists !== true || !operation.precondition.contentHash)
|
|
435
|
+
) {
|
|
436
|
+
issues.push(`${operation.id}: modifying operation requires exists=true and contentHash`);
|
|
437
|
+
}
|
|
438
|
+
const targetIssue = kindTargetIssue(operation);
|
|
439
|
+
if (targetIssue) issues.push(targetIssue);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (
|
|
443
|
+
!Array.isArray(plan.verification) ||
|
|
444
|
+
!plan.verification.some((step) => step.kind === "agent.verify" && step.required)
|
|
445
|
+
) {
|
|
446
|
+
issues.push("executable plan requires at least one required typed agent.verify step");
|
|
447
|
+
}
|
|
448
|
+
for (const step of plan.verification ?? []) {
|
|
449
|
+
if (step.kind !== "agent.verify") {
|
|
450
|
+
issues.push(`${step.id}: executable plans only support agent.verify verification`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return issues;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function preconditionIssues(rootDir: string, plan: AgentPlan): Promise<string[]> {
|
|
457
|
+
const issues: string[] = [];
|
|
458
|
+
try {
|
|
459
|
+
const currentRevision = await computeAgentBaseRevision(rootDir, plan.scope);
|
|
460
|
+
if (currentRevision !== plan.baseRevision) {
|
|
461
|
+
issues.push(`base revision mismatch: expected ${plan.baseRevision}, received ${currentRevision}`);
|
|
462
|
+
}
|
|
463
|
+
} catch (error) {
|
|
464
|
+
issues.push(`could not verify base revision: ${error instanceof Error ? error.message : String(error)}`);
|
|
465
|
+
}
|
|
466
|
+
for (const operation of plan.operations) {
|
|
467
|
+
try {
|
|
468
|
+
const state = await fileState(rootDir, operation.target);
|
|
469
|
+
if (state.exists !== operation.precondition.exists) {
|
|
470
|
+
issues.push(
|
|
471
|
+
`${operation.id}: existence precondition mismatch for ${operation.target} ` +
|
|
472
|
+
`(expected ${operation.precondition.exists}, received ${state.exists})`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
if (
|
|
476
|
+
operation.precondition.contentHash &&
|
|
477
|
+
state.hash !== operation.precondition.contentHash
|
|
478
|
+
) {
|
|
479
|
+
issues.push(
|
|
480
|
+
`${operation.id}: content hash precondition mismatch for ${operation.target}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
} catch (error) {
|
|
484
|
+
issues.push(
|
|
485
|
+
`${operation.id}: could not verify ${operation.target}: ` +
|
|
486
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return issues;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function emptyRollback(policy: AgentPlan["rollbackPolicy"] = "automatic"): AgentRollbackReceipt {
|
|
494
|
+
return {
|
|
495
|
+
id: null,
|
|
496
|
+
snapshotPath: null,
|
|
497
|
+
policy,
|
|
498
|
+
attempted: false,
|
|
499
|
+
ok: null,
|
|
500
|
+
restoredFiles: [],
|
|
501
|
+
conflicts: [],
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function receiptBase(
|
|
506
|
+
sourcePlan: string,
|
|
507
|
+
plan: AgentPlan | null,
|
|
508
|
+
dryRun: boolean,
|
|
509
|
+
): ApplyReceipt {
|
|
510
|
+
const now = new Date().toISOString();
|
|
511
|
+
return {
|
|
512
|
+
schemaVersion: 1,
|
|
513
|
+
framework: "mandu",
|
|
514
|
+
generatedAt: now,
|
|
515
|
+
receiptId: `receipt-${randomUUID()}`,
|
|
516
|
+
planId: plan?.id ?? null,
|
|
517
|
+
idempotencyKey: plan?.idempotencyKey ?? null,
|
|
518
|
+
baseRevision: plan?.baseRevision ?? null,
|
|
519
|
+
startedAt: now,
|
|
520
|
+
completedAt: now,
|
|
521
|
+
ok: false,
|
|
522
|
+
status: dryRun ? "preview" : "blocked",
|
|
523
|
+
dryRun,
|
|
524
|
+
replayed: false,
|
|
525
|
+
sourcePlan,
|
|
526
|
+
intent: plan?.intent ?? "",
|
|
527
|
+
domains: plan?.domains ?? ["unknown"],
|
|
528
|
+
actions: [],
|
|
529
|
+
operations: [],
|
|
530
|
+
touchedFiles: [],
|
|
531
|
+
changedFiles: [],
|
|
532
|
+
verification: [],
|
|
533
|
+
rollback: emptyRollback(plan?.rollbackPolicy),
|
|
534
|
+
warnings: [],
|
|
535
|
+
nextVerifyCommand: "mandu agent verify --changed --json --write",
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function previewActions(plan: AgentPlan): AgentApplyAction[] {
|
|
540
|
+
return [
|
|
541
|
+
...plan.filesToRead.map((file) => ({
|
|
542
|
+
kind: "read_file" as const,
|
|
543
|
+
description: `Read ${file} before editing.`,
|
|
544
|
+
file,
|
|
545
|
+
applied: false,
|
|
546
|
+
})),
|
|
547
|
+
...plan.mcpTools.map((tool) => ({
|
|
548
|
+
kind: "mcp_tool" as const,
|
|
549
|
+
description: `Consider ${tool} for this plan.`,
|
|
550
|
+
tool,
|
|
551
|
+
applied: false,
|
|
552
|
+
})),
|
|
553
|
+
...plan.filesToCreate.map((file) => ({
|
|
554
|
+
kind: "manual_edit" as const,
|
|
555
|
+
description: `Create ${file} only after confirming the local pattern.`,
|
|
556
|
+
file,
|
|
557
|
+
applied: false,
|
|
558
|
+
})),
|
|
559
|
+
...plan.verification.map((item) => ({
|
|
560
|
+
kind: "verify" as const,
|
|
561
|
+
description: item.reason,
|
|
562
|
+
command: item.command,
|
|
563
|
+
applied: false,
|
|
564
|
+
})),
|
|
565
|
+
];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function operationReceipts(plan: AgentPlan): AgentOperationReceipt[] {
|
|
569
|
+
return plan.operations.map((operation) => ({
|
|
570
|
+
operationId: operation.id,
|
|
571
|
+
kind: operation.kind,
|
|
572
|
+
target: operation.target,
|
|
573
|
+
status: "planned",
|
|
574
|
+
beforeHash: operation.precondition.contentHash ?? null,
|
|
575
|
+
afterHash: sha256(operation.effect.content),
|
|
576
|
+
}));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function assertOperationPrecondition(
|
|
580
|
+
rootDir: string,
|
|
581
|
+
operation: AgentOperation,
|
|
582
|
+
): Promise<void> {
|
|
583
|
+
const state = await fileState(rootDir, operation.target);
|
|
584
|
+
if (state.exists !== operation.precondition.exists) {
|
|
585
|
+
throw new ApplyFailure(
|
|
586
|
+
`${operation.id}: existence changed after preflight for ${operation.target}`,
|
|
587
|
+
operation.id,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
if (
|
|
591
|
+
operation.precondition.contentHash &&
|
|
592
|
+
state.hash !== operation.precondition.contentHash
|
|
593
|
+
) {
|
|
594
|
+
throw new ApplyFailure(
|
|
595
|
+
`${operation.id}: content changed after preflight for ${operation.target}`,
|
|
596
|
+
operation.id,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function writeBufferAtomic(
|
|
602
|
+
rootDir: string,
|
|
603
|
+
targetPath: string,
|
|
604
|
+
content: string | Buffer,
|
|
605
|
+
): Promise<void> {
|
|
606
|
+
await assertNoSymlinkTraversal(rootDir, targetPath);
|
|
607
|
+
const target = resolveInside(rootDir, targetPath);
|
|
608
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
609
|
+
const temp = path.join(
|
|
610
|
+
path.dirname(target),
|
|
611
|
+
`.${path.basename(target)}.mandu-${randomUUID()}.tmp`,
|
|
612
|
+
);
|
|
613
|
+
try {
|
|
614
|
+
await fs.writeFile(temp, content, { flag: "wx" });
|
|
615
|
+
await fs.rename(temp, target);
|
|
616
|
+
} finally {
|
|
617
|
+
await fs.rm(temp, { force: true });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function writeAtomic(rootDir: string, operation: AgentOperation): Promise<void> {
|
|
622
|
+
const state = await fileState(rootDir, operation.target);
|
|
623
|
+
await writeBufferAtomic(rootDir, operation.target, operation.effect.content);
|
|
624
|
+
if (state.mode !== null) {
|
|
625
|
+
await fs.chmod(resolveInside(rootDir, operation.target), state.mode);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function writeJsonExclusive(filePath: string, value: unknown): Promise<void> {
|
|
630
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
631
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
632
|
+
encoding: "utf8",
|
|
633
|
+
flag: "wx",
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
|
638
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
639
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function receiptStorageKey(plan: AgentPlan): string {
|
|
643
|
+
return sha256(`${plan.id}\0${plan.idempotencyKey}`).slice(0, 40);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function receiptPath(rootDir: string, plan: AgentPlan): string {
|
|
647
|
+
return path.join(rootDir, RECEIPTS_DIR, `${receiptStorageKey(plan)}.json`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function lockPath(rootDir: string, plan: AgentPlan): string {
|
|
651
|
+
return path.join(rootDir, LOCKS_DIR, `${receiptStorageKey(plan)}.lock`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function snapshotPath(rootDir: string, rollbackId: string): string {
|
|
655
|
+
return path.join(rootDir, SNAPSHOTS_DIR, `${rollbackId}.json`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function readJson<T>(filePath: string): Promise<T | null> {
|
|
659
|
+
try {
|
|
660
|
+
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function createSnapshot(rootDir: string, plan: AgentPlan): Promise<AgentSnapshot> {
|
|
668
|
+
const id = `rollback-${randomUUID()}`;
|
|
669
|
+
const files: AgentSnapshotFile[] = [];
|
|
670
|
+
for (const operation of plan.operations) {
|
|
671
|
+
const state = await fileState(rootDir, operation.target);
|
|
672
|
+
const createdDirectories: string[] = [];
|
|
673
|
+
const parent = path.posix.dirname(operation.target);
|
|
674
|
+
if (parent !== ".") {
|
|
675
|
+
let current = path.resolve(rootDir);
|
|
676
|
+
let missing = false;
|
|
677
|
+
const relativeParts: string[] = [];
|
|
678
|
+
for (const segment of parent.split("/")) {
|
|
679
|
+
relativeParts.push(segment);
|
|
680
|
+
current = path.join(current, segment);
|
|
681
|
+
if (!missing) {
|
|
682
|
+
try {
|
|
683
|
+
const stat = await fs.lstat(current);
|
|
684
|
+
if (!stat.isDirectory()) {
|
|
685
|
+
throw new Error(`operation parent is not a directory: ${relativeParts.join("/")}`);
|
|
686
|
+
}
|
|
687
|
+
} catch (error) {
|
|
688
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
689
|
+
missing = true;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (missing) createdDirectories.push(relativeParts.join("/"));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
files.push({
|
|
696
|
+
path: operation.target,
|
|
697
|
+
existed: state.exists,
|
|
698
|
+
contentBase64: state.content?.toString("base64") ?? null,
|
|
699
|
+
mode: state.mode,
|
|
700
|
+
expectedAfterHash: sha256(operation.effect.content),
|
|
701
|
+
createdDirectories,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
const snapshot: AgentSnapshot = {
|
|
705
|
+
schemaVersion: 1,
|
|
706
|
+
id,
|
|
707
|
+
planId: plan.id,
|
|
708
|
+
createdAt: new Date().toISOString(),
|
|
709
|
+
files,
|
|
710
|
+
};
|
|
711
|
+
await writeJsonExclusive(snapshotPath(rootDir, id), snapshot);
|
|
712
|
+
return snapshot;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function restoreSnapshot(
|
|
716
|
+
rootDir: string,
|
|
717
|
+
snapshot: AgentSnapshot,
|
|
718
|
+
appliedTargets?: ReadonlySet<string>,
|
|
719
|
+
): Promise<AgentRollbackResult> {
|
|
720
|
+
const restoredFiles: string[] = [];
|
|
721
|
+
const conflicts: string[] = [];
|
|
722
|
+
const directoriesToRemove = new Set<string>();
|
|
723
|
+
|
|
724
|
+
for (let index = snapshot.files.length - 1; index >= 0; index -= 1) {
|
|
725
|
+
const file = snapshot.files[index]!;
|
|
726
|
+
if (appliedTargets && !appliedTargets.has(file.path)) continue;
|
|
727
|
+
const current = await fileState(rootDir, file.path);
|
|
728
|
+
if (current.hash !== file.expectedAfterHash) {
|
|
729
|
+
conflicts.push(
|
|
730
|
+
`${file.path}: current content no longer matches the content written by apply`,
|
|
731
|
+
);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const absolute = resolveInside(rootDir, file.path);
|
|
736
|
+
if (!file.existed) {
|
|
737
|
+
await fs.rm(absolute, { force: true });
|
|
738
|
+
restoredFiles.push(file.path);
|
|
739
|
+
for (const directory of file.createdDirectories) directoriesToRemove.add(directory);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (file.contentBase64 === null) {
|
|
744
|
+
conflicts.push(`${file.path}: snapshot content is missing`);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
await writeBufferAtomic(rootDir, file.path, Buffer.from(file.contentBase64, "base64"));
|
|
748
|
+
if (file.mode !== null) {
|
|
749
|
+
await fs.chmod(absolute, file.mode);
|
|
750
|
+
}
|
|
751
|
+
restoredFiles.push(file.path);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const orderedDirectories = [...directoriesToRemove]
|
|
755
|
+
.sort((left, right) => right.split("/").length - left.split("/").length);
|
|
756
|
+
for (const directory of orderedDirectories) {
|
|
757
|
+
try {
|
|
758
|
+
await fs.rmdir(resolveInside(rootDir, directory));
|
|
759
|
+
} catch (error) {
|
|
760
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
761
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST") throw error;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
rollbackId: snapshot.id,
|
|
767
|
+
ok: conflicts.length === 0,
|
|
768
|
+
restoredFiles,
|
|
769
|
+
conflicts,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export async function rollbackAgentApply(
|
|
774
|
+
rootDir: string,
|
|
775
|
+
rollbackId: string,
|
|
776
|
+
): Promise<AgentRollbackResult> {
|
|
777
|
+
if (!/^rollback-[0-9a-f-]{36}$/.test(rollbackId)) {
|
|
778
|
+
throw new Error("invalid rollback id");
|
|
779
|
+
}
|
|
780
|
+
const snapshot = await readJson<AgentSnapshot>(snapshotPath(rootDir, rollbackId));
|
|
781
|
+
if (!snapshot || snapshot.id !== rollbackId) {
|
|
782
|
+
throw new Error(`rollback snapshot not found: ${rollbackId}`);
|
|
783
|
+
}
|
|
784
|
+
return restoreSnapshot(rootDir, snapshot);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function runVerification(rootDir: string, plan: AgentPlan) {
|
|
788
|
+
const results: ApplyReceipt["verification"] = [];
|
|
789
|
+
for (const step of plan.verification) {
|
|
790
|
+
if (step.kind !== "agent.verify") continue;
|
|
791
|
+
const report = await buildAgentVerifyReport(rootDir, {
|
|
792
|
+
changedOnly: step.changedOnly !== false,
|
|
793
|
+
includeDiagnose: step.includeDiagnose === true,
|
|
794
|
+
includeGit: step.includeGit !== false,
|
|
795
|
+
includeGuard: step.includeGuard !== false,
|
|
796
|
+
includeContract: step.includeContract !== false,
|
|
797
|
+
});
|
|
798
|
+
results.push({
|
|
799
|
+
id: step.id,
|
|
800
|
+
kind: "agent.verify",
|
|
801
|
+
required: step.required,
|
|
802
|
+
ok: report.ok,
|
|
803
|
+
checks: report.checks,
|
|
804
|
+
diagnostics: report.diagnostics,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
return results;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function executePlan(
|
|
811
|
+
rootDir: string,
|
|
812
|
+
sourcePlan: string,
|
|
813
|
+
plan: AgentPlan,
|
|
814
|
+
options: BuildAgentApplyOptions,
|
|
815
|
+
): Promise<ApplyReceipt> {
|
|
816
|
+
const storagePath = receiptPath(rootDir, plan);
|
|
817
|
+
const existing = await readJson<ApplyReceipt>(storagePath);
|
|
818
|
+
if (existing) {
|
|
819
|
+
return { ...existing, replayed: true };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const executionLock = lockPath(rootDir, plan);
|
|
823
|
+
await fs.mkdir(path.dirname(executionLock), { recursive: true });
|
|
824
|
+
let lockHandle: fs.FileHandle | null = null;
|
|
825
|
+
try {
|
|
826
|
+
try {
|
|
827
|
+
lockHandle = await fs.open(executionLock, "wx");
|
|
828
|
+
await lockHandle.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
829
|
+
} catch (error) {
|
|
830
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
831
|
+
const blocked = receiptBase(sourcePlan, plan, false);
|
|
832
|
+
blocked.warnings.push("An apply with the same plan and idempotency key is already running.");
|
|
833
|
+
blocked.completedAt = new Date().toISOString();
|
|
834
|
+
return blocked;
|
|
835
|
+
}
|
|
836
|
+
throw error;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const racedReceipt = await readJson<ApplyReceipt>(storagePath);
|
|
840
|
+
if (racedReceipt) return { ...racedReceipt, replayed: true };
|
|
841
|
+
|
|
842
|
+
const receipt = receiptBase(sourcePlan, plan, false);
|
|
843
|
+
receipt.operations = operationReceipts(plan);
|
|
844
|
+
receipt.touchedFiles = plan.operations.map((operation) => operation.target);
|
|
845
|
+
receipt.actions = plan.operations.map((operation) => ({
|
|
846
|
+
kind: operation.kind,
|
|
847
|
+
description: `${operation.kind} ${operation.target}`,
|
|
848
|
+
file: operation.target,
|
|
849
|
+
applied: false,
|
|
850
|
+
}));
|
|
851
|
+
|
|
852
|
+
const structuralIssues = validateExecutablePlan(plan);
|
|
853
|
+
if (structuralIssues.length > 0) {
|
|
854
|
+
receipt.warnings.push(...structuralIssues);
|
|
855
|
+
receipt.completedAt = new Date().toISOString();
|
|
856
|
+
await writeJsonExclusive(storagePath, receipt);
|
|
857
|
+
return receipt;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const stateIssues = await preconditionIssues(rootDir, plan);
|
|
861
|
+
if (stateIssues.length > 0) {
|
|
862
|
+
receipt.warnings.push(...stateIssues);
|
|
863
|
+
receipt.completedAt = new Date().toISOString();
|
|
864
|
+
await writeJsonExclusive(storagePath, receipt);
|
|
865
|
+
return receipt;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
let snapshot: AgentSnapshot;
|
|
869
|
+
try {
|
|
870
|
+
snapshot = await createSnapshot(rootDir, plan);
|
|
871
|
+
} catch (error) {
|
|
872
|
+
receipt.warnings.push(
|
|
873
|
+
`could not create rollback snapshot: ${error instanceof Error ? error.message : String(error)}`,
|
|
874
|
+
);
|
|
875
|
+
receipt.completedAt = new Date().toISOString();
|
|
876
|
+
await writeJsonExclusive(storagePath, receipt);
|
|
877
|
+
return receipt;
|
|
878
|
+
}
|
|
879
|
+
receipt.rollback.id = snapshot.id;
|
|
880
|
+
receipt.rollback.snapshotPath = toPosix(path.relative(rootDir, snapshotPath(rootDir, snapshot.id)));
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
for (const [index, operation] of plan.operations.entries()) {
|
|
884
|
+
try {
|
|
885
|
+
await assertOperationPrecondition(rootDir, operation);
|
|
886
|
+
await writeAtomic(rootDir, operation);
|
|
887
|
+
receipt.operations[index]!.status = "applied";
|
|
888
|
+
receipt.actions[index]!.applied = true;
|
|
889
|
+
receipt.changedFiles.push(operation.target);
|
|
890
|
+
if (options.failureAfterOperations === index + 1) {
|
|
891
|
+
throw new ApplyFailure(
|
|
892
|
+
`injected apply failure after ${index + 1} operation(s)`,
|
|
893
|
+
operation.id,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
} catch (error) {
|
|
897
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
898
|
+
if (receipt.operations[index]!.status !== "applied") {
|
|
899
|
+
receipt.operations[index]!.status = "failed";
|
|
900
|
+
receipt.operations[index]!.error = message;
|
|
901
|
+
}
|
|
902
|
+
throw new ApplyFailure(message, operation.id);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
receipt.verification = await runVerification(rootDir, plan);
|
|
907
|
+
const failedRequired = receipt.verification.find((result) => result.required && !result.ok);
|
|
908
|
+
if (failedRequired) {
|
|
909
|
+
throw new ApplyFailure(`required verification failed: ${failedRequired.id}`);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
receipt.ok = true;
|
|
913
|
+
receipt.status = "applied";
|
|
914
|
+
receipt.completedAt = new Date().toISOString();
|
|
915
|
+
await writeJsonExclusive(storagePath, receipt);
|
|
916
|
+
return receipt;
|
|
917
|
+
} catch (error) {
|
|
918
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
919
|
+
receipt.warnings.push(message);
|
|
920
|
+
receipt.status = "failed";
|
|
921
|
+
|
|
922
|
+
if (plan.rollbackPolicy === "automatic") {
|
|
923
|
+
receipt.rollback.attempted = true;
|
|
924
|
+
const appliedTargets = new Set(
|
|
925
|
+
receipt.operations
|
|
926
|
+
.filter((operation) => operation.status === "applied")
|
|
927
|
+
.map((operation) => operation.target),
|
|
928
|
+
);
|
|
929
|
+
const rollback = await restoreSnapshot(rootDir, snapshot, appliedTargets);
|
|
930
|
+
receipt.rollback.ok = rollback.ok;
|
|
931
|
+
receipt.rollback.restoredFiles = rollback.restoredFiles;
|
|
932
|
+
receipt.rollback.conflicts = rollback.conflicts;
|
|
933
|
+
if (rollback.ok) {
|
|
934
|
+
receipt.status = "rolled_back";
|
|
935
|
+
receipt.changedFiles = [];
|
|
936
|
+
for (const operation of receipt.operations) {
|
|
937
|
+
if (operation.status === "applied") operation.status = "rolled_back";
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
receipt.completedAt = new Date().toISOString();
|
|
943
|
+
await writeJsonExclusive(storagePath, receipt);
|
|
944
|
+
return receipt;
|
|
945
|
+
}
|
|
946
|
+
} finally {
|
|
947
|
+
await lockHandle?.close();
|
|
948
|
+
if (lockHandle) await fs.rm(executionLock, { force: true });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function compatibilityPreview(sourcePlan: string, plan: AgentPlan): AgentApplyReport {
|
|
953
|
+
const receipt = receiptBase(sourcePlan, plan, true);
|
|
954
|
+
receipt.ok = true;
|
|
955
|
+
receipt.status = "preview";
|
|
956
|
+
receipt.actions = previewActions(plan);
|
|
957
|
+
receipt.warnings = [
|
|
958
|
+
"This intent-only plan has no typed operations, so apply remains a compatibility preview.",
|
|
959
|
+
"Create an executable plan with typed operations before requesting filesystem mutation.",
|
|
960
|
+
];
|
|
961
|
+
return receipt;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export async function buildAgentApplyReport(
|
|
965
|
+
rootDir: string = process.cwd(),
|
|
966
|
+
options: BuildAgentApplyOptions = {},
|
|
967
|
+
): Promise<AgentApplyReport> {
|
|
968
|
+
const sourceRel = options.from ?? DEFAULT_PLAN_PATH;
|
|
969
|
+
let sourcePath: string;
|
|
970
|
+
try {
|
|
971
|
+
const normalizedSource = toPosix(sourceRel).replace(/^\.\//, "");
|
|
972
|
+
sourcePath = resolveInside(rootDir, normalizedSource);
|
|
973
|
+
await assertNoSymlinkTraversal(rootDir, normalizedSource);
|
|
974
|
+
} catch (error) {
|
|
975
|
+
const receipt = receiptBase(sourceRel, null, true);
|
|
976
|
+
receipt.warnings.push(error instanceof Error ? error.message : String(error));
|
|
977
|
+
return receipt;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const plan = await readAgentPlan(sourcePath);
|
|
981
|
+
if (!plan) {
|
|
982
|
+
const receipt = receiptBase(sourceRel, null, true);
|
|
983
|
+
receipt.actions = [
|
|
984
|
+
{
|
|
985
|
+
kind: "verify",
|
|
986
|
+
description: "Create an agent plan first.",
|
|
987
|
+
command: "mandu agent plan \"<task>\" --json --write",
|
|
988
|
+
applied: false,
|
|
989
|
+
},
|
|
990
|
+
];
|
|
991
|
+
receipt.warnings.push(`Could not read ${sourceRel}.`);
|
|
992
|
+
return receipt;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (!plan.executable || !Array.isArray(plan.operations) || plan.operations.length === 0) {
|
|
996
|
+
return compatibilityPreview(sourceRel, plan);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const structuralIssues = validateExecutablePlan(plan);
|
|
1000
|
+
if (options.dryRun !== false) {
|
|
1001
|
+
const stateIssues = structuralIssues.length === 0
|
|
1002
|
+
? await preconditionIssues(rootDir, plan)
|
|
1003
|
+
: [];
|
|
1004
|
+
const receipt = receiptBase(sourceRel, plan, true);
|
|
1005
|
+
receipt.operations = operationReceipts(plan);
|
|
1006
|
+
receipt.touchedFiles = plan.operations.map((operation) => operation.target);
|
|
1007
|
+
receipt.actions = plan.operations.map((operation) => ({
|
|
1008
|
+
kind: operation.kind,
|
|
1009
|
+
description: `${operation.kind} ${operation.target}`,
|
|
1010
|
+
file: operation.target,
|
|
1011
|
+
applied: false,
|
|
1012
|
+
}));
|
|
1013
|
+
receipt.ok = structuralIssues.length === 0 && stateIssues.length === 0;
|
|
1014
|
+
receipt.status = receipt.ok ? "preview" : "blocked";
|
|
1015
|
+
receipt.warnings = receipt.ok
|
|
1016
|
+
? ["Typed apply preview only. No filesystem changes were made."]
|
|
1017
|
+
: [...structuralIssues, ...stateIssues];
|
|
1018
|
+
return receipt;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return executePlan(path.resolve(rootDir), sourceRel, plan, options);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export async function writeAgentApplyReport(
|
|
1025
|
+
rootDir: string,
|
|
1026
|
+
report: AgentApplyReport,
|
|
1027
|
+
): Promise<{ path: string; report: AgentApplyReport }> {
|
|
1028
|
+
const outPath = path.join(rootDir, APPLY_REPORT_PATH);
|
|
1029
|
+
await writeJson(outPath, report);
|
|
1030
|
+
return { path: outPath, report };
|
|
1031
|
+
}
|