@piagent/platform 1.3.1 → 1.4.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/CHANGELOG.md +132 -0
- package/README.md +10 -4
- package/README.vi.md +27 -0
- package/architecture/layers.json +96 -1
- package/docs/README.md +1 -0
- package/docs/architecture.md +1 -0
- package/docs/authority-manifest.md +1 -0
- package/docs/auto-delegation-policy.md +1 -0
- package/docs/capability-packs.md +1 -0
- package/docs/command-reference-vietnamese.md +5 -4
- package/docs/context-engine.md +1 -0
- package/docs/context-window-policy.md +1 -0
- package/docs/distribution-standard.md +8 -7
- package/docs/en/architecture.md +13 -0
- package/docs/herdr-workflow.md +1 -0
- package/docs/languages.json +20 -4
- package/docs/mcp-and-tools.md +125 -9
- package/docs/memory-policy.md +1 -0
- package/docs/model-options.md +1 -0
- package/docs/oauth-providers.md +1 -0
- package/docs/operator-manual-vietnamese.md +15 -12
- package/docs/package-architecture-notes.md +1 -0
- package/docs/project-adapters.md +1 -0
- package/docs/project-onboarding.md +1 -0
- package/docs/publishing-for-teams.md +37 -0
- package/docs/quality-benchmark.md +1 -0
- package/docs/quickstart-vietnamese.md +3 -2
- package/docs/release-install-policy.md +22 -8
- package/docs/runtime-harness-standard.md +2 -1
- package/docs/runtime-policy-design.md +1 -0
- package/docs/runtime-quality-baseline.md +1 -0
- package/docs/security-redaction-benchmark.md +1 -0
- package/docs/security-threat-model.md +2 -1
- package/docs/subagent-orchestration-capabilities.md +1 -0
- package/docs/subagents-and-multiagent.md +1 -0
- package/docs/task-implementation-contract.md +12 -4
- package/docs/task-lifecycle-vietnamese.md +1 -0
- package/docs/team-onboarding.md +3 -2
- package/docs/third-party-neutrality.md +26 -0
- package/docs/usage-observability.md +71 -0
- package/docs/vercel-docs-site.md +1 -0
- package/docs/vi/architecture.md +13 -0
- package/docs/workflow-recipes.md +1 -0
- package/docs-site/architecture.html +5 -1
- package/docs-site/benchmark.html +5 -1
- package/docs-site/commands.html +5 -1
- package/docs-site/content/en/index.html +49 -0
- package/docs-site/content/en/runtime.html +13 -0
- package/docs-site/content/index.html +49 -0
- package/docs-site/content/runtime.html +28 -0
- package/docs-site/context-engine.html +5 -1
- package/docs-site/en/architecture.html +5 -1
- package/docs-site/en/benchmark.html +5 -1
- package/docs-site/en/commands.html +5 -1
- package/docs-site/en/context-engine.html +5 -1
- package/docs-site/en/index.html +54 -1
- package/docs-site/en/mcp-approval.html +5 -1
- package/docs-site/en/mcp-auth.html +5 -1
- package/docs-site/en/mcp-servers.html +5 -1
- package/docs-site/en/mcp.html +5 -1
- package/docs-site/en/permissions.html +5 -1
- package/docs-site/en/profiles.html +5 -1
- package/docs-site/en/quickstart.html +5 -1
- package/docs-site/en/runtime.html +18 -1
- package/docs-site/en/scope.html +5 -1
- package/docs-site/en/security.html +5 -1
- package/docs-site/en/team.html +5 -1
- package/docs-site/en/whats-new.html +5 -1
- package/docs-site/en/workflows.html +5 -1
- package/docs-site/index.html +54 -1
- package/docs-site/mcp-approval.html +5 -1
- package/docs-site/mcp-auth.html +5 -1
- package/docs-site/mcp-servers.html +5 -1
- package/docs-site/mcp.html +5 -1
- package/docs-site/permissions.html +5 -1
- package/docs-site/profiles.html +5 -1
- package/docs-site/quickstart.html +5 -1
- package/docs-site/runtime.html +33 -1
- package/docs-site/scope.html +5 -1
- package/docs-site/security.html +5 -1
- package/docs-site/team.html +5 -1
- package/docs-site/whats-new.html +5 -1
- package/docs-site/workflows.html +5 -1
- package/evals/fixtures/mutation-provenance-record.invalid.json +17 -0
- package/evals/fixtures/mutation-provenance-record.valid.json +27 -0
- package/evals/fixtures/piagent-webui/approval-v1.invalid.json +54 -0
- package/evals/fixtures/piagent-webui/approval-v1.valid.json +58 -0
- package/evals/fixtures/piagent-webui/attachment-v1.invalid.json +25 -0
- package/evals/fixtures/piagent-webui/attachment-v1.valid.json +25 -0
- package/evals/fixtures/piagent-webui/capabilities-v1.invalid.json +51 -0
- package/evals/fixtures/piagent-webui/capabilities-v1.valid.json +122 -0
- package/evals/fixtures/piagent-webui/commit-summary-v1.invalid.json +10 -0
- package/evals/fixtures/piagent-webui/commit-summary-v1.valid.json +25 -0
- package/evals/fixtures/piagent-webui/control-command-v1.invalid.json +37 -0
- package/evals/fixtures/piagent-webui/control-command-v1.valid.json +35 -0
- package/evals/fixtures/piagent-webui/diff-v1.invalid.json +13 -0
- package/evals/fixtures/piagent-webui/diff-v1.valid.json +141 -0
- package/evals/fixtures/piagent-webui/gateway-capabilities-v1.invalid.json +35 -0
- package/evals/fixtures/piagent-webui/gateway-capabilities-v1.valid.json +35 -0
- package/evals/fixtures/piagent-webui/gateway-protocol-v1.invalid.json +14 -0
- package/evals/fixtures/piagent-webui/gateway-protocol-v1.valid.json +14 -0
- package/evals/fixtures/piagent-webui/handoff-history-v1.invalid.json +16 -0
- package/evals/fixtures/piagent-webui/handoff-history-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/model-catalog-v1.invalid.json +31 -0
- package/evals/fixtures/piagent-webui/model-catalog-v1.valid.json +43 -0
- package/evals/fixtures/piagent-webui/queue-v1.invalid.json +29 -0
- package/evals/fixtures/piagent-webui/queue-v1.valid.json +44 -0
- package/evals/fixtures/piagent-webui/recovery-history-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/recovery-history-v1.valid.json +20 -0
- package/evals/fixtures/piagent-webui/release-monitor-v1.invalid.json +12 -0
- package/evals/fixtures/piagent-webui/release-monitor-v1.valid.json +29 -0
- package/evals/fixtures/piagent-webui/review-state-v1.invalid.json +25 -0
- package/evals/fixtures/piagent-webui/review-state-v1.valid.json +38 -0
- package/evals/fixtures/piagent-webui/runtime-event-v2.invalid.json +49 -0
- package/evals/fixtures/piagent-webui/runtime-event-v2.valid.json +49 -0
- package/evals/fixtures/piagent-webui/session-catalog-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/session-catalog-v1.valid.json +58 -0
- package/evals/fixtures/piagent-webui/session-command-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/session-command-v1.valid.json +19 -0
- package/evals/fixtures/piagent-webui/snapshot-v1.invalid.json +5 -0
- package/evals/fixtures/piagent-webui/snapshot-v1.valid.json +191 -0
- package/evals/fixtures/piagent-webui/source-change-v1.invalid.json +57 -0
- package/evals/fixtures/piagent-webui/source-change-v1.valid.json +98 -0
- package/evals/fixtures/piagent-webui/source-mutation-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/source-mutation-v1.valid.json +38 -0
- package/evals/fixtures/piagent-webui/source-revert-v1.invalid.json +20 -0
- package/evals/fixtures/piagent-webui/source-revert-v1.valid.json +62 -0
- package/evals/fixtures/piagent-webui/subagent-tree-v1.invalid.json +26 -0
- package/evals/fixtures/piagent-webui/subagent-tree-v1.valid.json +39 -0
- package/evals/fixtures/piagent-webui/task-index-v1.invalid.json +15 -0
- package/evals/fixtures/piagent-webui/task-index-v1.valid.json +15 -0
- package/evals/fixtures/piagent-webui/task-timeline-v1.invalid.json +14 -0
- package/evals/fixtures/piagent-webui/task-timeline-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/transcript-v1.invalid.json +35 -0
- package/evals/fixtures/piagent-webui/transcript-v1.valid.json +55 -0
- package/evals/fixtures/task-baseline-manifest.invalid.json +16 -0
- package/evals/fixtures/task-baseline-manifest.valid.json +41 -0
- package/evals/fixtures/verifier-file-snapshot.invalid.json +21 -0
- package/evals/fixtures/verifier-file-snapshot.valid.json +21 -0
- package/package.json +43 -4
- package/packages/piagent-core/README.md +3 -2
- package/packages/piagent-core/benchmark/benchmark-candidate.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-forensics.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-ledger.d.ts +4 -0
- package/packages/piagent-core/benchmark/benchmark-record-validation.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-runtime.d.ts +1 -0
- package/packages/piagent-core/capabilities/capability-core.js +2 -2
- package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
- package/packages/piagent-core/capabilities/runtime-integrity.js +190 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +80 -272
- package/packages/piagent-core/extensions/redaction-core.d.ts +2 -0
- package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
- package/packages/piagent-core/extensions/shell-reach.ts +250 -0
- package/packages/piagent-core/extensions/task-journal.js +30 -1
- package/packages/piagent-core/extensions/task-state.js +33 -36
- package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
- package/packages/piagent-core/mcp/mcp-server-catalog.js +139 -9
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -2
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +12 -2
- package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +20 -19
- package/packages/piagent-core/runtime/hooks/tool-result-metadata.ts +13 -0
- package/packages/piagent-core/runtime/input/chat-images.ts +2 -2
- package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +94 -0
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +318 -0
- package/packages/piagent-core/runtime/inspection/commit-summary-projection.ts +59 -0
- package/packages/piagent-core/runtime/inspection/context-telemetry-inspection.ts +37 -0
- package/packages/piagent-core/runtime/inspection/criteria-links.ts +295 -0
- package/packages/piagent-core/runtime/inspection/diff-projection.ts +499 -0
- package/packages/piagent-core/runtime/inspection/git-filter-safety.ts +38 -0
- package/packages/piagent-core/runtime/inspection/git-numstat-adapter.ts +71 -0
- package/packages/piagent-core/runtime/inspection/git-status-adapter.ts +493 -0
- package/packages/piagent-core/runtime/inspection/git-tree-adapter.ts +108 -0
- package/packages/piagent-core/runtime/inspection/mcp-control.ts +10 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-contract.ts +131 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-recorder.ts +48 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-store.ts +161 -0
- package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +36 -0
- package/packages/piagent-core/runtime/inspection/review-state-contract.ts +108 -0
- package/packages/piagent-core/runtime/inspection/review-state-projection.ts +93 -0
- package/packages/piagent-core/runtime/inspection/review-state-store.ts +112 -0
- package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +262 -0
- package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +103 -0
- package/packages/piagent-core/runtime/inspection/source-change-projection.ts +471 -0
- package/packages/piagent-core/runtime/inspection/source-evidence-contract.ts +139 -0
- package/packages/piagent-core/runtime/inspection/source-evidence-store.ts +315 -0
- package/packages/piagent-core/runtime/inspection/source-handoff-store.ts +70 -0
- package/packages/piagent-core/runtime/inspection/source-inspection-plan.ts +34 -0
- package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +164 -0
- package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +168 -0
- package/packages/piagent-core/runtime/inspection/source-open-target.ts +38 -0
- package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +147 -0
- package/packages/piagent-core/runtime/inspection/task-baseline-start-capture.ts +22 -0
- package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +160 -0
- package/packages/piagent-core/runtime/inspection/task-control-journal.ts +131 -0
- package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +95 -0
- package/packages/piagent-core/runtime/inspection/task-provenance-projection.ts +60 -0
- package/packages/piagent-core/runtime/inspection/task-recovery-timeline.ts +88 -0
- package/packages/piagent-core/runtime/inspection/task-run-index.ts +87 -0
- package/packages/piagent-core/runtime/inspection/task-source-projection.ts +401 -0
- package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +101 -0
- package/packages/piagent-core/runtime/inspection/text-diff.ts +60 -0
- package/packages/piagent-core/runtime/inspection/verifier-snapshot-contract.ts +114 -0
- package/packages/piagent-core/runtime/inspection/verifier-snapshot-store.ts +189 -0
- package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +320 -0
- package/packages/piagent-core/runtime/inspection/workspace-file-reader.ts +132 -0
- package/packages/piagent-core/runtime/inspection/workspace-source-projection.ts +96 -0
- package/packages/piagent-core/runtime/inspection/zero-turn-conformance.ts +175 -0
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +93 -6
- package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
- package/packages/piagent-core/runtime/policy/source-index-transaction.ts +173 -0
- package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +42 -0
- package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +184 -0
- package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +105 -0
- package/packages/piagent-core/runtime/product/activity-inspector-footer.ts +5 -3
- package/packages/piagent-core/runtime/product/activity-inspector.ts +85 -343
- package/packages/piagent-core/runtime/recovery/resume-state.ts +38 -1
- package/packages/piagent-core/runtime/registration/activity-inspector-command.ts +68 -14
- package/packages/piagent-core/runtime/registration/task-start-tool.ts +22 -27
- package/packages/piagent-core/runtime/session/model-authorship-state.ts +31 -3
- package/packages/piagent-core/runtime/session/runtime-state.ts +11 -1
- package/packages/piagent-core/runtime/session/token-economics.js +34 -0
- package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
- package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
- package/packages/piagent-core/security/sensitive-data.js +7 -0
- package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +1 -0
- package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +172 -0
- package/packages/piagent-webui/dist/client/index.html +16 -0
- package/packages/piagent-webui/extension/attachment-store.ts +323 -0
- package/packages/piagent-webui/extension/held-message-queue.ts +311 -0
- package/packages/piagent-webui/extension/lifecycle-controller.ts +423 -0
- package/packages/piagent-webui/extension/lifecycle-event-adapter.ts +23 -0
- package/packages/piagent-webui/extension/piagent-webui.ts +470 -0
- package/packages/piagent-webui/extension/review-controller.ts +160 -0
- package/packages/piagent-webui/extension/same-session-bridge.ts +500 -0
- package/packages/piagent-webui/extension/session-options-controller.ts +281 -0
- package/packages/piagent-webui/extension/session-stream-adapter.ts +249 -0
- package/packages/piagent-webui/extension/source-mutation-controller.ts +215 -0
- package/packages/piagent-webui/extension/source-open-controller.ts +136 -0
- package/packages/piagent-webui/extension/source-revert-controller.ts +189 -0
- package/packages/piagent-webui/extension/terminal-session-adapter.ts +73 -0
- package/packages/piagent-webui/extension/vscode-handoff.ts +51 -0
- package/packages/piagent-webui/gateway/control-socket.ts +92 -0
- package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
- package/packages/piagent-webui/gateway/gateway-events.ts +60 -0
- package/packages/piagent-webui/gateway/gateway-protocol-service.ts +64 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +215 -0
- package/packages/piagent-webui/gateway/gateway-session-stream.ts +110 -0
- package/packages/piagent-webui/gateway/mcp-auth-broker.ts +158 -0
- package/packages/piagent-webui/gateway/native-project-picker.ts +29 -0
- package/packages/piagent-webui/gateway/pi-host.ts +41 -0
- package/packages/piagent-webui/gateway/profile-state.ts +1 -0
- package/packages/piagent-webui/gateway/project-registry.ts +108 -0
- package/packages/piagent-webui/gateway/provider-auth-broker.ts +172 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +128 -0
- package/packages/piagent-webui/gateway/session-command-controller.ts +237 -0
- package/packages/piagent-webui/gateway/session-command-store.ts +224 -0
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +239 -0
- package/packages/piagent-webui/gateway/session-lease-store.ts +1 -0
- package/packages/piagent-webui/gateway/session-metadata-store.ts +152 -0
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +489 -0
- package/packages/piagent-webui/ownership/gateway-runtime-context.ts +8 -0
- package/packages/piagent-webui/ownership/profile-state.ts +117 -0
- package/packages/piagent-webui/ownership/session-lease-store.ts +329 -0
- package/packages/piagent-webui/ownership/session-refs.ts +8 -0
- package/packages/piagent-webui/package.json +33 -0
- package/packages/piagent-webui/server/README.md +4 -0
- package/packages/piagent-webui/server/benchmark-release-monitor.ts +230 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +403 -0
- package/packages/piagent-webui/server/gateway-websocket.ts +143 -0
- package/packages/piagent-webui/server/http-security.ts +27 -0
- package/packages/piagent-webui/server/ipc-read-model-client.ts +89 -0
- package/packages/piagent-webui/server/loopback-server.ts +368 -0
- package/packages/piagent-webui/server/read-model-provider.ts +37 -0
- package/packages/piagent-webui/server/read-only-router.ts +105 -0
- package/packages/piagent-webui/server/session-auth.ts +88 -0
- package/packages/piagent-webui/server/sidecar-main.ts +86 -0
- package/packages/piagent-webui/server/sse-hub.ts +76 -0
- package/packages/piagent-webui/server/static-bundle.ts +40 -0
- package/packages/piagent-webui/server/transcript-projection.ts +125 -0
- package/schemas/mutation-provenance-record.schema.json +100 -0
- package/schemas/piagent-webui/approval-v1.schema.json +654 -0
- package/schemas/piagent-webui/attachment-v1.schema.json +267 -0
- package/schemas/piagent-webui/capabilities-v1.schema.json +729 -0
- package/schemas/piagent-webui/catalog-v1.json +240 -0
- package/schemas/piagent-webui/commit-summary-v1.schema.json +63 -0
- package/schemas/piagent-webui/common-v1.schema.json +479 -0
- package/schemas/piagent-webui/control-command-v1.schema.json +1780 -0
- package/schemas/piagent-webui/diff-v1.schema.json +826 -0
- package/schemas/piagent-webui/gateway-capabilities-v1.schema.json +136 -0
- package/schemas/piagent-webui/gateway-protocol-v1.schema.json +456 -0
- package/schemas/piagent-webui/handoff-history-v1.schema.json +92 -0
- package/schemas/piagent-webui/model-catalog-v1.schema.json +95 -0
- package/schemas/piagent-webui/queue-v1.schema.json +99 -0
- package/schemas/piagent-webui/recovery-history-v1.schema.json +144 -0
- package/schemas/piagent-webui/release-monitor-v1.schema.json +173 -0
- package/schemas/piagent-webui/review-state-v1.schema.json +206 -0
- package/schemas/piagent-webui/runtime-event-v2.schema.json +5206 -0
- package/schemas/piagent-webui/session-catalog-v1.schema.json +222 -0
- package/schemas/piagent-webui/session-command-v1.schema.json +390 -0
- package/schemas/piagent-webui/snapshot-v1.schema.json +2622 -0
- package/schemas/piagent-webui/source-change-v1.schema.json +763 -0
- package/schemas/piagent-webui/source-mutation-v1.schema.json +96 -0
- package/schemas/piagent-webui/source-revert-v1.schema.json +130 -0
- package/schemas/piagent-webui/subagent-tree-v1.schema.json +88 -0
- package/schemas/piagent-webui/task-index-v1.schema.json +74 -0
- package/schemas/piagent-webui/task-timeline-v1.schema.json +49 -0
- package/schemas/piagent-webui/transcript-v1.schema.json +243 -0
- package/schemas/task-baseline-manifest.schema.json +122 -0
- package/schemas/verifier-file-snapshot.schema.json +81 -0
- package/scripts/build-docs-site.mjs +21 -1
- package/scripts/check-architecture.mjs +1 -0
- package/scripts/check-doc-languages.mjs +19 -2
- package/scripts/check-third-party-neutrality.mjs +43 -0
- package/scripts/explain-command.mjs +240 -0
- package/scripts/install-global.sh +105 -4
- package/scripts/mcp-manage.mjs +17 -4
- package/scripts/pi-session-stats.sh +10 -2
- package/scripts/piagent-cli.mjs +30 -3
- package/scripts/piagent-dashboard.mjs +261 -0
- package/scripts/piagent-webui-launcher.mjs +121 -0
- package/scripts/setup.sh +69 -33
- package/scripts/traffic-snapshot.mjs +214 -0
- package/scripts/typescript-loader.mjs +42 -1
- package/scripts/uninstall-global.sh +53 -2
- package/scripts/update-global.mjs +33 -1
- package/scripts/verify-local.sh +257 -0
- package/scripts/verify-release-identity.mjs +4 -1
- package/scripts/verify-translation-pairing.mjs +121 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export class WorkspaceReadError extends Error {
|
|
6
|
+
readonly code: "outside-root" | "symlink-ancestor" | "not-file" | "oversized" | "changed-during-read";
|
|
7
|
+
|
|
8
|
+
constructor(code: WorkspaceReadError["code"], message: string) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "WorkspaceReadError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type SafeCandidate = { root: string; absolute: string; parent: string };
|
|
16
|
+
|
|
17
|
+
function inside(root: string, candidate: string): boolean {
|
|
18
|
+
const relative = path.relative(root, candidate);
|
|
19
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function safeCandidate(repoRoot: string, repoPath: string): SafeCandidate {
|
|
23
|
+
const root = fs.realpathSync.native(repoRoot);
|
|
24
|
+
const segments = repoPath.split("/");
|
|
25
|
+
if (!repoPath || path.posix.isAbsolute(repoPath) || segments.includes("..") || segments.includes("")) {
|
|
26
|
+
throw new WorkspaceReadError("outside-root", "Workspace path is not a safe repository-relative path");
|
|
27
|
+
}
|
|
28
|
+
let parent = root;
|
|
29
|
+
for (const segment of segments.slice(0, -1)) {
|
|
30
|
+
parent = path.join(parent, segment);
|
|
31
|
+
const stat = fs.lstatSync(parent);
|
|
32
|
+
if (stat.isSymbolicLink()) throw new WorkspaceReadError("symlink-ancestor", "Workspace path traverses a symbolic link");
|
|
33
|
+
if (!stat.isDirectory()) throw new WorkspaceReadError("not-file", "Workspace path has a non-directory ancestor");
|
|
34
|
+
}
|
|
35
|
+
const canonicalParent = fs.realpathSync.native(parent);
|
|
36
|
+
if (!inside(root, canonicalParent)) throw new WorkspaceReadError("outside-root", "Workspace path resolves outside its repository root");
|
|
37
|
+
const absolute = path.join(canonicalParent, segments.at(-1) as string);
|
|
38
|
+
if (!inside(root, absolute)) throw new WorkspaceReadError("outside-root", "Workspace path escapes its repository root");
|
|
39
|
+
return { root, absolute, parent: canonicalParent };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sameFile(left: fs.Stats, right: fs.Stats): boolean {
|
|
43
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function openRegularFile(repoRoot: string, repoPath: string): { descriptor: number; stat: fs.Stats; candidate: SafeCandidate } {
|
|
47
|
+
const candidate = safeCandidate(repoRoot, repoPath);
|
|
48
|
+
const descriptor = fs.openSync(candidate.absolute, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
49
|
+
try {
|
|
50
|
+
const stat = fs.fstatSync(descriptor);
|
|
51
|
+
if (!stat.isFile()) throw new WorkspaceReadError("not-file", "Workspace entry is not a regular file");
|
|
52
|
+
const canonical = fs.realpathSync.native(candidate.absolute);
|
|
53
|
+
const current = fs.lstatSync(candidate.absolute);
|
|
54
|
+
if (!inside(candidate.root, canonical) || !sameFile(stat, current)) {
|
|
55
|
+
throw new WorkspaceReadError("changed-during-read", "Workspace path changed while it was opened");
|
|
56
|
+
}
|
|
57
|
+
return { descriptor, stat, candidate };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
fs.closeSync(descriptor);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function verifyAfterRead(descriptor: number, before: fs.Stats, candidate: SafeCandidate): void {
|
|
65
|
+
const after = fs.fstatSync(descriptor);
|
|
66
|
+
const current = fs.lstatSync(candidate.absolute);
|
|
67
|
+
const canonical = fs.realpathSync.native(candidate.absolute);
|
|
68
|
+
if (
|
|
69
|
+
!sameFile(before, after)
|
|
70
|
+
|| !sameFile(after, current)
|
|
71
|
+
|| before.size !== after.size
|
|
72
|
+
|| before.mtimeMs !== after.mtimeMs
|
|
73
|
+
|| before.ctimeMs !== after.ctimeMs
|
|
74
|
+
|| !inside(candidate.root, canonical)
|
|
75
|
+
) {
|
|
76
|
+
throw new WorkspaceReadError("changed-during-read", "Workspace file changed during inspection");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function inspectWorkspaceEntry(repoRoot: string, repoPath: string): fs.Stats {
|
|
81
|
+
const candidate = safeCandidate(repoRoot, repoPath);
|
|
82
|
+
return fs.lstatSync(candidate.absolute);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readWorkspaceLink(repoRoot: string, repoPath: string): string {
|
|
86
|
+
const candidate = safeCandidate(repoRoot, repoPath);
|
|
87
|
+
const before = fs.lstatSync(candidate.absolute);
|
|
88
|
+
if (!before.isSymbolicLink()) throw new WorkspaceReadError("not-file", "Workspace entry is not a symbolic link");
|
|
89
|
+
const target = fs.readlinkSync(candidate.absolute);
|
|
90
|
+
const after = fs.lstatSync(candidate.absolute);
|
|
91
|
+
if (!sameFile(before, after) || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
|
|
92
|
+
throw new WorkspaceReadError("changed-during-read", "Workspace link changed during inspection");
|
|
93
|
+
}
|
|
94
|
+
return target;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function readWorkspaceFile(repoRoot: string, repoPath: string, maxBytes: number): Buffer {
|
|
98
|
+
const opened = openRegularFile(repoRoot, repoPath);
|
|
99
|
+
try {
|
|
100
|
+
if (opened.stat.size > maxBytes) throw new WorkspaceReadError("oversized", "Workspace file exceeds its read cap");
|
|
101
|
+
const content = Buffer.allocUnsafe(opened.stat.size);
|
|
102
|
+
let position = 0;
|
|
103
|
+
while (position < content.length) {
|
|
104
|
+
const count = fs.readSync(opened.descriptor, content, position, content.length - position, position);
|
|
105
|
+
if (count <= 0) throw new WorkspaceReadError("changed-during-read", "Workspace file ended during inspection");
|
|
106
|
+
position += count;
|
|
107
|
+
}
|
|
108
|
+
verifyAfterRead(opened.descriptor, opened.stat, opened.candidate);
|
|
109
|
+
return content;
|
|
110
|
+
} finally {
|
|
111
|
+
fs.closeSync(opened.descriptor);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function hashWorkspaceFile(repoRoot: string, repoPath: string): string {
|
|
116
|
+
const opened = openRegularFile(repoRoot, repoPath);
|
|
117
|
+
try {
|
|
118
|
+
const state = createHash("sha256");
|
|
119
|
+
const buffer = Buffer.allocUnsafe(256 * 1024);
|
|
120
|
+
let position = 0;
|
|
121
|
+
while (position < opened.stat.size) {
|
|
122
|
+
const count = fs.readSync(opened.descriptor, buffer, 0, Math.min(buffer.length, opened.stat.size - position), position);
|
|
123
|
+
if (count <= 0) throw new WorkspaceReadError("changed-during-read", "Workspace file ended during hashing");
|
|
124
|
+
state.update(buffer.subarray(0, count));
|
|
125
|
+
position += count;
|
|
126
|
+
}
|
|
127
|
+
verifyAfterRead(opened.descriptor, opened.stat, opened.candidate);
|
|
128
|
+
return state.digest("hex");
|
|
129
|
+
} finally {
|
|
130
|
+
fs.closeSync(opened.descriptor);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
collectSourceChangeViews,
|
|
5
|
+
type SourceChangeDocument,
|
|
6
|
+
type SourceChangeViews,
|
|
7
|
+
type SourceProjectionOptions
|
|
8
|
+
} from "./source-change-projection.ts";
|
|
9
|
+
|
|
10
|
+
export type WorkspaceSourceProjectionOptions = Omit<SourceProjectionOptions, "cwd" | "pageLimit"> & {
|
|
11
|
+
roots: string[];
|
|
12
|
+
pageLimit?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function token(prefix: string, value: string): string {
|
|
16
|
+
return `${prefix}.${createHash("sha256").update(value).digest("hex")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function combineDocuments(
|
|
20
|
+
documents: SourceChangeDocument[],
|
|
21
|
+
view: SourceChangeDocument["view"],
|
|
22
|
+
pageLimit: number
|
|
23
|
+
): SourceChangeDocument {
|
|
24
|
+
const template = structuredClone(documents[0]);
|
|
25
|
+
const viewRevision = token("workspace-view", JSON.stringify(documents.map((document) => document.viewRevision)));
|
|
26
|
+
const bases = documents.flatMap((document) => document.bases as Array<Record<string, unknown>>);
|
|
27
|
+
const states = documents.map((document) => document.availability.state);
|
|
28
|
+
const anyUnavailable = states.includes("unavailable");
|
|
29
|
+
const anyStale = states.includes("stale");
|
|
30
|
+
const exact = !anyUnavailable && !anyStale;
|
|
31
|
+
const allFiles = exact
|
|
32
|
+
? documents.flatMap((document) => document.files).sort((left, right) => {
|
|
33
|
+
const repo = String(left.repoRef).localeCompare(String(right.repoRef), "en");
|
|
34
|
+
return repo || String(left.path).localeCompare(String(right.path), "en");
|
|
35
|
+
})
|
|
36
|
+
: [];
|
|
37
|
+
const files = allFiles.slice(0, pageLimit);
|
|
38
|
+
const childTruncated = documents.some((document) => (document.page as any).truncated === true);
|
|
39
|
+
const rawTotal = exact ? documents.reduce((total, document) => total + Number((document.page as any).total ?? 0), 0) : 0;
|
|
40
|
+
const total = Math.min(1_000_000, rawTotal);
|
|
41
|
+
const truncated = exact && (childTruncated || files.length < allFiles.length || rawTotal > total);
|
|
42
|
+
const reasonCode = anyUnavailable ? "repository-view-unavailable" : anyStale ? "git-race" : null;
|
|
43
|
+
const rawRemoved = documents.reduce((sum, document) => sum + Number((document.redaction as any).valuesRemoved ?? 0), 0);
|
|
44
|
+
const valuesRemoved = Math.min(10_000, rawRemoved);
|
|
45
|
+
return {
|
|
46
|
+
...template,
|
|
47
|
+
view,
|
|
48
|
+
viewRevision,
|
|
49
|
+
bases,
|
|
50
|
+
availability: exact
|
|
51
|
+
? { state: "current", reasonCode: null, message: null }
|
|
52
|
+
: anyUnavailable
|
|
53
|
+
? { state: "unavailable", reasonCode, message: "At least one repository view is unavailable" }
|
|
54
|
+
: { state: "stale", reasonCode, message: "At least one repository changed during collection" },
|
|
55
|
+
files,
|
|
56
|
+
page: {
|
|
57
|
+
cursor: null,
|
|
58
|
+
nextCursor: truncated ? token("workspace-page", `${viewRevision}:${pageLimit}`) : null,
|
|
59
|
+
total,
|
|
60
|
+
returned: files.length,
|
|
61
|
+
truncated
|
|
62
|
+
},
|
|
63
|
+
truncationReason: truncated ? "page-limit" : null,
|
|
64
|
+
redaction: {
|
|
65
|
+
applied: documents.some((document) => (document.redaction as any).applied === true),
|
|
66
|
+
valuesRemoved,
|
|
67
|
+
truncated: rawRemoved > valuesRemoved || documents.some((document) => (document.redaction as any).truncated === true)
|
|
68
|
+
},
|
|
69
|
+
health: exact
|
|
70
|
+
? documents.some((document) => (document.health as any).state !== "ok")
|
|
71
|
+
? { state: "degraded", reasonCode: "repository-view-degraded", message: "At least one repository view is degraded" }
|
|
72
|
+
: { state: "ok", reasonCode: null, message: null }
|
|
73
|
+
: anyUnavailable
|
|
74
|
+
? { state: "unavailable", reasonCode, message: "At least one repository view is unavailable" }
|
|
75
|
+
: { state: "degraded", reasonCode, message: "At least one repository view is stale" },
|
|
76
|
+
issues: documents.flatMap((document) => document.issues as Array<Record<string, unknown>>).slice(0, 64)
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function collectWorkspaceSourceChangeViews(options: WorkspaceSourceProjectionOptions): Promise<SourceChangeViews> {
|
|
81
|
+
const roots = [...new Set(options.roots)];
|
|
82
|
+
if (roots.length === 0) throw new Error("At least one workspace Git root is required");
|
|
83
|
+
if (roots.length > 32) throw new Error("Workspace source projection supports at most 32 Git roots");
|
|
84
|
+
const pageLimit = Number.isInteger(options.pageLimit) ? Math.max(1, Math.min(2_000, options.pageLimit as number)) : 300;
|
|
85
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
86
|
+
const raw = await Promise.all(roots.map((cwd) => collectSourceChangeViews({ ...options, cwd, pageLimit: 2_000, generatedAt })));
|
|
87
|
+
const collected = [...new Map(raw.map((views) => [String((views.workingTree.bases as any[])[0]?.repoRef), views])).values()];
|
|
88
|
+
const working = collected.map((views) => views.workingTree);
|
|
89
|
+
const staged = collected.map((views) => views.staged);
|
|
90
|
+
const task = collected.map((views) => views.task).filter((document): document is SourceChangeDocument => document !== null);
|
|
91
|
+
return {
|
|
92
|
+
workingTree: combineDocuments(working, "working-tree", pageLimit),
|
|
93
|
+
staged: combineDocuments(staged, "staged", pageLimit),
|
|
94
|
+
task: task.length === collected.length ? combineDocuments(task, "task", pageLimit) : null
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const ZERO_TURN_CONFORMANCE_VERSION = "piagent-webui-zero-turn-v1" as const;
|
|
4
|
+
|
|
5
|
+
const EFFECT_KEYS = [
|
|
6
|
+
"providerRequests", "userMessages", "assistantMessages", "inputTokens", "outputTokens",
|
|
7
|
+
"cacheReadTokens", "cacheWriteTokens", "costMicros", "continuationConsumed", "turnTriggers"
|
|
8
|
+
] as const;
|
|
9
|
+
type EffectKey = typeof EFFECT_KEYS[number];
|
|
10
|
+
export type ZeroTurnEffects = Record<EffectKey, number>;
|
|
11
|
+
export type ZeroTurnCausalEvent = {
|
|
12
|
+
sequence: number;
|
|
13
|
+
correlationId: string | null;
|
|
14
|
+
attribution: "ui-command" | "unrelated-operation" | "unknown";
|
|
15
|
+
effects: Partial<ZeroTurnEffects>;
|
|
16
|
+
};
|
|
17
|
+
export type ZeroTurnObservation = {
|
|
18
|
+
providerRequests: number;
|
|
19
|
+
userMessages: number;
|
|
20
|
+
assistantMessages: number;
|
|
21
|
+
usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; costMicros: number };
|
|
22
|
+
continuationConsumed: number;
|
|
23
|
+
turnTriggers: number;
|
|
24
|
+
sessionRef: string;
|
|
25
|
+
leafMessageRef: string | null;
|
|
26
|
+
messageSetDigest: string;
|
|
27
|
+
taskContractDigest: string | null;
|
|
28
|
+
journalHead: string | null;
|
|
29
|
+
promptDigest: string;
|
|
30
|
+
toolSchemaDigest: string;
|
|
31
|
+
latestCausalSequence: number;
|
|
32
|
+
causalEvents: ZeroTurnCausalEvent[];
|
|
33
|
+
};
|
|
34
|
+
export type ZeroTurnOptions = {
|
|
35
|
+
action: string;
|
|
36
|
+
commandId: string;
|
|
37
|
+
concurrency: "quiescent" | "concurrent";
|
|
38
|
+
mutationClass: "view" | "control";
|
|
39
|
+
allowedDigestChanges?: Array<"taskContractDigest" | "journalHead">;
|
|
40
|
+
};
|
|
41
|
+
export type ZeroTurnReport<T = unknown> = {
|
|
42
|
+
version: typeof ZERO_TURN_CONFORMANCE_VERSION;
|
|
43
|
+
passed: boolean;
|
|
44
|
+
action: string;
|
|
45
|
+
commandId: string;
|
|
46
|
+
concurrency: ZeroTurnOptions["concurrency"];
|
|
47
|
+
mutationClass: ZeroTurnOptions["mutationClass"];
|
|
48
|
+
violations: string[];
|
|
49
|
+
delta: ZeroTurnEffects;
|
|
50
|
+
result?: T;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
54
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
55
|
+
|
|
56
|
+
function hash(value: string): string { return createHash("sha256").update(value).digest("hex"); }
|
|
57
|
+
function plain(value: unknown): value is Record<string, unknown> {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
59
|
+
const prototype = Object.getPrototypeOf(value);
|
|
60
|
+
return prototype === Object.prototype || prototype === null;
|
|
61
|
+
}
|
|
62
|
+
function canonical(value: unknown, seen = new Set<object>()): unknown {
|
|
63
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
64
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
if (seen.has(value)) throw new Error("Cyclic value cannot be digested");
|
|
67
|
+
seen.add(value); const output = value.map((item) => canonical(item, seen)); seen.delete(value); return output;
|
|
68
|
+
}
|
|
69
|
+
if (!plain(value)) throw new Error("Only JSON-compatible facts can be digested");
|
|
70
|
+
if (seen.has(value)) throw new Error("Cyclic value cannot be digested");
|
|
71
|
+
seen.add(value);
|
|
72
|
+
const output = Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key], seen)]));
|
|
73
|
+
seen.delete(value); return output;
|
|
74
|
+
}
|
|
75
|
+
function nonnegativeInteger(value: unknown): value is number {
|
|
76
|
+
return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= Number.MAX_SAFE_INTEGER;
|
|
77
|
+
}
|
|
78
|
+
function zeroEffects(): ZeroTurnEffects {
|
|
79
|
+
return { providerRequests: 0, userMessages: 0, assistantMessages: 0, inputTokens: 0, outputTokens: 0,
|
|
80
|
+
cacheReadTokens: 0, cacheWriteTokens: 0, costMicros: 0, continuationConsumed: 0, turnTriggers: 0 };
|
|
81
|
+
}
|
|
82
|
+
function counters(value: ZeroTurnObservation): ZeroTurnEffects {
|
|
83
|
+
return { providerRequests: value.providerRequests, userMessages: value.userMessages, assistantMessages: value.assistantMessages,
|
|
84
|
+
inputTokens: value.usage.inputTokens, outputTokens: value.usage.outputTokens, cacheReadTokens: value.usage.cacheReadTokens,
|
|
85
|
+
cacheWriteTokens: value.usage.cacheWriteTokens, costMicros: value.usage.costMicros,
|
|
86
|
+
continuationConsumed: value.continuationConsumed, turnTriggers: value.turnTriggers };
|
|
87
|
+
}
|
|
88
|
+
function observationErrors(value: ZeroTurnObservation): string[] {
|
|
89
|
+
const errors: string[] = [], values = counters(value);
|
|
90
|
+
for (const key of EFFECT_KEYS) if (!nonnegativeInteger(values[key])) errors.push(`invalid-observation:${key}`);
|
|
91
|
+
if (!REF.test(value.sessionRef) || !(value.leafMessageRef === null || REF.test(value.leafMessageRef))) errors.push("invalid-observation:session-identity");
|
|
92
|
+
for (const [key, digest] of [["messageSetDigest", value.messageSetDigest], ["promptDigest", value.promptDigest], ["toolSchemaDigest", value.toolSchemaDigest]] as const) {
|
|
93
|
+
if (!DIGEST.test(digest)) errors.push(`invalid-observation:${key}`);
|
|
94
|
+
}
|
|
95
|
+
for (const [key, digest] of [["taskContractDigest", value.taskContractDigest], ["journalHead", value.journalHead]] as const) {
|
|
96
|
+
if (!(digest === null || DIGEST.test(digest) || REF.test(digest))) errors.push(`invalid-observation:${key}`);
|
|
97
|
+
}
|
|
98
|
+
if (!nonnegativeInteger(value.latestCausalSequence)) errors.push("invalid-observation:causal-sequence");
|
|
99
|
+
let previous = -1;
|
|
100
|
+
for (const event of value.causalEvents) {
|
|
101
|
+
if (!nonnegativeInteger(event.sequence) || event.sequence <= previous || event.sequence > value.latestCausalSequence) errors.push("invalid-observation:causal-event-order");
|
|
102
|
+
previous = event.sequence;
|
|
103
|
+
if (!(event.correlationId === null || REF.test(event.correlationId)) || !["ui-command", "unrelated-operation", "unknown"].includes(event.attribution)) errors.push("invalid-observation:causal-event-identity");
|
|
104
|
+
for (const [key, effect] of Object.entries(event.effects)) {
|
|
105
|
+
if (!EFFECT_KEYS.includes(key as EffectKey) || !nonnegativeInteger(effect)) errors.push(`invalid-observation:causal-effect:${key}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return errors;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function digestZeroTurnFact(domain: string, value: unknown): string {
|
|
112
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(domain)) throw new Error("Invalid fact digest domain");
|
|
113
|
+
return `sha256:${hash(`piagent-webui-zero-turn:${domain}\n${JSON.stringify(canonical(value))}`)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function providerVisibleToolSchemaDigest(tools: Array<{ name: string; description?: string; parameters?: unknown }>): string {
|
|
117
|
+
const projected = tools.map((tool) => {
|
|
118
|
+
if (!REF.test(tool.name)) throw new Error(`Invalid provider-visible tool name: ${tool.name}`);
|
|
119
|
+
return { name: tool.name, description: String(tool.description ?? ""), parameters: canonical(tool.parameters ?? {}) };
|
|
120
|
+
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
121
|
+
if (new Set(projected.map((tool) => tool.name)).size !== projected.length) throw new Error("Provider-visible tool names must be unique");
|
|
122
|
+
return digestZeroTurnFact("tool-schema", projected);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function evaluateZeroTurn<T>(options: ZeroTurnOptions, before: ZeroTurnObservation, after: ZeroTurnObservation, result?: T): ZeroTurnReport<T> {
|
|
126
|
+
const violations = [...observationErrors(before).map((error) => `before:${error}`), ...observationErrors(after).map((error) => `after:${error}`)];
|
|
127
|
+
const beforeCounters = counters(before), afterCounters = counters(after), delta = zeroEffects();
|
|
128
|
+
for (const key of EFFECT_KEYS) {
|
|
129
|
+
delta[key] = afterCounters[key] - beforeCounters[key];
|
|
130
|
+
if (delta[key] < 0) violations.push(`counter-regressed:${key}`);
|
|
131
|
+
}
|
|
132
|
+
if (after.latestCausalSequence < before.latestCausalSequence) violations.push("causal-sequence-regressed");
|
|
133
|
+
const events = after.causalEvents.filter((event) => event.sequence > before.latestCausalSequence);
|
|
134
|
+
if (events.some((event) => event.sequence > after.latestCausalSequence)) violations.push("causal-event-after-observation");
|
|
135
|
+
if (options.concurrency === "quiescent") {
|
|
136
|
+
for (const key of EFFECT_KEYS) if (delta[key] !== 0) violations.push(`quiescent-counter-changed:${key}`);
|
|
137
|
+
if (events.some((event) => EFFECT_KEYS.some((key) => (event.effects[key] ?? 0) > 0))) violations.push("quiescent-causal-effect-observed");
|
|
138
|
+
} else {
|
|
139
|
+
const reconciled = zeroEffects();
|
|
140
|
+
for (const event of events) {
|
|
141
|
+
for (const key of EFFECT_KEYS) reconciled[key] += event.effects[key] ?? 0;
|
|
142
|
+
if (EFFECT_KEYS.some((key) => (event.effects[key] ?? 0) > 0)
|
|
143
|
+
&& (event.attribution !== "unrelated-operation" || event.correlationId === options.commandId)) violations.push(`prohibited-causal-attribution:${event.sequence}`);
|
|
144
|
+
}
|
|
145
|
+
for (const key of EFFECT_KEYS) if (reconciled[key] !== delta[key]) violations.push(`unreconciled-concurrent-delta:${key}`);
|
|
146
|
+
}
|
|
147
|
+
if (before.sessionRef !== after.sessionRef) violations.push("session-replaced");
|
|
148
|
+
const unrelatedMessagesSettled = options.concurrency === "concurrent" && delta.userMessages + delta.assistantMessages > 0;
|
|
149
|
+
if (before.leafMessageRef !== after.leafMessageRef && !unrelatedMessagesSettled) violations.push("leaf-message-changed");
|
|
150
|
+
if (before.messageSetDigest !== after.messageSetDigest && !unrelatedMessagesSettled) violations.push("message-set-changed");
|
|
151
|
+
if (before.promptDigest !== after.promptDigest) violations.push("prompt-changed");
|
|
152
|
+
if (before.toolSchemaDigest !== after.toolSchemaDigest) violations.push("provider-tool-schema-changed");
|
|
153
|
+
const allowed = new Set(options.allowedDigestChanges ?? []);
|
|
154
|
+
if (before.taskContractDigest !== after.taskContractDigest && !allowed.has("taskContractDigest")) violations.push("task-contract-changed");
|
|
155
|
+
if (before.journalHead !== after.journalHead && !allowed.has("journalHead")) violations.push("journal-head-changed");
|
|
156
|
+
if (options.mutationClass === "view" && allowed.size > 0) violations.push("view-action-cannot-allow-authoritative-mutation");
|
|
157
|
+
return { version: ZERO_TURN_CONFORMANCE_VERSION, passed: violations.length === 0, action: options.action, commandId: options.commandId,
|
|
158
|
+
concurrency: options.concurrency, mutationClass: options.mutationClass, violations: [...new Set(violations)], delta, ...(result === undefined ? {} : { result }) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function runZeroTurnConformance<T>(
|
|
162
|
+
options: ZeroTurnOptions,
|
|
163
|
+
observe: () => ZeroTurnObservation | Promise<ZeroTurnObservation>,
|
|
164
|
+
action: () => T | Promise<T>
|
|
165
|
+
): Promise<ZeroTurnReport<T>> {
|
|
166
|
+
const before = structuredClone(await observe());
|
|
167
|
+
let result: T;
|
|
168
|
+
try { result = await action(); } catch (error) {
|
|
169
|
+
const after = structuredClone(await observe());
|
|
170
|
+
const report = evaluateZeroTurn<T>(options, before, after);
|
|
171
|
+
return { ...report, passed: false, violations: [...report.violations, `action-failed:${error instanceof Error ? error.name : "unknown"}`] };
|
|
172
|
+
}
|
|
173
|
+
const after = structuredClone(await observe());
|
|
174
|
+
return evaluateZeroTurn(options, before, after, result);
|
|
175
|
+
}
|
|
@@ -7,22 +7,49 @@ import type { HelperRequest, HelperRole } from "./role-policy.ts";
|
|
|
7
7
|
import { validateHelperRequest } from "./role-policy.ts";
|
|
8
8
|
|
|
9
9
|
export const OWNED_WORK_BUDGET_VERSION = "owned-work-budget-v1" as const;
|
|
10
|
+
// `maxRepairPasses` is declared for the repair lane, which reserves no helper
|
|
11
|
+
// role here, so this mechanism never reads it. It is named rather than removed
|
|
12
|
+
// because the repair lane is bounded elsewhere; nothing in this file enforces it.
|
|
10
13
|
export type OwnedWorkCeilings = Readonly<{ maxConcurrentHelpers: number; maxTotalHelpers: number; maxScoutPasses: number; maxPlannerPasses: number; maxReviewPasses: number; maxOracleCalls: number; maxRepairPasses: number; maxWriters: number }>;
|
|
11
14
|
export const DEFAULT_OWNED_WORK_CEILINGS: OwnedWorkCeilings = Object.freeze({ maxConcurrentHelpers: 2, maxTotalHelpers: 3, maxScoutPasses: 1, maxPlannerPasses: 1, maxReviewPasses: 1, maxOracleCalls: 1, maxRepairPasses: 1, maxWriters: 1 });
|
|
12
15
|
export const AUTOMATIC_OWNED_WORK_CEILINGS: OwnedWorkCeilings = Object.freeze({ ...DEFAULT_OWNED_WORK_CEILINGS, maxConcurrentHelpers: 1, maxTotalHelpers: 1 });
|
|
13
16
|
const LOCK_WAIT_MS = 5;
|
|
14
17
|
const LOCK_WAIT_CEILING_MS = 250;
|
|
15
18
|
const STALE_LOCK_MS = 30_000;
|
|
16
|
-
type
|
|
17
|
-
type BudgetState = { version: typeof OWNED_WORK_BUDGET_VERSION; taskId: string; taskRunId: string; terminal: boolean; reservations:
|
|
18
|
-
export type
|
|
19
|
+
export type OwnedWorkReservation = { id: string; deduplicationKey: string; role: HelperRole; authority: "read-only" | "single-writer"; status: "active" | "succeeded" | "failed" | "cancelled" | "orphaned"; reservedAt: string; expiresAt: string; completedAt: string | null; usageRef: { calls: number; tokens: number; outputDigest: string | null } | null };
|
|
20
|
+
type BudgetState = { version: typeof OWNED_WORK_BUDGET_VERSION; taskId: string; taskRunId: string; terminal: boolean; reservations: OwnedWorkReservation[]; updatedAt: string };
|
|
21
|
+
export type OwnedWorkBudgetInspection = {
|
|
22
|
+
state: "missing" | "ready" | "corrupt"; terminal: boolean | null; reservations: OwnedWorkReservation[];
|
|
23
|
+
updatedAt: string | null; derivedOrphans: number; reasonCode: string | null;
|
|
24
|
+
};
|
|
25
|
+
export type HelperReleaseResult = { accepted: boolean; status: OwnedWorkReservation["status"] | "stale"; reason: "released" | "helper-call-budget-exceeded" | "helper-token-budget-exceeded" | "reservation-not-active" };
|
|
19
26
|
|
|
20
27
|
function digest(value: unknown): string { return crypto.createHash("sha256").update(String(value ?? "")).digest("hex"); }
|
|
21
28
|
function statePath(cwd: string, taskRunId: string): string { return path.join(cwd, ".pi", "piagent-state", "helper-budgets", `${safeTaskId(taskRunId)}.json`); }
|
|
22
29
|
function lockPath(cwd: string, taskRunId: string): string { return `${statePath(cwd, taskRunId)}.lock`; }
|
|
23
30
|
function empty(request: HelperRequest, now: string): BudgetState { return { version: OWNED_WORK_BUDGET_VERSION, taskId: request.taskId, taskRunId: request.taskRunId, terminal: false, reservations: [], updatedAt: now }; }
|
|
24
|
-
function active(state: BudgetState):
|
|
25
|
-
|
|
31
|
+
function active(state: BudgetState): OwnedWorkReservation[] { return state.reservations.filter((item) => item.status === "active"); }
|
|
32
|
+
// The per-role ceiling, read from the ceilings the caller passed rather than
|
|
33
|
+
// from constants written here. These limits were real but fixed at one apiece,
|
|
34
|
+
// so `OwnedWorkCeilings` advertised six numbers that nothing ever read: a
|
|
35
|
+
// profile could raise `maxScoutPasses` and be silently ignored, while the two
|
|
36
|
+
// neighbouring fields in the same object did take effect. Half-honoured
|
|
37
|
+
// configuration is worse than none, because the half that works teaches the
|
|
38
|
+
// reader to trust the half that does not.
|
|
39
|
+
//
|
|
40
|
+
// Every default is one, so this changes no behaviour under the shipped
|
|
41
|
+
// ceilings; it makes the object mean what it says.
|
|
42
|
+
function roleLimit(role: HelperRole, ceilings: OwnedWorkCeilings): number {
|
|
43
|
+
switch (role) {
|
|
44
|
+
case "scout": case "retriever": case "researcher": return ceilings.maxScoutPasses;
|
|
45
|
+
case "planner": return ceilings.maxPlannerPasses;
|
|
46
|
+
case "reviewer": return ceilings.maxReviewPasses;
|
|
47
|
+
case "oracle": return ceilings.maxOracleCalls;
|
|
48
|
+
case "worker": return ceilings.maxWriters;
|
|
49
|
+
// An unknown role reserves nothing rather than defaulting to a limit.
|
|
50
|
+
default: return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
26
53
|
|
|
27
54
|
function read(cwd: string, request: HelperRequest, now: string): BudgetState {
|
|
28
55
|
const target = statePath(cwd, request.taskRunId); if (!fs.existsSync(target)) return empty(request, now);
|
|
@@ -63,6 +90,66 @@ function recoverOrphans(state: BudgetState, now: string): number {
|
|
|
63
90
|
return recovered;
|
|
64
91
|
}
|
|
65
92
|
|
|
93
|
+
function exactTimestamp(value: unknown): value is string {
|
|
94
|
+
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
|
|
95
|
+
&& Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
96
|
+
}
|
|
97
|
+
function exactFields(value: Record<string, unknown>, fields: string[]): boolean {
|
|
98
|
+
return Object.keys(value).length === fields.length && fields.every((field) => field in value);
|
|
99
|
+
}
|
|
100
|
+
function validInspectionState(value: unknown, taskId: string, taskRunId: string): value is BudgetState {
|
|
101
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
102
|
+
const state = value as Record<string, any>;
|
|
103
|
+
if (!exactFields(state, ["version", "taskId", "taskRunId", "terminal", "reservations", "updatedAt"])
|
|
104
|
+
|| state.version !== OWNED_WORK_BUDGET_VERSION || state.taskId !== taskId || state.taskRunId !== taskRunId
|
|
105
|
+
|| typeof state.terminal !== "boolean" || !exactTimestamp(state.updatedAt) || !Array.isArray(state.reservations)
|
|
106
|
+
|| state.reservations.length > 64) return false;
|
|
107
|
+
const ids = new Set<string>(); let activeWriters = 0;
|
|
108
|
+
for (const item of state.reservations) {
|
|
109
|
+
if (!item || typeof item !== "object" || Array.isArray(item)
|
|
110
|
+
|| !exactFields(item, ["id", "deduplicationKey", "role", "authority", "status", "reservedAt", "expiresAt", "completedAt", "usageRef"])
|
|
111
|
+
|| typeof item.id !== "string" || !/^[a-f0-9]{32}$/.test(item.id) || ids.has(item.id)
|
|
112
|
+
|| typeof item.deduplicationKey !== "string" || !/^[a-f0-9]{64}$/.test(item.deduplicationKey)
|
|
113
|
+
|| !["retriever", "scout", "planner", "worker", "reviewer", "oracle", "researcher"].includes(item.role)
|
|
114
|
+
|| !["read-only", "single-writer"].includes(item.authority)
|
|
115
|
+
|| !["active", "succeeded", "failed", "cancelled", "orphaned"].includes(item.status)
|
|
116
|
+
|| !exactTimestamp(item.reservedAt) || !exactTimestamp(item.expiresAt) || Date.parse(item.expiresAt) < Date.parse(item.reservedAt)
|
|
117
|
+
|| item.completedAt !== null && !exactTimestamp(item.completedAt)
|
|
118
|
+
|| item.status === "active" && item.completedAt !== null || item.status !== "active" && item.completedAt === null) return false;
|
|
119
|
+
ids.add(item.id); if (item.status === "active" && item.authority === "single-writer") activeWriters += 1;
|
|
120
|
+
if (item.usageRef !== null) {
|
|
121
|
+
if (typeof item.usageRef !== "object" || Array.isArray(item.usageRef)
|
|
122
|
+
|| !exactFields(item.usageRef, ["calls", "tokens", "outputDigest"])
|
|
123
|
+
|| !Number.isInteger(item.usageRef.calls) || item.usageRef.calls < 0 || item.usageRef.calls > 101
|
|
124
|
+
|| !Number.isInteger(item.usageRef.tokens) || item.usageRef.tokens < 0 || item.usageRef.tokens > 100001
|
|
125
|
+
|| item.usageRef.outputDigest !== null && (typeof item.usageRef.outputDigest !== "string" || !/^[a-f0-9]{64}$/.test(item.usageRef.outputDigest))) return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return activeWriters <= 1 && (!state.terminal || !state.reservations.some((item: any) => item.status === "active"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Read-only projection input. It never repairs, locks, or writes helper state. */
|
|
132
|
+
export function inspectOwnedWorkBudget(cwd: string, taskId: string, taskRunId: string, now = new Date().toISOString()): OwnedWorkBudgetInspection {
|
|
133
|
+
try {
|
|
134
|
+
if (!exactTimestamp(now)) return { state: "corrupt", terminal: null, reservations: [], updatedAt: null, derivedOrphans: 0, reasonCode: "invalid-inspection-time" };
|
|
135
|
+
const target = statePath(cwd, taskRunId); if (!fs.existsSync(target)) return { state: "missing", terminal: null, reservations: [], updatedAt: null, derivedOrphans: 0, reasonCode: "helper-budget-missing" };
|
|
136
|
+
const resolved = resolveLocalStatePath(cwd, target, { label: "Helper budget state", kind: "file" });
|
|
137
|
+
const descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); let value;
|
|
138
|
+
try {
|
|
139
|
+
const before = fs.fstatSync(descriptor, { bigint: true }); if (!before.isFile() || before.size > BigInt(1024 * 1024)) throw new Error("helper-budget-size");
|
|
140
|
+
value = JSON.parse(fs.readFileSync(descriptor, "utf8")); const after = fs.fstatSync(descriptor, { bigint: true });
|
|
141
|
+
const fields = ["dev", "ino", "mode", "size", "mtimeNs", "ctimeNs"] as const;
|
|
142
|
+
if (fields.some((field) => before[field] !== after[field])) throw new Error("helper-budget-changed");
|
|
143
|
+
} finally { fs.closeSync(descriptor); }
|
|
144
|
+
if (!validInspectionState(value, taskId, taskRunId)) return { state: "corrupt", terminal: null, reservations: [], updatedAt: null, derivedOrphans: 0, reasonCode: "helper-budget-invalid" };
|
|
145
|
+
const clone = structuredClone(value), derivedOrphans = recoverOrphans(clone, now);
|
|
146
|
+
return { state: "ready", terminal: clone.terminal, reservations: clone.reservations, updatedAt: clone.updatedAt, derivedOrphans,
|
|
147
|
+
reasonCode: derivedOrphans ? "expired-helper-derived-orphan" : null };
|
|
148
|
+
} catch {
|
|
149
|
+
return { state: "corrupt", terminal: null, reservations: [], updatedAt: null, derivedOrphans: 0, reasonCode: "helper-budget-unreadable" };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
66
153
|
export class OwnedWorkBudgetController {
|
|
67
154
|
reserve(cwd: string, requestInput: HelperRequest, now = new Date().toISOString(), ceilings: OwnedWorkCeilings = DEFAULT_OWNED_WORK_CEILINGS): { decision: "reserved" | "duplicate" | "blocked"; reason: string; reservationId: string | null; recoveredOrphans: number } {
|
|
68
155
|
const request = validateHelperRequest(structuredClone(requestInput));
|
|
@@ -72,7 +159,7 @@ export class OwnedWorkBudgetController {
|
|
|
72
159
|
const duplicate = state.reservations.find((item) => item.deduplicationKey === request.deduplicationKey && ["active", "succeeded"].includes(item.status));
|
|
73
160
|
if (duplicate) return { decision: "duplicate", reason: "equivalent-helper-already-owned", reservationId: duplicate.id, recoveredOrphans };
|
|
74
161
|
if (active(state).length >= ceilings.maxConcurrentHelpers || state.reservations.length >= ceilings.maxTotalHelpers) return { decision: "blocked", reason: "helper-budget-exhausted", reservationId: null, recoveredOrphans };
|
|
75
|
-
if (state.reservations.filter((item) => item.role === request.role).length >= roleLimit(request.role)) return { decision: "blocked", reason: `${request.role}-ceiling-reached`, reservationId: null, recoveredOrphans };
|
|
162
|
+
if (state.reservations.filter((item) => item.role === request.role).length >= roleLimit(request.role, ceilings)) return { decision: "blocked", reason: `${request.role}-ceiling-reached`, reservationId: null, recoveredOrphans };
|
|
76
163
|
if (request.authority === "single-writer" && active(state).some((item) => item.authority === "single-writer")) return { decision: "blocked", reason: "single-writer-already-owned", reservationId: null, recoveredOrphans };
|
|
77
164
|
const id = digest(`${request.taskRunId}:${request.deduplicationKey}:${state.reservations.length}`).slice(0, 32);
|
|
78
165
|
state.reservations.push({ id, deduplicationKey: request.deduplicationKey, role: request.role, authority: request.authority, status: "active", reservedAt: now, expiresAt: new Date(Date.parse(now) + request.ceilings.timeSeconds * 1000).toISOString(), completedAt: null, usageRef: null });
|