@piagent/platform 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -3
- package/README.md +3 -3
- 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 +10 -2
- 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/herdr-workflow.md +1 -0
- package/docs/mcp-and-tools.md +14 -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 +1 -0
- package/docs/quality-benchmark.md +1 -0
- package/docs/quickstart-vietnamese.md +3 -2
- package/docs/release-install-policy.md +11 -7
- package/docs/runtime-harness-standard.md +1 -0
- 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 +1 -0
- 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 +1 -0
- package/docs/vercel-docs-site.md +1 -0
- package/docs/workflow-recipes.md +1 -0
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/index.html +1 -1
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +1 -1
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +1 -1
- package/docs-site/workflows.html +1 -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 +30 -3
- 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 +280 -0
- package/packages/piagent-core/capabilities/runtime-integrity.js +153 -1
- package/packages/piagent-core/extensions/document-intake.ts +24 -10
- package/packages/piagent-core/extensions/piagent-guard.ts +90 -73
- package/packages/piagent-core/extensions/redaction-core.d.ts +2 -0
- package/packages/piagent-core/extensions/state-retention.js +77 -44
- 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-server-catalog.js +5 -5
- 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 +5 -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 +68 -4
- 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 +13 -14
- 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-webui/dist/client/assets/index-DjMntaK5.js +172 -0
- package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +1 -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 +408 -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/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 +204 -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 +485 -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 +110 -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/check-architecture.mjs +1 -0
- package/scripts/check-doc-languages.mjs +19 -2
- package/scripts/check-third-party-neutrality.mjs +43 -0
- package/scripts/install-global.sh +1 -1
- package/scripts/piagent-cli.mjs +17 -3
- package/scripts/piagent-dashboard.mjs +208 -0
- package/scripts/piagent-webui-launcher.mjs +121 -0
- package/scripts/typescript-loader.mjs +26 -1
- package/scripts/verify-local.sh +253 -0
- package/scripts/verify-release-identity.mjs +4 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const VERIFIER_SNAPSHOT_VERSION = "piagent-verifier-file-snapshot-v1" as const;
|
|
5
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
6
|
+
const TREE = /^wt-content-v2:[a-f0-9]{64}$/;
|
|
7
|
+
const PUBLIC_ID = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
8
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
9
|
+
|
|
10
|
+
export type VerifierSnapshotFile = {
|
|
11
|
+
pathDigest: string;
|
|
12
|
+
repoPathBase64: string | null;
|
|
13
|
+
state: "exact" | "protected" | "unavailable";
|
|
14
|
+
reasonCode: string | null;
|
|
15
|
+
carrierDigest: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type VerifierFileSnapshot = {
|
|
19
|
+
schemaVersion: 1;
|
|
20
|
+
version: typeof VERIFIER_SNAPSHOT_VERSION;
|
|
21
|
+
attemptId: string;
|
|
22
|
+
attemptRef: string;
|
|
23
|
+
taskId: string;
|
|
24
|
+
taskRunId: string;
|
|
25
|
+
sessionIdentityHash: string;
|
|
26
|
+
toolCallIdentityHash: string;
|
|
27
|
+
commandDigest: string;
|
|
28
|
+
observedAt: string;
|
|
29
|
+
capturedAt: string;
|
|
30
|
+
retentionUntil: string;
|
|
31
|
+
exitCode: number;
|
|
32
|
+
outcome: "passed" | "failed";
|
|
33
|
+
treeDigest: string;
|
|
34
|
+
fileCount: number;
|
|
35
|
+
exposedPathCount: number;
|
|
36
|
+
files: VerifierSnapshotFile[];
|
|
37
|
+
integrityDigest: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function plain(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
42
|
+
const prototype = Object.getPrototypeOf(value);
|
|
43
|
+
return prototype === Object.prototype || prototype === null;
|
|
44
|
+
}
|
|
45
|
+
function canonical(value: unknown): unknown {
|
|
46
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
47
|
+
if (!plain(value)) return value;
|
|
48
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
49
|
+
}
|
|
50
|
+
function hash(value: string): string { return createHash("sha256").update(value).digest("hex"); }
|
|
51
|
+
function exactKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
|
52
|
+
const actual = Object.keys(value).sort(), expected = [...keys].sort();
|
|
53
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function verifierSnapshotIdentity(value: Pick<VerifierFileSnapshot,
|
|
57
|
+
"taskRunId" | "toolCallIdentityHash" | "commandDigest" | "observedAt" | "treeDigest" | "exitCode">): string {
|
|
58
|
+
const identity = { taskRunId: value.taskRunId, toolCallIdentityHash: value.toolCallIdentityHash,
|
|
59
|
+
commandDigest: value.commandDigest, observedAt: value.observedAt, treeDigest: value.treeDigest, exitCode: value.exitCode };
|
|
60
|
+
return hash(`verifier-file-snapshot-identity-v1\0${JSON.stringify(canonical(identity))}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function verifierSnapshotDigest(value: Omit<VerifierFileSnapshot, "integrityDigest"> | VerifierFileSnapshot): string {
|
|
64
|
+
const { integrityDigest: _ignored, ...payload } = value as VerifierFileSnapshot;
|
|
65
|
+
return `sha256:${hash(`verifier-file-snapshot-v1\0${JSON.stringify(canonical(payload))}`)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function decodeVerifierSnapshotPath(file: VerifierSnapshotFile): string | null {
|
|
69
|
+
if (!file.repoPathBase64) return null;
|
|
70
|
+
const value = Buffer.from(file.repoPathBase64, "base64url").toString("utf8");
|
|
71
|
+
return value && !path.posix.isAbsolute(value) && !path.win32.isAbsolute(value)
|
|
72
|
+
&& !value.split("/").includes("..") && !value.includes("\0") ? value : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validFile(value: unknown): value is VerifierSnapshotFile {
|
|
76
|
+
if (!plain(value) || !exactKeys(value, ["pathDigest", "repoPathBase64", "state", "reasonCode", "carrierDigest"])) return false;
|
|
77
|
+
if (!DIGEST.test(String(value.pathDigest)) || !TREE.test(String(value.carrierDigest))) return false;
|
|
78
|
+
if (!["exact", "protected", "unavailable"].includes(String(value.state))) return false;
|
|
79
|
+
if (value.state === "exact") {
|
|
80
|
+
if (typeof value.repoPathBase64 !== "string" || !value.repoPathBase64 || value.repoPathBase64.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(value.repoPathBase64) || value.reasonCode !== null) return false;
|
|
81
|
+
const decoded = decodeVerifierSnapshotPath(value as VerifierSnapshotFile);
|
|
82
|
+
return Boolean(decoded && value.pathDigest === `sha256:${hash(decoded as string)}`);
|
|
83
|
+
}
|
|
84
|
+
return value.repoPathBase64 === null && typeof value.reasonCode === "string" && /^[a-z0-9][a-z0-9.-]{0,95}$/.test(value.reasonCode);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function verifierSnapshotErrors(value: unknown): string[] {
|
|
88
|
+
const keys = ["schemaVersion", "version", "attemptId", "attemptRef", "taskId", "taskRunId", "sessionIdentityHash", "toolCallIdentityHash", "commandDigest", "observedAt", "capturedAt", "retentionUntil", "exitCode", "outcome", "treeDigest", "fileCount", "exposedPathCount", "files", "integrityDigest"];
|
|
89
|
+
if (!plain(value) || !exactKeys(value, keys)) return ["snapshot shape is invalid"];
|
|
90
|
+
const errors: string[] = [];
|
|
91
|
+
if (value.schemaVersion !== 1 || value.version !== VERIFIER_SNAPSHOT_VERSION) errors.push("snapshot version is invalid");
|
|
92
|
+
if (!PUBLIC_ID.test(String(value.taskId)) || !PUBLIC_ID.test(String(value.taskRunId))) errors.push("task identity is invalid");
|
|
93
|
+
if (![value.sessionIdentityHash, value.toolCallIdentityHash, value.commandDigest, value.integrityDigest].every((item) => DIGEST.test(String(item)))) errors.push("digest identity is invalid");
|
|
94
|
+
const observed = Date.parse(String(value.observedAt)), captured = Date.parse(String(value.capturedAt)), retention = Date.parse(String(value.retentionUntil));
|
|
95
|
+
if (![value.observedAt, value.capturedAt, value.retentionUntil].every((item) => TIMESTAMP.test(String(item)))
|
|
96
|
+
|| ![observed, captured, retention].every(Number.isFinite) || captured < observed || retention <= captured
|
|
97
|
+
|| retention - captured > 365 * 24 * 60 * 60 * 1000) errors.push("snapshot timestamps are invalid");
|
|
98
|
+
if (!Number.isInteger(value.exitCode) || (value.exitCode as number) < -2147483648 || (value.exitCode as number) > 2147483647
|
|
99
|
+
|| (value.outcome === "passed" ? value.exitCode !== 0 : value.outcome !== "failed" || value.exitCode === 0)) errors.push("verifier outcome is invalid");
|
|
100
|
+
if (!TREE.test(String(value.treeDigest))) errors.push("tree digest is invalid");
|
|
101
|
+
if (!Array.isArray(value.files) || value.files.length > 2000 || !value.files.every(validFile)
|
|
102
|
+
|| value.fileCount !== value.files.length || value.exposedPathCount !== value.files.filter((file) => (file as VerifierSnapshotFile).state === "exact").length) errors.push("file snapshot is invalid");
|
|
103
|
+
else if (new Set(value.files.map((file) => (file as VerifierSnapshotFile).pathDigest)).size !== value.files.length) errors.push("file snapshot has duplicate paths");
|
|
104
|
+
const identity = verifierSnapshotIdentity(value as VerifierFileSnapshot);
|
|
105
|
+
if (value.attemptId !== `verifier.${identity}` || value.attemptRef !== `verifier-evidence.${identity}`) errors.push("attempt identity is invalid");
|
|
106
|
+
if (value.integrityDigest !== verifierSnapshotDigest(value as VerifierFileSnapshot)) errors.push("snapshot integrity is invalid");
|
|
107
|
+
return errors;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function validateVerifierFileSnapshot(value: unknown): VerifierFileSnapshot {
|
|
111
|
+
const errors = verifierSnapshotErrors(value);
|
|
112
|
+
if (errors.length) throw new Error(`Invalid verifier file snapshot: ${errors.join("; ")}`);
|
|
113
|
+
return structuredClone(value) as VerifierFileSnapshot;
|
|
114
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { ensurePrivateStateDirectory, resolveLocalStatePath } from "../../extensions/local-state-path.js";
|
|
6
|
+
import { matchesProtectedPath } from "../../extensions/policy-core.js";
|
|
7
|
+
import { workingTreeEvidenceDigest, workingTreeSnapshotUsesCurrentAlgorithm } from "../../extensions/working-tree-digest.js";
|
|
8
|
+
import { projectGitPath } from "./git-status-adapter.ts";
|
|
9
|
+
import { readTaskBaselineManifest } from "./source-evidence-store.ts";
|
|
10
|
+
import {
|
|
11
|
+
VERIFIER_SNAPSHOT_VERSION,
|
|
12
|
+
decodeVerifierSnapshotPath,
|
|
13
|
+
validateVerifierFileSnapshot,
|
|
14
|
+
verifierSnapshotDigest,
|
|
15
|
+
verifierSnapshotIdentity,
|
|
16
|
+
type VerifierFileSnapshot
|
|
17
|
+
} from "./verifier-snapshot-contract.ts";
|
|
18
|
+
|
|
19
|
+
const MAX_ATTEMPTS = 200;
|
|
20
|
+
const MAX_FILES = 2_000;
|
|
21
|
+
const MAX_RECORD_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
export type CaptureVerifierSnapshotOptions = {
|
|
25
|
+
projectRoot: string;
|
|
26
|
+
taskId: string;
|
|
27
|
+
taskRunId: string;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
toolCallId: string;
|
|
30
|
+
commandHash: string;
|
|
31
|
+
observedAt: string;
|
|
32
|
+
capturedAt: string;
|
|
33
|
+
exitCode: number;
|
|
34
|
+
treeDigest: string;
|
|
35
|
+
snapshot: Record<string, string>;
|
|
36
|
+
protectedPaths?: string[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type VerifierStaleness = {
|
|
40
|
+
state: "current" | "stale" | "unknown";
|
|
41
|
+
attemptRef: string | null;
|
|
42
|
+
invalidatedByFiles: string[];
|
|
43
|
+
invalidatedPathDigests: string[];
|
|
44
|
+
filesKnown: boolean;
|
|
45
|
+
truncated: boolean;
|
|
46
|
+
reasonCode: string | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function hash(value: string | Buffer): string { return createHash("sha256").update(value).digest("hex"); }
|
|
50
|
+
function runDirectory(root: string, run: string): string {
|
|
51
|
+
return path.join(root, ".pi", "piagent-state", "source-evidence", `run-${hash(run)}`, "verifiers");
|
|
52
|
+
}
|
|
53
|
+
function safePath(value: string): boolean {
|
|
54
|
+
return Boolean(value && value.length <= 1536 && !path.posix.isAbsolute(value) && !path.win32.isAbsolute(value)
|
|
55
|
+
&& !value.split("/").includes("..") && !value.includes("\0"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readPrivate(root: string, file: string): Buffer {
|
|
59
|
+
const safe = resolveLocalStatePath(root, file, { label: "Verifier snapshot", kind: "file" });
|
|
60
|
+
const descriptor = fs.openSync(safe, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
61
|
+
try {
|
|
62
|
+
const stat = fs.fstatSync(descriptor);
|
|
63
|
+
if (!stat.isFile() || stat.size > MAX_RECORD_BYTES) throw new Error("Verifier snapshot is oversized or not regular");
|
|
64
|
+
return fs.readFileSync(descriptor);
|
|
65
|
+
} finally { fs.closeSync(descriptor); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function publishPrivate(root: string, target: string, content: Buffer): void {
|
|
69
|
+
const parent = ensurePrivateStateDirectory(root, path.dirname(target), "Verifier snapshot directory");
|
|
70
|
+
const safeTarget = resolveLocalStatePath(root, target, { label: "Verifier snapshot" });
|
|
71
|
+
const temporary = path.join(parent, `${path.basename(target)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
72
|
+
const descriptor = fs.openSync(temporary, "wx", 0o600);
|
|
73
|
+
try { fs.writeFileSync(descriptor, content); fs.fsyncSync(descriptor); fs.fchmodSync(descriptor, 0o600); }
|
|
74
|
+
finally { fs.closeSync(descriptor); }
|
|
75
|
+
try { fs.linkSync(temporary, safeTarget); }
|
|
76
|
+
catch (error) {
|
|
77
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
78
|
+
if (!readPrivate(root, safeTarget).equals(content)) throw new Error("Verifier snapshot identity collision");
|
|
79
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
80
|
+
try { fs.chmodSync(parent, 0o700); fs.chmodSync(safeTarget, 0o600); } catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function captureVerifierFileSnapshot(options: CaptureVerifierSnapshotOptions): VerifierFileSnapshot | undefined {
|
|
84
|
+
if (!/^[a-f0-9]{64}$/.test(options.commandHash) || !options.toolCallId
|
|
85
|
+
|| !workingTreeSnapshotUsesCurrentAlgorithm(options.snapshot)
|
|
86
|
+
|| workingTreeEvidenceDigest(options.snapshot) !== options.treeDigest) return undefined;
|
|
87
|
+
const entries = Object.entries(options.snapshot).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
88
|
+
if (entries.length > MAX_FILES) return undefined;
|
|
89
|
+
let manifest;
|
|
90
|
+
try { manifest = readTaskBaselineManifest(options.projectRoot, options.taskRunId); }
|
|
91
|
+
catch { return undefined; }
|
|
92
|
+
if (manifest && (manifest.taskId !== options.taskId || Date.parse(manifest.retentionUntil) <= Date.parse(options.capturedAt))) return undefined;
|
|
93
|
+
const files = entries.map(([repoPath, carrierDigest]) => {
|
|
94
|
+
const pathDigest = `sha256:${hash(repoPath)}`;
|
|
95
|
+
if (matchesProtectedPath(repoPath, options.protectedPaths ?? [])) {
|
|
96
|
+
return { pathDigest, repoPathBase64: null, state: "protected" as const, reasonCode: "protected-path", carrierDigest };
|
|
97
|
+
}
|
|
98
|
+
if (!safePath(repoPath)) return { pathDigest, repoPathBase64: null, state: "unavailable" as const, reasonCode: "path-unavailable", carrierDigest };
|
|
99
|
+
return { pathDigest, repoPathBase64: Buffer.from(repoPath, "utf8").toString("base64url"), state: "exact" as const, reasonCode: null, carrierDigest };
|
|
100
|
+
});
|
|
101
|
+
const commandDigest = `sha256:${options.commandHash}`;
|
|
102
|
+
const toolCallIdentityHash = `sha256:${hash(`tool-call\0${options.toolCallId}`)}`;
|
|
103
|
+
const identity = verifierSnapshotIdentity({ taskRunId: options.taskRunId, toolCallIdentityHash, commandDigest,
|
|
104
|
+
observedAt: options.observedAt, treeDigest: options.treeDigest, exitCode: options.exitCode });
|
|
105
|
+
const payload: Omit<VerifierFileSnapshot, "integrityDigest"> = {
|
|
106
|
+
schemaVersion: 1, version: VERIFIER_SNAPSHOT_VERSION,
|
|
107
|
+
attemptId: `verifier.${identity}`, attemptRef: `verifier-evidence.${identity}`,
|
|
108
|
+
taskId: options.taskId, taskRunId: options.taskRunId,
|
|
109
|
+
sessionIdentityHash: `sha256:${hash(`session\0${options.sessionId}`)}`, toolCallIdentityHash,
|
|
110
|
+
commandDigest, observedAt: options.observedAt, capturedAt: options.capturedAt,
|
|
111
|
+
retentionUntil: manifest?.retentionUntil ?? new Date(Date.parse(options.capturedAt) + DEFAULT_RETENTION_MS).toISOString(),
|
|
112
|
+
exitCode: options.exitCode, outcome: options.exitCode === 0 ? "passed" : "failed", treeDigest: options.treeDigest,
|
|
113
|
+
fileCount: files.length, exposedPathCount: files.filter((file) => file.state === "exact").length, files
|
|
114
|
+
};
|
|
115
|
+
const record = validateVerifierFileSnapshot({ ...payload, integrityDigest: verifierSnapshotDigest(payload) });
|
|
116
|
+
const directory = runDirectory(options.projectRoot, options.taskRunId);
|
|
117
|
+
let count = 0;
|
|
118
|
+
try { count = fs.readdirSync(resolveLocalStatePath(options.projectRoot, directory, { label: "Verifier snapshot", kind: "directory" })).filter((file) => file.endsWith(".json")).length; }
|
|
119
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
120
|
+
const target = path.join(directory, `${record.attemptId}.json`);
|
|
121
|
+
if (count >= MAX_ATTEMPTS && !fs.existsSync(target)) throw new Error("Verifier snapshot quota is exhausted");
|
|
122
|
+
const bytes = Buffer.from(`${JSON.stringify(record, null, 2)}\n`);
|
|
123
|
+
if (bytes.length > MAX_RECORD_BYTES) return undefined;
|
|
124
|
+
publishPrivate(options.projectRoot, target, bytes);
|
|
125
|
+
return record;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readVerifierFileSnapshots(root: string, taskRunId: string): { records: VerifierFileSnapshot[]; corruptions: string[] } {
|
|
129
|
+
const directory = runDirectory(root, taskRunId);
|
|
130
|
+
let files: string[];
|
|
131
|
+
try { files = fs.readdirSync(resolveLocalStatePath(root, directory, { label: "Verifier snapshot", kind: "directory" })).filter((file) => file.endsWith(".json")).sort(); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { records: [], corruptions: [] };
|
|
134
|
+
return { records: [], corruptions: ["verifier-snapshot-directory-unavailable"] };
|
|
135
|
+
}
|
|
136
|
+
if (files.length > MAX_ATTEMPTS) return { records: [], corruptions: ["verifier-snapshot-limit"] };
|
|
137
|
+
const records: VerifierFileSnapshot[] = [], corruptions: string[] = [];
|
|
138
|
+
for (const file of files) {
|
|
139
|
+
try {
|
|
140
|
+
const record = validateVerifierFileSnapshot(JSON.parse(readPrivate(root, path.join(directory, file)).toString("utf8")));
|
|
141
|
+
if (file !== `${record.attemptId}.json` || record.taskRunId !== taskRunId) throw new Error("identity mismatch");
|
|
142
|
+
records.push(record);
|
|
143
|
+
} catch { corruptions.push(`snapshot.${hash(file)}`); }
|
|
144
|
+
}
|
|
145
|
+
return { records: corruptions.length ? [] : records.sort((a, b) => a.observedAt.localeCompare(b.observedAt) || a.attemptId.localeCompare(b.attemptId)), corruptions };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function findVerifierFileSnapshot(
|
|
149
|
+
records: VerifierFileSnapshot[],
|
|
150
|
+
match: { commandDigest: string; observedAt: string; treeDigest: string; exitCode: number }
|
|
151
|
+
): VerifierFileSnapshot | undefined {
|
|
152
|
+
return [...records].reverse().find((record) => record.commandDigest === match.commandDigest
|
|
153
|
+
&& record.observedAt === match.observedAt && record.treeDigest === match.treeDigest && record.exitCode === match.exitCode);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function inspectVerifierStaleness(
|
|
157
|
+
record: VerifierFileSnapshot | undefined,
|
|
158
|
+
currentSnapshot: Record<string, string>,
|
|
159
|
+
protectedPaths: string[] | null = null,
|
|
160
|
+
at = new Date()
|
|
161
|
+
): VerifierStaleness {
|
|
162
|
+
if (record && at.getTime() >= Date.parse(record.retentionUntil)) {
|
|
163
|
+
return { state: "unknown", attemptRef: record.attemptRef, invalidatedByFiles: [], invalidatedPathDigests: [], filesKnown: false, truncated: false, reasonCode: "verifier-file-snapshot-expired" };
|
|
164
|
+
}
|
|
165
|
+
if (!record || !workingTreeSnapshotUsesCurrentAlgorithm(currentSnapshot)) {
|
|
166
|
+
return { state: "unknown", attemptRef: record?.attemptRef ?? null, invalidatedByFiles: [], invalidatedPathDigests: [], filesKnown: false, truncated: false, reasonCode: record ? "current-tree-unavailable" : "verifier-file-snapshot-unavailable" };
|
|
167
|
+
}
|
|
168
|
+
const currentTree = workingTreeEvidenceDigest(currentSnapshot);
|
|
169
|
+
if (currentTree === record.treeDigest) return { state: "current", attemptRef: record.attemptRef, invalidatedByFiles: [], invalidatedPathDigests: [], filesKnown: true, truncated: false, reasonCode: null };
|
|
170
|
+
const prior = new Map(record.files.map((file) => [file.pathDigest, file]));
|
|
171
|
+
const current = new Map(Object.entries(currentSnapshot).map(([repoPath, carrierDigest]) => [`sha256:${hash(repoPath)}`, { repoPath, carrierDigest }]));
|
|
172
|
+
const changed = [...new Set([...prior.keys(), ...current.keys()])].filter((digest) => prior.get(digest)?.carrierDigest !== current.get(digest)?.carrierDigest).sort();
|
|
173
|
+
const visible: string[] = [];
|
|
174
|
+
let hidden = 0;
|
|
175
|
+
for (const pathDigest of changed) {
|
|
176
|
+
const old = prior.get(pathDigest), now = current.get(pathDigest);
|
|
177
|
+
const decoded = old ? decodeVerifierSnapshotPath(old) : now?.repoPath ?? null;
|
|
178
|
+
if (!decoded || (old && old.state !== "exact") || (!old && protectedPaths === null)
|
|
179
|
+
|| (protectedPaths !== null && matchesProtectedPath(decoded, protectedPaths))) { hidden += 1; continue; }
|
|
180
|
+
const display = projectGitPath(decoded).display;
|
|
181
|
+
if (!display) hidden += 1;
|
|
182
|
+
else visible.push(display);
|
|
183
|
+
}
|
|
184
|
+
visible.sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
185
|
+
const returned = visible.slice(0, 300), truncated = returned.length < visible.length;
|
|
186
|
+
return { state: "stale", attemptRef: record.attemptRef, invalidatedByFiles: returned,
|
|
187
|
+
invalidatedPathDigests: changed.slice(0, 300), filesKnown: hidden === 0 && !truncated,
|
|
188
|
+
truncated, reasonCode: hidden > 0 ? "invalidated-files-partially-hidden" : truncated ? "invalidated-files-truncated" : null };
|
|
189
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { TaskContract } from "../../extensions/guard-types.ts";
|
|
4
|
+
import { matchesProtectedPath } from "../../extensions/policy-core.js";
|
|
5
|
+
import { redactSensitiveText } from "../../extensions/redaction-core.js";
|
|
6
|
+
import { workingTreeSnapshot } from "../../extensions/task-state.js";
|
|
7
|
+
import { workingTreeEvidenceDigest, workingTreeSnapshotUsesCurrentAlgorithm } from "../../extensions/working-tree-digest.js";
|
|
8
|
+
import { inspectTaskContinuationBudget } from "../recovery/continuation-budget.ts";
|
|
9
|
+
import { readHandoffProjection } from "../recovery/handoff-projection.ts";
|
|
10
|
+
import type { ActivityInspectorEvent, CurrentActivity } from "../product/activity-inspector.ts";
|
|
11
|
+
import { projectCriteriaFileVerifier, type CriteriaLinkProjection } from "./criteria-links.ts";
|
|
12
|
+
import { collectSourceChangeViews, type SourceChangeDocument, type WebUiIdentity } from "./source-change-projection.ts";
|
|
13
|
+
|
|
14
|
+
export const WEBUI_SNAPSHOT_VERSION = "piagent-webui-snapshot-v1" as const;
|
|
15
|
+
export const WEBUI_RUNTIME_INSTANCE_REF = `runtime.${createHash("sha256").update(`${process.pid}\0${Date.now()}\0${process.cwd()}`).digest("hex")}`;
|
|
16
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
17
|
+
|
|
18
|
+
type UsageTotals = { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number };
|
|
19
|
+
type WebUiRuntimeModel = {
|
|
20
|
+
provider?: unknown; id?: unknown; name?: unknown; reasoning?: unknown; input?: unknown;
|
|
21
|
+
thinkingLevelMap?: unknown; contextWindow?: unknown; maxTokens?: unknown;
|
|
22
|
+
};
|
|
23
|
+
export type WebUiInspectionProjection = {
|
|
24
|
+
snapshot: Record<string, any>;
|
|
25
|
+
sourceViews: CriteriaLinkProjection["sourceViews"];
|
|
26
|
+
relations: CriteriaLinkProjection["relations"];
|
|
27
|
+
verifierAttempts: CriteriaLinkProjection["verifierAttempts"];
|
|
28
|
+
scopedEvents: ActivityInspectorEvent[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function hash(value: string): string { return createHash("sha256").update(value).digest("hex"); }
|
|
32
|
+
function token(prefix: string, value: unknown): string { return `${prefix}.${hash(JSON.stringify(value))}`; }
|
|
33
|
+
export function webUiProjectRef(cwd: string): string { return token("project", cwd); }
|
|
34
|
+
export function webUiSessionRef(sessionId: string): string { return token("session", sessionId); }
|
|
35
|
+
export function webUiModelRef(provider: string, modelId: string): string { return token("model", [provider, modelId]); }
|
|
36
|
+
export function webUiTaskRevision(task: Pick<TaskContract, "taskRunId" | "updatedAt" | "trace">): string {
|
|
37
|
+
return token("task-rev", [task.taskRunId, task.updatedAt, task.trace.outcome]);
|
|
38
|
+
}
|
|
39
|
+
export function webUiControlRevision(taskRunId: string, controlState: "active" | "terminal"): string {
|
|
40
|
+
return token("control-rev", [taskRunId, controlState]);
|
|
41
|
+
}
|
|
42
|
+
function timestamp(value: unknown, fallback = new Date().toISOString()): string {
|
|
43
|
+
return typeof value === "string" && TIMESTAMP.test(value) ? value : fallback;
|
|
44
|
+
}
|
|
45
|
+
function strings(values: unknown[], maximum = 300): string[] {
|
|
46
|
+
return [...new Set(values.filter((value): value is string => typeof value === "string" && value.trim().length > 0).map((value) => value.trim()))].slice(0, maximum);
|
|
47
|
+
}
|
|
48
|
+
function display(value: unknown, maximum = 500): string {
|
|
49
|
+
return redactSensitiveText(String(value ?? "")).text
|
|
50
|
+
.replace(/\u001b(?:[@-_]|\[[0-?]*[ -/]*[@-~])/g, "")
|
|
51
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
52
|
+
.replace(/\s+/g, " ").trim().slice(0, maximum);
|
|
53
|
+
}
|
|
54
|
+
function number(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0; }
|
|
55
|
+
function cost(value: unknown): number {
|
|
56
|
+
if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, value);
|
|
57
|
+
return value && typeof value === "object" ? number((value as Record<string, unknown>).total) : 0;
|
|
58
|
+
}
|
|
59
|
+
function usage(entries: unknown[]): { totals: UsageTotals; latest: UsageTotals | null; observed: boolean } {
|
|
60
|
+
const totals: UsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
61
|
+
let latest: UsageTotals | null = null, observed = false;
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (!entry || typeof entry !== "object") continue;
|
|
64
|
+
const message = (entry as any).type === "message" ? (entry as any).message : undefined;
|
|
65
|
+
const raw = message?.usage ?? (entry as any).usage;
|
|
66
|
+
if (!raw || (message && !["assistant", "toolResult"].includes(message.role))) continue;
|
|
67
|
+
const current = { input: number(raw.input), output: number(raw.output), cacheRead: number(raw.cacheRead), cacheWrite: number(raw.cacheWrite), cost: cost(raw.cost) };
|
|
68
|
+
observed = true;
|
|
69
|
+
for (const key of Object.keys(totals) as Array<keyof UsageTotals>) totals[key] += current[key];
|
|
70
|
+
if (message?.role === "assistant") latest = current;
|
|
71
|
+
}
|
|
72
|
+
return { totals, latest, observed };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function scopedEvents(events: ActivityInspectorEvent[], sessionId: string, task?: TaskContract): ActivityInspectorEvent[] {
|
|
76
|
+
const deduped = new Map<string, ActivityInspectorEvent>();
|
|
77
|
+
for (const event of events) {
|
|
78
|
+
if (String(event.sessionId ?? "") !== sessionId || (task ? event.taskRunId !== task.taskRunId : Boolean(event.taskRunId))) continue;
|
|
79
|
+
const key = event.activityId ?? [event.event, event.toolCallId, event.recordedAt, event.toolName].join(":");
|
|
80
|
+
deduped.set(key, event);
|
|
81
|
+
}
|
|
82
|
+
return [...deduped.values()].sort((left, right) => String(left.recordedAt).localeCompare(String(right.recordedAt))).slice(-2_000);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sourceSummary(document: SourceChangeDocument | null, view: "task" | "working-tree" | "staged") {
|
|
86
|
+
if (!document || document.availability.state === "unavailable") return {
|
|
87
|
+
view, base: view === "task" ? "task-baseline" : "HEAD", state: "unavailable", revision: null,
|
|
88
|
+
counts: { files: 0, added: 0, modified: 0, deleted: 0, renamed: 0, untracked: 0, conflicted: 0, additions: null, deletions: null },
|
|
89
|
+
health: { state: "unavailable", reasonCode: document?.availability.reasonCode ?? "no-active-task", message: document?.availability.message ?? "No active task baseline exists" }
|
|
90
|
+
};
|
|
91
|
+
const files = document.files as Array<Record<string, any>>;
|
|
92
|
+
const truncated = document.page.truncated;
|
|
93
|
+
const statsExact = !truncated && files.every((file) => file.stats?.state === "exact");
|
|
94
|
+
const count = (status: string) => files.filter((file) => file.status === status).length;
|
|
95
|
+
const additions = statsExact ? files.reduce((sum, file) => sum + number(file.stats.additions), 0) : null;
|
|
96
|
+
const deletions = statsExact ? files.reduce((sum, file) => sum + number(file.stats.deletions), 0) : null;
|
|
97
|
+
return {
|
|
98
|
+
view, base: view === "task" ? "task-baseline" : "HEAD", state: document.availability.state === "stale" ? "stale" : "ready", revision: document.viewRevision,
|
|
99
|
+
counts: { files: document.page.total, added: count("A"), modified: count("M"), deleted: count("D"), renamed: count("R"), untracked: count("U"), conflicted: count("C"),
|
|
100
|
+
additions: additions !== null && additions <= 1_000_000_000 ? additions : null,
|
|
101
|
+
deletions: deletions !== null && deletions <= 1_000_000_000 ? deletions : null },
|
|
102
|
+
health: document.availability.state === "stale"
|
|
103
|
+
? { state: "degraded", reasonCode: document.availability.reasonCode ?? "source-view-stale", message: document.availability.message ?? "Source view is stale" }
|
|
104
|
+
: truncated ? { state: "degraded", reasonCode: "source-view-truncated", message: "Source view totals exceed the bounded file list" }
|
|
105
|
+
: { state: "ok", reasonCode: null, message: null }
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function activityProjection(events: ActivityInspectorEvent[], current: CurrentActivity[]) {
|
|
110
|
+
const results = new Map(events.filter((event) => event.event === "tool_result").map((event) => [event.toolCallId, event]));
|
|
111
|
+
const decisions = new Map(events.filter((event) => event.event === "tool_decision").map((event) => [event.toolCallId, event]));
|
|
112
|
+
const calls = events.filter((event) => event.event === "tool_call").slice(-200);
|
|
113
|
+
const projected = calls.map((call) => {
|
|
114
|
+
const result = results.get(call.toolCallId), decision = decisions.get(call.toolCallId);
|
|
115
|
+
const blocked = decision?.decision === "blocked";
|
|
116
|
+
const failed = !blocked && Boolean(result && (result.isError === true || (typeof result.exitCode === "number" && result.exitCode !== 0)));
|
|
117
|
+
const state = blocked ? "blocked" : !result ? "running" : failed ? "failed" : "passed";
|
|
118
|
+
const toolName = display(call.toolName ?? "unknown", 80).replace(/[^A-Za-z0-9._:@~-]/g, "-") || "unknown";
|
|
119
|
+
const rawId = String(call.toolCallId ?? call.activityId ?? `${call.recordedAt}:${toolName}`);
|
|
120
|
+
const command = display(call.command ?? call.targetPath ?? toolName, 65_536);
|
|
121
|
+
return {
|
|
122
|
+
activityRef: token("activity", [rawId, call.recordedAt]), kind: ["bash", "shell", "exec"].includes(toolName) ? "command" : "tool", state,
|
|
123
|
+
label: display(state === "running" ? `${toolName} running` : `${toolName} ${state}`, 500), preview: command,
|
|
124
|
+
toolCallId: token("tool", rawId), toolName, commandDigest: call.command ? `sha256:${hash(call.command)}` : null,
|
|
125
|
+
logRef: null, exitCode: typeof result?.exitCode === "number" ? result.exitCode : null, exitCodeExact: result?.exitCodeExact === true,
|
|
126
|
+
startedAt: timestamp(call.recordedAt), finishedAt: result || blocked ? timestamp(result?.recordedAt ?? decision?.recordedAt, timestamp(call.recordedAt)) : null
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
const callIds = new Set(calls.map((call) => String(call.toolCallId ?? "")));
|
|
130
|
+
const extraRunning = current.filter((item) => (item.status ?? "running") === "running" && !callIds.has(String(item.toolCallId))).map((item) => ({
|
|
131
|
+
activityRef: token("activity", [item.toolCallId, item.startedAt]), kind: ["bash", "shell", "exec"].includes(item.toolName) ? "command" : "tool", state: "running",
|
|
132
|
+
label: display(item.label || `${item.toolName} running`, 500), preview: display(item.target ?? item.toolName, 65_536),
|
|
133
|
+
toolCallId: token("tool", item.toolCallId), toolName: display(item.toolName, 80).replace(/[^A-Za-z0-9._:@~-]/g, "-") || "unknown",
|
|
134
|
+
commandDigest: null, logRef: null, exitCode: null, exitCodeExact: false, startedAt: timestamp(item.startedAt), finishedAt: null
|
|
135
|
+
}));
|
|
136
|
+
const running = [...projected.filter((item) => item.state === "running"), ...extraRunning];
|
|
137
|
+
const recent = projected.filter((item) => item.state !== "running").reverse();
|
|
138
|
+
const returned = Math.min(running.length, 32) + Math.min(recent.length, 200);
|
|
139
|
+
return { running: running.slice(-32), recent: recent.slice(0, 200), page: { cursor: null, nextCursor: null, total: projected.length + extraRunning.length, returned, truncated: projected.length + extraRunning.length > returned }, health: { state: "ok", reasonCode: null, message: null } };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function unavailableCapability(code: string, message: string) {
|
|
143
|
+
return { status: "unavailable", version: null, reason: { code, message } };
|
|
144
|
+
}
|
|
145
|
+
function capabilities(identity: WebUiIdentity, generatedAt: string, resyncRequired: boolean, replay?: { eventRetentionCount: number; eventRetentionSeconds: number }) {
|
|
146
|
+
return {
|
|
147
|
+
schemaVersion: 1, version: "piagent-webui-capabilities-v1", generatedAt, protocolMin: 1, protocolMax: 1,
|
|
148
|
+
supportedSnapshotVersions: [1], supportedEventVersions: [2], mode: "inspect-only",
|
|
149
|
+
compatibility: resyncRequired ? { state: "resync-required", reason: { code: "event-replay-gap", message: "A fresh canonical snapshot is required before event replay can continue" } } : { state: "compatible", reason: null }, identity,
|
|
150
|
+
runtimeBuild: { state: "unavailable", buildId: null, version: null, reason: { code: "runtime-build-not-projected", message: "Runtime build identity is not projected" } },
|
|
151
|
+
serverBuild: { state: "unavailable", buildId: null, version: null, reason: { code: "webui-server-not-started", message: "The WebUI server is not running" } },
|
|
152
|
+
capabilities: {
|
|
153
|
+
inspect: { status: "available", version: 1, reason: null, sourceViews: ["task", "working-tree", "staged"] },
|
|
154
|
+
"control.chat": unavailableCapability("chat-control-not-enabled", "The same-session bridge exists, but transcript, queue, server control and browser UX are not enabled"),
|
|
155
|
+
"control.lifecycle": unavailableCapability("lifecycle-contract-incomplete", "Pi 0.84.1 lacks semantic Pause and an acknowledged phase-complete Stop contract"),
|
|
156
|
+
"control.resumeAndContinue": unavailableCapability("compound-control-unavailable", "Resume is unavailable until a durable acknowledged Pause contract exists"),
|
|
157
|
+
"control.sessionOptions": unavailableCapability("effect-scope-not-productionized", "Model and thinking APIs exist but lifecycle and persistence scope enforcement is not enabled"),
|
|
158
|
+
attachments: unavailableCapability("attachments-not-implemented", "Attachments are not implemented"),
|
|
159
|
+
approve: unavailableCapability("approval-broker-not-implemented", "Approval broker is not implemented"),
|
|
160
|
+
reviewActions: unavailableCapability("review-actions-not-implemented", "Review actions are not implemented")
|
|
161
|
+
},
|
|
162
|
+
replay: { eventRetentionCount: replay?.eventRetentionCount ?? 0, eventRetentionSeconds: replay?.eventRetentionSeconds ?? 0, resyncSupported: true },
|
|
163
|
+
limits: { maxRequestBodyBytes: 1_048_576, maxEventPayloadBytes: 65_536, maxReplayEvents: 10_000, maxDiffBytes: 4_194_304,
|
|
164
|
+
maxDiffLines: 5_000, maxDiffHunks: 256, maxLogPreviewBytes: 65_536, maxLogPreviewLines: 500, maxAttachmentCount: 16,
|
|
165
|
+
maxAttachmentFileBytes: 10_485_760, maxAttachmentTotalBytes: 33_554_432, maxQueueItems: 100, maxMessageBytes: 65_536,
|
|
166
|
+
maxSseClients: 8, maxGitProcesses: 4, requestTimeoutMs: 30_000 }
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function taskProjection(task: TaskContract, criteria: CriteriaLinkProjection["criteria"], controlState: "active" | "terminal") {
|
|
171
|
+
const safeCriteria = criteria.map((criterion) => ({ ...criterion, obligation: display(criterion.obligation, 500) || "Criterion unavailable" }));
|
|
172
|
+
const completed = safeCriteria.filter((criterion) => criterion.state === "satisfied").length;
|
|
173
|
+
const total = safeCriteria.length;
|
|
174
|
+
const blocker = task.trace.outcome === "blocked" || task.trace.outcome === "partial" || task.trace.outcome === "failed"
|
|
175
|
+
? display(task.failureReason ?? task.trace.friction ?? "Task cannot complete", 500) : null;
|
|
176
|
+
return {
|
|
177
|
+
taskId: task.taskId, taskRunId: task.taskRunId, summary: display(task.summary, 500), changeMode: task.changeMode,
|
|
178
|
+
riskLane: task.riskLane === "high-risk" ? "high-risk" : "low-risk", outcome: task.trace.outcome, controlState,
|
|
179
|
+
criteria: safeCriteria, workPlan: task.workPlan.slice(0, 64).map((step) => ({ stepId: step.id, summary: display(step.title, 500) || "Work step", status: step.status, criterionIds: [] })),
|
|
180
|
+
scope: strings(task.scope.map((item) => display(item, 500)), 256), outOfScope: strings(task.outOfScope.map((item) => display(item, 500)), 256), progress: { completed, total, percent: total ? completed / total * 100 : 0 },
|
|
181
|
+
blocker, reasonCode: blocker ? "task-blocked-or-incomplete" : null
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function usageCounter(value: UsageTotals | null, reasonCode: string) {
|
|
186
|
+
const valid = value && [value.input, value.output, value.cacheRead, value.cacheWrite].every((item) => Number.isInteger(item) && item >= 0 && item <= 1_000_000_000_000)
|
|
187
|
+
&& Number.isFinite(value.cost) && value.cost >= 0 && value.cost <= 1_000_000_000;
|
|
188
|
+
return valid ? { state: "known", ...value, currency: "USD", reasonCode: null }
|
|
189
|
+
: { state: "unknown", input: null, output: null, cacheRead: null, cacheWrite: null, cost: null, currency: null, reasonCode };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function contextProjection(value: { tokens: number | null; contextWindow: number; percent: number | null } | undefined, generatedAt: string) {
|
|
193
|
+
return value && Number.isInteger(value.tokens) && value.tokens! >= 0 && value.tokens! <= 1_000_000_000
|
|
194
|
+
&& Number.isInteger(value.contextWindow) && value.contextWindow > 0 && value.contextWindow <= 1_000_000_000
|
|
195
|
+
&& typeof value.percent === "number" && Number.isFinite(value.percent) && value.percent >= 0 && value.percent <= 100
|
|
196
|
+
? { state: "known", tokens: value.tokens, contextWindow: value.contextWindow, percent: value.percent, capturedAt: generatedAt, reasonCode: null }
|
|
197
|
+
: { state: "unavailable", tokens: null, contextWindow: null, percent: null, capturedAt: null, reasonCode: "host-context-usage-unavailable" };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function modelFact(value: WebUiRuntimeModel | undefined) {
|
|
201
|
+
const publicId = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
202
|
+
const provider = typeof value?.provider === "string" ? value.provider : "";
|
|
203
|
+
const modelId = typeof value?.id === "string" ? value.id : "";
|
|
204
|
+
const contextWindow = typeof value?.contextWindow === "number" ? value.contextWindow : 0;
|
|
205
|
+
const maxOutputTokens = typeof value?.maxTokens === "number" ? value.maxTokens : 0;
|
|
206
|
+
if (!publicId.test(provider) || !publicId.test(modelId) || !Number.isInteger(contextWindow) || contextWindow < 1 || contextWindow > 100_000_000
|
|
207
|
+
|| !Number.isInteger(maxOutputTokens) || maxOutputTokens < 1 || maxOutputTokens > 100_000_000 || typeof value?.reasoning !== "boolean") {
|
|
208
|
+
return { state: "unavailable", value: null, evidence: null, reasonCode: "model-snapshot-not-projectable" };
|
|
209
|
+
}
|
|
210
|
+
const mapping = value.thinkingLevelMap && typeof value.thinkingLevelMap === "object" ? value.thinkingLevelMap as Record<string, unknown> : {};
|
|
211
|
+
const supportedThinkingLevels = value.reasoning
|
|
212
|
+
? ["off", "minimal", "low", "medium", "high", "xhigh", "max"].filter((level) => mapping[level] !== null && !(["xhigh", "max"].includes(level) && mapping[level] === undefined))
|
|
213
|
+
: ["off"];
|
|
214
|
+
const inputCapabilities = Array.isArray(value.input)
|
|
215
|
+
? [...new Set(value.input.filter((item): item is "text" | "image" => item === "text" || item === "image"))]
|
|
216
|
+
: [];
|
|
217
|
+
return { state: "known", value: { modelRef: webUiModelRef(provider, modelId), provider, modelId,
|
|
218
|
+
displayName: display(value.name ?? modelId, 500) || modelId, reasoning: value.reasoning, inputCapabilities,
|
|
219
|
+
supportedThinkingLevels, contextWindow, maxOutputTokens }, evidence: "observed", reasonCode: null };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function thinkingFact(value: unknown) {
|
|
223
|
+
const level = typeof value === "string" ? value : "";
|
|
224
|
+
return ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(level)
|
|
225
|
+
? { state: "known", value: level, evidence: "observed", reasonCode: null }
|
|
226
|
+
: { state: "unavailable", value: null, evidence: null, reasonCode: "thinking-level-not-projected" };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function continuation(cwd: string, task?: TaskContract) {
|
|
230
|
+
if (!task) return { state: "not-applicable", consumed: null, maximum: null, remaining: null, reservationRef: null, reasonCode: "no-active-task" };
|
|
231
|
+
const budget = inspectTaskContinuationBudget(cwd, task);
|
|
232
|
+
if (!budget.enforcementSafe) return { state: "unknown", consumed: null, maximum: null, remaining: null, reservationRef: null, reasonCode: "continuation-journal-unavailable" };
|
|
233
|
+
const remaining = Math.max(0, budget.maximum - budget.consumed);
|
|
234
|
+
return { state: remaining === 0 ? "exhausted" : "available", consumed: budget.consumed, maximum: budget.maximum, remaining, reservationRef: null, reasonCode: null };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function handoff(cwd: string, task: TaskContract | undefined, currentDigest: string | null) {
|
|
238
|
+
if (!task) return null;
|
|
239
|
+
let value;
|
|
240
|
+
try { value = readHandoffProjection(cwd, task.taskRunId); } catch { return null; }
|
|
241
|
+
if (!value) return null;
|
|
242
|
+
const stale = Boolean(currentDigest && value.tree.currentDigest && currentDigest !== value.tree.currentDigest);
|
|
243
|
+
return { handoffRef: token("handoff", task.taskRunId), state: stale ? "stale" : "ready", summary: display(value.goal.summary, 500),
|
|
244
|
+
blocker: value.failure.warnings[0] ? display(value.failure.warnings[0], 500) : null,
|
|
245
|
+
nextSafeAction: display(value.nextSafeAction.action, 500) || null,
|
|
246
|
+
evidenceRefs: strings([value.references.taskContract, value.references.journal, value.references.trajectory].map((item) => token("evidence", item)), 64),
|
|
247
|
+
generatedAt: timestamp(value.generatedAt), reasonCode: stale ? "handoff-tree-stale" : null };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function buildWebUiInspectionProjection(input: {
|
|
251
|
+
cwd: string;
|
|
252
|
+
sessionId: string;
|
|
253
|
+
task?: TaskContract;
|
|
254
|
+
events?: ActivityInspectorEvent[];
|
|
255
|
+
sessionEntries?: unknown[];
|
|
256
|
+
contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };
|
|
257
|
+
current?: CurrentActivity[];
|
|
258
|
+
protectedPaths?: string[];
|
|
259
|
+
generatedAt?: string;
|
|
260
|
+
runtimeInstanceId?: string;
|
|
261
|
+
eventCursor?: string;
|
|
262
|
+
resyncRequired?: boolean;
|
|
263
|
+
eventReplay?: { eventRetentionCount: number; eventRetentionSeconds: number };
|
|
264
|
+
model?: WebUiRuntimeModel;
|
|
265
|
+
thinkingLevel?: unknown;
|
|
266
|
+
}): Promise<WebUiInspectionProjection> {
|
|
267
|
+
const generatedAt = timestamp(input.generatedAt);
|
|
268
|
+
const identity: WebUiIdentity = { projectRef: webUiProjectRef(input.cwd), runtimeInstanceId: input.runtimeInstanceId ?? WEBUI_RUNTIME_INSTANCE_REF,
|
|
269
|
+
sessionRef: webUiSessionRef(input.sessionId), taskId: input.task?.taskId ?? null, taskRunId: input.task?.taskRunId ?? null, agentOperationId: null, toolCallId: null };
|
|
270
|
+
const effectiveProtectedPaths = input.protectedPaths ?? input.task?.protectedPaths ?? [];
|
|
271
|
+
const currentSnapshot = workingTreeSnapshot(input.cwd, {
|
|
272
|
+
isProtectedProjectPath: (projectPath: string) => Boolean(matchesProtectedPath(projectPath, effectiveProtectedPaths))
|
|
273
|
+
}) as Record<string, string>;
|
|
274
|
+
const taskRevision = input.task ? webUiTaskRevision(input.task) : null;
|
|
275
|
+
const views = await collectSourceChangeViews({ cwd: input.cwd, identity, generatedAt, taskRevision,
|
|
276
|
+
isProtectedPath: (_root, repoPath) => Boolean(matchesProtectedPath(repoPath, effectiveProtectedPaths)) });
|
|
277
|
+
const events = scopedEvents(input.events ?? [], input.sessionId, input.task);
|
|
278
|
+
const linked = projectCriteriaFileVerifier({ cwd: input.cwd, task: input.task, sourceViews: views, currentSnapshot,
|
|
279
|
+
protectedPaths: input.protectedPaths, events, at: new Date(generatedAt) });
|
|
280
|
+
const sourceChanges = { task: sourceSummary(linked.sourceViews.task, "task"), workingTree: sourceSummary(linked.sourceViews.workingTree, "working-tree"), staged: sourceSummary(linked.sourceViews.staged, "staged") };
|
|
281
|
+
const currentDigest = workingTreeSnapshotUsesCurrentAlgorithm(currentSnapshot) ? workingTreeEvidenceDigest(currentSnapshot) : null;
|
|
282
|
+
const current = input.current ?? [], controlState = input.task?.trace.outcome === "pending" ? "active" as const : "terminal" as const;
|
|
283
|
+
const context = contextProjection(input.contextUsage, generatedAt), usageFacts = usage(input.sessionEntries ?? []);
|
|
284
|
+
const eventCursor = input.eventCursor ?? token("event-cursor", events.map((event) => event.activityId ?? [event.event, event.toolCallId, event.recordedAt]));
|
|
285
|
+
const runtimeRevision = token("runtime-rev", [taskRevision, sourceChanges.workingTree.revision, sourceChanges.staged.revision, eventCursor, current]);
|
|
286
|
+
const approvals = { state: "unknown", pending: [], recent: [], health: { state: "unavailable", reasonCode: "approval-projection-unavailable", message: "Current host approval state is not exposed to the read-only projector" } };
|
|
287
|
+
const task = input.task ? taskProjection(input.task, linked.criteria, controlState) : null;
|
|
288
|
+
const issues = [
|
|
289
|
+
{ code: "approval-projection-unavailable", message: "Approval state is unavailable until the same-process bridge is proved" },
|
|
290
|
+
...(linked.verification.health.state === "ok" ? [] : [{ code: linked.verification.health.reasonCode ?? "verification-projection-degraded", message: linked.verification.health.message ?? "Verification projection is degraded" }]),
|
|
291
|
+
...([sourceChanges.task, sourceChanges.workingTree, sourceChanges.staged].filter((item) => item.state === "unavailable").map((item) => ({ code: item.health.reasonCode ?? "source-view-unavailable", message: item.health.message ?? `${item.view} source view unavailable` }))),
|
|
292
|
+
...(input.resyncRequired ? [{ code: "event-replay-gap", message: "Event replay has a gap; refresh from this snapshot" }] : [])
|
|
293
|
+
].slice(0, 128).map((issue, index) => ({ issueRef: token("issue", [runtimeRevision, index, issue.code]), severity: "warning", code: issue.code, message: display(issue.message, 500), relatedRefs: [] }));
|
|
294
|
+
const snapshot = {
|
|
295
|
+
schemaVersion: 1, version: WEBUI_SNAPSHOT_VERSION, generatedAt, identity,
|
|
296
|
+
revision: { runtimeRevision, taskRevision, controlRevision: input.task ? webUiControlRevision(input.task.taskRunId, controlState) : null,
|
|
297
|
+
workspaceRevision: linked.sourceViews.workingTree.viewRevision, indexRevision: linked.sourceViews.staged.viewRevision,
|
|
298
|
+
approvalRevision: null, sessionOptionRevision: token("session-option-rev", input.sessionId), queueRevision: null, journalHead: null,
|
|
299
|
+
eventCursor },
|
|
300
|
+
capabilities: capabilities(identity, generatedAt, input.resyncRequired === true, input.eventReplay),
|
|
301
|
+
session: {
|
|
302
|
+
connectionState: input.resyncRequired ? "resync-required" : "connected", connectionReason: input.resyncRequired ? "event-replay-gap" : null, displayName: input.task?.sessionName ?? null,
|
|
303
|
+
operation: current.some((item) => (item.status ?? "running") === "running")
|
|
304
|
+
? { liveness: "running", operationRef: null, hostPhase: { state: "known", value: "tool", evidence: "derived", reasonCode: null }, startedAt: timestamp(current[0]?.startedAt, generatedAt), settledAt: null, reasonCode: null }
|
|
305
|
+
: { liveness: "idle", operationRef: null, hostPhase: { state: "known", value: "idle", evidence: "derived", reasonCode: null }, startedAt: null, settledAt: null, reasonCode: null },
|
|
306
|
+
controlState: input.task ? controlState : "active", taskOutcome: input.task?.trace.outcome ?? null, approvalState: approvals.state,
|
|
307
|
+
verificationState: linked.verification.state, permissionProfile: { state: "unavailable", value: null, evidence: null, reasonCode: "permission-profile-not-projected" },
|
|
308
|
+
model: modelFact(input.model),
|
|
309
|
+
thinking: thinkingFact(input.thinkingLevel),
|
|
310
|
+
queue: { state: "unavailable", hasPending: null, heldCount: null, revision: null, reasonCode: "host-queue-api-unavailable" }, context
|
|
311
|
+
},
|
|
312
|
+
task, sourceChanges, activity: activityProjection(events, current), approvals, verification: linked.verification,
|
|
313
|
+
usage: { context, latestTurn: usageCounter(usageFacts.latest, "no-assistant-turn"), sessionTotal: usageCounter(usageFacts.observed ? usageFacts.totals : null, "host-total-usage-unavailable"),
|
|
314
|
+
taskTotal: usageCounter(null, input.task ? "task-usage-boundary-unavailable" : "no-active-task"), capturedAt: generatedAt,
|
|
315
|
+
health: { state: "degraded", reasonCode: "partial-usage-only", message: "Task-attributed usage is not available" } },
|
|
316
|
+
continuation: continuation(input.cwd, input.task), handoff: handoff(input.cwd, input.task, currentDigest),
|
|
317
|
+
health: { state: issues.length ? "degraded" : "ok", issues, resyncRequired: input.resyncRequired === true, generatedFromRevision: runtimeRevision }
|
|
318
|
+
};
|
|
319
|
+
return { snapshot, sourceViews: linked.sourceViews, relations: linked.relations, verifierAttempts: linked.verifierAttempts, scopedEvents: events };
|
|
320
|
+
}
|