@piagent/platform 1.3.1 → 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 +39 -0
- 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 +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/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 +7 -6
- 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/runtime-integrity.js +143 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +62 -37
- package/packages/piagent-core/extensions/redaction-core.d.ts +2 -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-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
|
+
}
|