@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,112 @@
|
|
|
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 { readTaskBaselineManifest, taskBaselineRetentionState } from "./source-evidence-store.ts";
|
|
7
|
+
import { REVIEW_EVIDENCE_VERSION, reviewDigest, reviewRecordDigest, validateReviewEvidenceRecord,
|
|
8
|
+
type ReviewEvidenceRecord, type ReviewTarget } from "./review-state-contract.ts";
|
|
9
|
+
|
|
10
|
+
const MAX_RECORDS = 2_000;
|
|
11
|
+
const MAX_RECORD_BYTES = 64 * 1024;
|
|
12
|
+
|
|
13
|
+
export type AppendReviewEvidenceOptions = {
|
|
14
|
+
projectRoot: string;
|
|
15
|
+
taskId: string;
|
|
16
|
+
taskRunId: string;
|
|
17
|
+
projectRef: string;
|
|
18
|
+
runtimeInstanceId: string;
|
|
19
|
+
sessionRef: string;
|
|
20
|
+
commandId: string;
|
|
21
|
+
idempotencyKeyDigest: string;
|
|
22
|
+
actionDigest: string;
|
|
23
|
+
reviewState: "reviewed" | "unreviewed";
|
|
24
|
+
target: ReviewTarget;
|
|
25
|
+
requestedAt: string;
|
|
26
|
+
recordedAt: string;
|
|
27
|
+
observedRevisions: Record<string, string | null>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function hash(value: string): string { return createHash("sha256").update(value).digest("hex"); }
|
|
31
|
+
function directory(projectRoot: string, taskRunId: string): string {
|
|
32
|
+
return path.join(projectRoot, ".pi", "piagent-state", "source-evidence", `run-${hash(taskRunId)}`, "reviews");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPrivate(projectRoot: string, file: string): Buffer {
|
|
36
|
+
const safe = resolveLocalStatePath(projectRoot, file, { label: "Review evidence", kind: "file" });
|
|
37
|
+
const descriptor = fs.openSync(safe, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
38
|
+
try {
|
|
39
|
+
const stat = fs.fstatSync(descriptor);
|
|
40
|
+
if (!stat.isFile() || stat.size > MAX_RECORD_BYTES) throw new Error("Review evidence record is oversized or not regular");
|
|
41
|
+
return fs.readFileSync(descriptor);
|
|
42
|
+
} finally { fs.closeSync(descriptor); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function publishPrivate(projectRoot: string, target: string, content: Buffer): void {
|
|
46
|
+
const parent = ensurePrivateStateDirectory(projectRoot, path.dirname(target), "Review evidence directory");
|
|
47
|
+
const safeTarget = resolveLocalStatePath(projectRoot, target, { label: "Review evidence" });
|
|
48
|
+
const temporary = path.join(parent, `${path.basename(target)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
49
|
+
const descriptor = fs.openSync(temporary, "wx", 0o600);
|
|
50
|
+
try { fs.writeFileSync(descriptor, content); fs.fsyncSync(descriptor); fs.fchmodSync(descriptor, 0o600); }
|
|
51
|
+
finally { fs.closeSync(descriptor); }
|
|
52
|
+
try { fs.linkSync(temporary, safeTarget); }
|
|
53
|
+
catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
55
|
+
if (!readPrivate(projectRoot, safeTarget).equals(content)) throw new Error("Review evidence identity collision");
|
|
56
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
57
|
+
try { fs.chmodSync(parent, 0o700); fs.chmodSync(safeTarget, 0o600); } catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function appendReviewEvidence(options: AppendReviewEvidenceOptions): ReviewEvidenceRecord {
|
|
61
|
+
const manifest = readTaskBaselineManifest(options.projectRoot, options.taskRunId);
|
|
62
|
+
if (!manifest || manifest.taskId !== options.taskId || manifest.captureState !== "current"
|
|
63
|
+
|| taskBaselineRetentionState(manifest, new Date(options.recordedAt)) !== "active") throw new Error("review-baseline-unavailable");
|
|
64
|
+
const identityDigest = reviewDigest({ taskRunId: options.taskRunId, commandId: options.commandId, actionDigest: options.actionDigest });
|
|
65
|
+
const recordId = `review.${identityDigest.slice("sha256:".length)}`;
|
|
66
|
+
const existing = readReviewEvidence(options.projectRoot, options.taskRunId);
|
|
67
|
+
if (existing.corruptions.length) throw new Error("review-evidence-corrupt");
|
|
68
|
+
const duplicate = existing.records.find((record) => record.recordId === recordId);
|
|
69
|
+
if (duplicate) {
|
|
70
|
+
if (duplicate.commandId === options.commandId && duplicate.idempotencyKeyDigest === options.idempotencyKeyDigest
|
|
71
|
+
&& duplicate.actionDigest === options.actionDigest && duplicate.reviewState === options.reviewState
|
|
72
|
+
&& JSON.stringify(duplicate.target) === JSON.stringify(options.target)) return duplicate;
|
|
73
|
+
throw new Error("review-evidence-identity-collision");
|
|
74
|
+
}
|
|
75
|
+
if (existing.records.length >= MAX_RECORDS) throw new Error("review-evidence-quota-exhausted");
|
|
76
|
+
const sequence = (existing.records.at(-1)?.sequence ?? 0) + 1;
|
|
77
|
+
const payload: Omit<ReviewEvidenceRecord, "integrityDigest"> = {
|
|
78
|
+
schemaVersion: 1, version: REVIEW_EVIDENCE_VERSION, recordId, evidenceRef: `review-evidence.${identityDigest.slice("sha256:".length)}`, sequence,
|
|
79
|
+
taskId: options.taskId, taskRunId: options.taskRunId, projectRef: options.projectRef,
|
|
80
|
+
runtimeInstanceId: options.runtimeInstanceId, sessionRef: options.sessionRef,
|
|
81
|
+
commandId: options.commandId, idempotencyKeyDigest: options.idempotencyKeyDigest, actionDigest: options.actionDigest,
|
|
82
|
+
reviewState: options.reviewState, target: structuredClone(options.target), requestedAt: options.requestedAt, recordedAt: options.recordedAt,
|
|
83
|
+
retentionUntil: manifest.retentionUntil, observedRevisions: structuredClone(options.observedRevisions)
|
|
84
|
+
};
|
|
85
|
+
const record = validateReviewEvidenceRecord({ ...payload, integrityDigest: reviewRecordDigest(payload) });
|
|
86
|
+
const root = directory(options.projectRoot, options.taskRunId);
|
|
87
|
+
const target = path.join(root, `${record.recordId}.json`);
|
|
88
|
+
const bytes = Buffer.from(`${JSON.stringify(record, null, 2)}\n`);
|
|
89
|
+
if (bytes.length > MAX_RECORD_BYTES) throw new Error("review-evidence-record-oversized");
|
|
90
|
+
publishPrivate(options.projectRoot, target, bytes);
|
|
91
|
+
return record;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readReviewEvidence(projectRoot: string, taskRunId: string): { records: ReviewEvidenceRecord[]; corruptions: string[] } {
|
|
95
|
+
const root = directory(projectRoot, taskRunId);
|
|
96
|
+
let files: string[];
|
|
97
|
+
try { files = fs.readdirSync(resolveLocalStatePath(projectRoot, root, { label: "Review evidence", kind: "directory" })).filter((file) => file.endsWith(".json")).sort(); }
|
|
98
|
+
catch (error) {
|
|
99
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { records: [], corruptions: [] };
|
|
100
|
+
return { records: [], corruptions: ["review-evidence-directory-unavailable"] };
|
|
101
|
+
}
|
|
102
|
+
if (files.length > MAX_RECORDS) return { records: [], corruptions: ["review-evidence-record-limit"] };
|
|
103
|
+
const records: ReviewEvidenceRecord[] = [], corruptions: string[] = [];
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
try {
|
|
106
|
+
const record = validateReviewEvidenceRecord(JSON.parse(readPrivate(projectRoot, path.join(root, file)).toString("utf8")));
|
|
107
|
+
if (file !== `${record.recordId}.json` || record.taskRunId !== taskRunId) throw new Error("Review evidence identity mismatch");
|
|
108
|
+
records.push(record);
|
|
109
|
+
} catch { corruptions.push(`review.${hash(file)}`); }
|
|
110
|
+
}
|
|
111
|
+
return { records: corruptions.length ? [] : records.sort((left, right) => left.sequence - right.sequence), corruptions };
|
|
112
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createHash } 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
|
+
|
|
7
|
+
export const RUNTIME_EVENT_SCHEMA_VERSION = 2 as const;
|
|
8
|
+
export type RuntimeEventRevision = {
|
|
9
|
+
runtimeRevision: string;
|
|
10
|
+
taskRevision: string | null;
|
|
11
|
+
controlRevision: string | null;
|
|
12
|
+
workspaceRevision: string | null;
|
|
13
|
+
indexRevision: string | null;
|
|
14
|
+
approvalRevision: string | null;
|
|
15
|
+
sessionOptionRevision: string | null;
|
|
16
|
+
queueRevision: string | null;
|
|
17
|
+
};
|
|
18
|
+
export type RuntimeEventCorrelation = {
|
|
19
|
+
commandId: string | null;
|
|
20
|
+
messageRequestId: string | null;
|
|
21
|
+
replacementId: string | null;
|
|
22
|
+
approvalRequestId: string | null;
|
|
23
|
+
causationEventId: string | null;
|
|
24
|
+
idempotencyKeyDigest: string | null;
|
|
25
|
+
};
|
|
26
|
+
export type RuntimeEventDraft = {
|
|
27
|
+
sourceObservedAt: string;
|
|
28
|
+
projectRef: string;
|
|
29
|
+
runtimeInstanceId: string;
|
|
30
|
+
sessionRef: string;
|
|
31
|
+
taskId: string | null;
|
|
32
|
+
taskRunId: string | null;
|
|
33
|
+
agentOperationId: string | null;
|
|
34
|
+
turnIndex: number | null;
|
|
35
|
+
messageRef: string | null;
|
|
36
|
+
toolCallId: string | null;
|
|
37
|
+
revision: RuntimeEventRevision;
|
|
38
|
+
kind: string;
|
|
39
|
+
correlation: RuntimeEventCorrelation;
|
|
40
|
+
evidence: "observed" | "derived" | "unavailable";
|
|
41
|
+
payload: Record<string, unknown>;
|
|
42
|
+
redaction: { applied: boolean; valuesRemoved: number; truncated: boolean };
|
|
43
|
+
};
|
|
44
|
+
export type RuntimeEventV2 = RuntimeEventDraft & {
|
|
45
|
+
schemaVersion: typeof RUNTIME_EVENT_SCHEMA_VERSION;
|
|
46
|
+
eventId: string;
|
|
47
|
+
eventCursor: string;
|
|
48
|
+
writerSequence: number;
|
|
49
|
+
recordedAt: string;
|
|
50
|
+
};
|
|
51
|
+
export type RuntimeEventReplay = {
|
|
52
|
+
state: "current" | "truncated" | "resync-required";
|
|
53
|
+
events: RuntimeEventV2[];
|
|
54
|
+
nextCursor: string;
|
|
55
|
+
latestCursor: string;
|
|
56
|
+
firstAvailableSequence: number | null;
|
|
57
|
+
lastAvailableSequence: number | null;
|
|
58
|
+
reasonCode: string | null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const EVENT_KINDS = new Set([
|
|
62
|
+
"runtime.started", "runtime.health-changed", "runtime.disconnected", "runtime.resync-required", "runtime.resynced", "runtime.phase-changed",
|
|
63
|
+
"session.bound", "session.info-changed", "session.replacement-requested", "session.replacement-pending", "session.replacement-committed", "session.replacement-cancelled", "session.replacement-failed", "session.compaction-preflight", "session.compacted", "session.shutdown",
|
|
64
|
+
"agent-operation.started", "agent-operation.loop-ended", "agent-operation.settled", "agent-operation.stop-requested", "agent-operation.stop-settled",
|
|
65
|
+
"turn.started", "turn.ended", "message.started", "message.text-delta", "message.thinking-state", "message.completed", "message.failed",
|
|
66
|
+
"chat.held", "chat.dispatch-requested", "chat.dispatch-observed", "chat.dispatch-rejected", "chat.dispatch-unknown",
|
|
67
|
+
"session-option.model-changed", "session-option.thinking-changed", "task.started", "task.state-changed", "task.outcome-changed",
|
|
68
|
+
"task-control.stop-requested", "task-control.stop-settled", "task-control.pause-requested", "task-control.paused", "task-control.pause-cancelled", "task-control.resume-requested", "task-control.resumed", "task-control.resume-rejected", "task-control.continue-requested", "task-control.continue-dispatched", "task-control.continue-uncertain",
|
|
69
|
+
"queue.changed", "activity.requested", "activity.started", "activity.progress", "activity.finished", "activity.failed", "activity.blocked", "activity.aborted",
|
|
70
|
+
"approval.requested", "approval.resolved", "approval.expired", "source.changed", "verifier.started", "verifier.finished", "verifier.stale", "usage.updated", "handoff.updated"
|
|
71
|
+
]);
|
|
72
|
+
const EVENT_FIELDS = ["schemaVersion", "eventId", "eventCursor", "writerSequence", "recordedAt", "sourceObservedAt", "projectRef", "runtimeInstanceId", "sessionRef", "taskId", "taskRunId", "agentOperationId", "turnIndex", "messageRef", "toolCallId", "revision", "kind", "correlation", "evidence", "payload", "redaction"];
|
|
73
|
+
const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
|
|
74
|
+
const PUBLIC_ID = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
75
|
+
const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/;
|
|
76
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
77
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
78
|
+
const MAX_EVENT_BYTES = 256 * 1024;
|
|
79
|
+
|
|
80
|
+
function hash(value: string | Buffer): string { return createHash("sha256").update(value).digest("hex"); }
|
|
81
|
+
function plain(value: unknown): value is Record<string, any> {
|
|
82
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
83
|
+
const prototype = Object.getPrototypeOf(value);
|
|
84
|
+
return prototype === Object.prototype || prototype === null;
|
|
85
|
+
}
|
|
86
|
+
function canonical(value: unknown): unknown {
|
|
87
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
88
|
+
if (!plain(value)) return value;
|
|
89
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
90
|
+
}
|
|
91
|
+
function eventBody(value: RuntimeEventDraft | RuntimeEventV2): string {
|
|
92
|
+
const { schemaVersion: _schema, eventId: _event, eventCursor: _cursor, writerSequence: _sequence, recordedAt: _recorded, ...draft } = value as RuntimeEventV2;
|
|
93
|
+
return JSON.stringify(canonical(draft));
|
|
94
|
+
}
|
|
95
|
+
function eventId(draft: RuntimeEventDraft | RuntimeEventV2): string { return `event.${hash(`runtime-event-v2\0${eventBody(draft)}`)}`; }
|
|
96
|
+
function eventCursor(sequence: number, id: string): string { return `cursor.${sequence.toString(36)}.${hash(`${sequence}\0${id}`)}`; }
|
|
97
|
+
function emptyCursor(runtimeInstanceId: string, sessionRef: string): string { return `cursor.0.${hash(`empty\0${runtimeInstanceId}\0${sessionRef}`)}`; }
|
|
98
|
+
function segmentStart(file: string): number | null {
|
|
99
|
+
const match = /^segment\.([0-9]{12})\.jsonl$/.exec(file);
|
|
100
|
+
return match ? Number(match[1]) : null;
|
|
101
|
+
}
|
|
102
|
+
function segmentName(sequence: number): string { return `segment.${String(sequence).padStart(12, "0")}.jsonl`; }
|
|
103
|
+
function storeDirectory(root: string, runtime: string, session: string): string {
|
|
104
|
+
return path.join(root, ".pi", "piagent-state", "webui-events", `runtime-${hash(runtime)}`, `session-${hash(session)}`);
|
|
105
|
+
}
|
|
106
|
+
function boundedJson(value: unknown): boolean {
|
|
107
|
+
try { return Buffer.byteLength(JSON.stringify(value), "utf8") <= MAX_EVENT_BYTES; } catch { return false; }
|
|
108
|
+
}
|
|
109
|
+
function validTimestamp(value: unknown): boolean { return typeof value === "string" && TIMESTAMP.test(value) && Number.isFinite(Date.parse(value)); }
|
|
110
|
+
function nullableRef(value: unknown, pattern = REF): boolean { return value === null || typeof value === "string" && pattern.test(value); }
|
|
111
|
+
|
|
112
|
+
export function runtimeEventEnvelopeErrors(value: unknown): string[] {
|
|
113
|
+
if (!plain(value)) return ["event must be an object"];
|
|
114
|
+
const errors: string[] = [];
|
|
115
|
+
const keys = Object.keys(value).sort(), expected = [...EVENT_FIELDS].sort();
|
|
116
|
+
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) errors.push("event fields are invalid");
|
|
117
|
+
if (value.schemaVersion !== 2 || !REF.test(String(value.eventId)) || !REF.test(String(value.eventCursor))) errors.push("event envelope version or refs are invalid");
|
|
118
|
+
if (!Number.isInteger(value.writerSequence) || value.writerSequence < 1 || value.writerSequence > 999_999_999_999) errors.push("writer sequence is invalid");
|
|
119
|
+
if (!validTimestamp(value.recordedAt) || !validTimestamp(value.sourceObservedAt) || Date.parse(value.recordedAt) < Date.parse(value.sourceObservedAt)) errors.push("event timestamps are invalid");
|
|
120
|
+
if (![value.projectRef, value.runtimeInstanceId, value.sessionRef].every((item) => typeof item === "string" && REF.test(item))) errors.push("runtime identity is invalid");
|
|
121
|
+
if (!nullableRef(value.taskId, PUBLIC_ID) || !nullableRef(value.taskRunId, PUBLIC_ID) || (value.taskRunId !== null && value.taskId === null)) errors.push("task identity is invalid");
|
|
122
|
+
if (![value.agentOperationId, value.messageRef, value.toolCallId].every((item) => nullableRef(item)) || (value.toolCallId !== null && value.agentOperationId === null)) errors.push("operation identity is invalid");
|
|
123
|
+
if (value.turnIndex !== null && (!Number.isInteger(value.turnIndex) || value.turnIndex < 0 || value.turnIndex > 1_000_000_000)) errors.push("turn index is invalid");
|
|
124
|
+
if (!plain(value.revision) || Object.keys(value.revision).length !== 8 || !REVISION.test(String(value.revision.runtimeRevision))
|
|
125
|
+
|| !["taskRevision", "controlRevision", "workspaceRevision", "indexRevision", "approvalRevision", "sessionOptionRevision", "queueRevision"].every((key) => nullableRef(value.revision[key], REVISION))) errors.push("revision is invalid");
|
|
126
|
+
if (!EVENT_KINDS.has(String(value.kind)) || !["observed", "derived", "unavailable"].includes(String(value.evidence)) || !plain(value.payload)) errors.push("event kind, evidence or payload is invalid");
|
|
127
|
+
if (!plain(value.correlation) || Object.keys(value.correlation).length !== 6 || !["commandId", "messageRequestId", "replacementId", "approvalRequestId", "causationEventId"].every((key) => nullableRef(value.correlation[key]))
|
|
128
|
+
|| !(value.correlation.idempotencyKeyDigest === null || DIGEST.test(String(value.correlation.idempotencyKeyDigest)))) errors.push("correlation is invalid");
|
|
129
|
+
if (!plain(value.redaction) || Object.keys(value.redaction).length !== 3 || typeof value.redaction.applied !== "boolean" || typeof value.redaction.truncated !== "boolean"
|
|
130
|
+
|| !Number.isInteger(value.redaction.valuesRemoved) || value.redaction.valuesRemoved < 0 || value.redaction.valuesRemoved > 10_000
|
|
131
|
+
|| (!value.redaction.applied && value.redaction.valuesRemoved !== 0)) errors.push("redaction is invalid");
|
|
132
|
+
if (value.eventId !== eventId(value as RuntimeEventV2) || value.eventCursor !== eventCursor(value.writerSequence, value.eventId)) errors.push("event integrity is invalid");
|
|
133
|
+
if (!boundedJson(value)) errors.push("event exceeds byte limit");
|
|
134
|
+
return errors;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function validateRuntimeEventEnvelope(value: unknown): RuntimeEventV2 {
|
|
138
|
+
const errors = runtimeEventEnvelopeErrors(value);
|
|
139
|
+
if (errors.length) throw new Error(`Invalid runtime event: ${errors.join("; ")}`);
|
|
140
|
+
return structuredClone(value) as RuntimeEventV2;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readSegment(root: string, file: string): RuntimeEventV2[] {
|
|
144
|
+
const target = resolveLocalStatePath(root, file, { label: "WebUI event segment", kind: "file" });
|
|
145
|
+
const descriptor = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
146
|
+
try {
|
|
147
|
+
const stat = fs.fstatSync(descriptor);
|
|
148
|
+
if (!stat.isFile() || stat.size > 256 * 1024 * 1024) throw new Error("event segment is oversized or not regular");
|
|
149
|
+
const text = fs.readFileSync(descriptor, "utf8");
|
|
150
|
+
if (text && !text.endsWith("\n")) throw new Error("event segment has an incomplete tail");
|
|
151
|
+
return text.split("\n").filter(Boolean).map((line) => {
|
|
152
|
+
if (Buffer.byteLength(line, "utf8") > MAX_EVENT_BYTES) throw new Error("event line is oversized");
|
|
153
|
+
return validateRuntimeEventEnvelope(JSON.parse(line));
|
|
154
|
+
});
|
|
155
|
+
} finally { fs.closeSync(descriptor); }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class RuntimeEventStore {
|
|
159
|
+
readonly directory: string;
|
|
160
|
+
readonly corruptions: string[] = [];
|
|
161
|
+
readonly options: { projectRoot: string; projectRef: string; runtimeInstanceId: string; sessionRef: string; maxEventsPerSegment?: number; maxSegments?: number };
|
|
162
|
+
private events: RuntimeEventV2[] = [];
|
|
163
|
+
private segments: Array<{ file: string; start: number; count: number }> = [];
|
|
164
|
+
private eventById = new Map<string, RuntimeEventV2>();
|
|
165
|
+
private readonly maxEventsPerSegment: number;
|
|
166
|
+
private readonly maxSegments: number;
|
|
167
|
+
|
|
168
|
+
constructor(options: { projectRoot: string; projectRef: string; runtimeInstanceId: string; sessionRef: string; maxEventsPerSegment?: number; maxSegments?: number }) {
|
|
169
|
+
this.options = options;
|
|
170
|
+
this.maxEventsPerSegment = Math.max(2, Math.min(1_000, options.maxEventsPerSegment ?? 500));
|
|
171
|
+
this.maxSegments = Math.max(2, Math.min(20, options.maxSegments ?? 10));
|
|
172
|
+
this.directory = storeDirectory(options.projectRoot, options.runtimeInstanceId, options.sessionRef);
|
|
173
|
+
this.load();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private load(): void {
|
|
177
|
+
let names: string[];
|
|
178
|
+
try {
|
|
179
|
+
const directory = resolveLocalStatePath(this.options.projectRoot, this.directory, { label: "WebUI event store", kind: "directory" });
|
|
180
|
+
const entries = fs.readdirSync(directory);
|
|
181
|
+
if (entries.some((file) => segmentStart(file) === null)) throw new Error("event store contains an unexpected entry");
|
|
182
|
+
names = entries.sort();
|
|
183
|
+
if (names.length > this.maxSegments) throw new Error("event segment retention limit exceeded");
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
186
|
+
this.corruptions.push("event-store-directory-unavailable"); return;
|
|
187
|
+
}
|
|
188
|
+
let previous = 0;
|
|
189
|
+
try {
|
|
190
|
+
for (const name of names) {
|
|
191
|
+
const start = segmentStart(name) as number, records = readSegment(this.options.projectRoot, path.join(this.directory, name));
|
|
192
|
+
if (records.length === 0 || records[0].writerSequence !== start) throw new Error("segment start mismatch");
|
|
193
|
+
for (const record of records) {
|
|
194
|
+
if (record.projectRef !== this.options.projectRef || record.runtimeInstanceId !== this.options.runtimeInstanceId || record.sessionRef !== this.options.sessionRef) throw new Error("event store identity mismatch");
|
|
195
|
+
if (previous && record.writerSequence !== previous + 1) throw new Error("event sequence gap");
|
|
196
|
+
if (this.eventById.has(record.eventId)) throw new Error("duplicate event identity");
|
|
197
|
+
previous = record.writerSequence; this.events.push(record); this.eventById.set(record.eventId, record);
|
|
198
|
+
}
|
|
199
|
+
this.segments.push({ file: name, start, count: records.length });
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
this.events = []; this.segments = []; this.eventById.clear(); this.corruptions.push("event-store-corrupt");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
currentCursor(): string { return this.events.at(-1)?.eventCursor ?? emptyCursor(this.options.runtimeInstanceId, this.options.sessionRef); }
|
|
207
|
+
resyncRequired(): boolean { return this.corruptions.length > 0; }
|
|
208
|
+
retention(): { eventRetentionCount: number; eventRetentionSeconds: number } {
|
|
209
|
+
return { eventRetentionCount: this.maxEventsPerSegment * this.maxSegments, eventRetentionSeconds: 0 };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
append(draft: RuntimeEventDraft, recordedAt = new Date().toISOString()): { event: RuntimeEventV2; appended: boolean } {
|
|
213
|
+
if (this.resyncRequired()) throw new Error("WebUI event store requires resync");
|
|
214
|
+
if (draft.projectRef !== this.options.projectRef || draft.runtimeInstanceId !== this.options.runtimeInstanceId || draft.sessionRef !== this.options.sessionRef) throw new Error("Runtime event identity does not match its store");
|
|
215
|
+
const id = eventId(draft), duplicate = this.eventById.get(id);
|
|
216
|
+
if (duplicate) return { event: structuredClone(duplicate), appended: false };
|
|
217
|
+
const sequence = (this.events.at(-1)?.writerSequence ?? 0) + 1;
|
|
218
|
+
const event = validateRuntimeEventEnvelope({ schemaVersion: 2, eventId: id, eventCursor: eventCursor(sequence, id), writerSequence: sequence, recordedAt, ...structuredClone(draft) });
|
|
219
|
+
let segment = this.segments.at(-1);
|
|
220
|
+
if (!segment || segment.count >= this.maxEventsPerSegment) {
|
|
221
|
+
segment = { file: segmentName(sequence), start: sequence, count: 0 }; this.segments.push(segment);
|
|
222
|
+
}
|
|
223
|
+
const directory = ensurePrivateStateDirectory(this.options.projectRoot, this.directory, "WebUI event store");
|
|
224
|
+
const target = resolveLocalStatePath(this.options.projectRoot, path.join(directory, segment.file), { label: "WebUI event segment" });
|
|
225
|
+
const creating = segment.count === 0;
|
|
226
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW ?? 0)
|
|
227
|
+
| (creating ? fs.constants.O_CREAT | fs.constants.O_EXCL : 0);
|
|
228
|
+
const descriptor = fs.openSync(target, flags, 0o600);
|
|
229
|
+
try { fs.writeFileSync(descriptor, `${JSON.stringify(event)}\n`); fs.fsyncSync(descriptor); fs.fchmodSync(descriptor, 0o600); } finally { fs.closeSync(descriptor); }
|
|
230
|
+
segment.count += 1; this.events.push(event); this.eventById.set(event.eventId, event);
|
|
231
|
+
try { this.enforceRetention(); } catch { this.corruptions.push("event-retention-failed"); }
|
|
232
|
+
return { event: structuredClone(event), appended: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private enforceRetention(): void {
|
|
236
|
+
while (this.segments.length > this.maxSegments) {
|
|
237
|
+
const removed = this.segments.shift() as { file: string; start: number; count: number };
|
|
238
|
+
const target = resolveLocalStatePath(this.options.projectRoot, path.join(this.directory, removed.file), { label: "WebUI event segment", kind: "file" });
|
|
239
|
+
const stat = fs.lstatSync(target);
|
|
240
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("event retention target is unsafe");
|
|
241
|
+
fs.unlinkSync(target);
|
|
242
|
+
const discarded = this.events.splice(0, removed.count);
|
|
243
|
+
for (const event of discarded) this.eventById.delete(event.eventId);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
replay(afterCursor: string | null, limit = 1_000): RuntimeEventReplay {
|
|
248
|
+
const latestCursor = this.currentCursor(), first = this.events[0]?.writerSequence ?? null, last = this.events.at(-1)?.writerSequence ?? null;
|
|
249
|
+
if (this.resyncRequired()) return { state: "resync-required", events: [], nextCursor: latestCursor, latestCursor, firstAvailableSequence: first, lastAvailableSequence: last, reasonCode: this.corruptions[0] };
|
|
250
|
+
let start = 0;
|
|
251
|
+
if (afterCursor !== null && afterCursor !== emptyCursor(this.options.runtimeInstanceId, this.options.sessionRef)) {
|
|
252
|
+
const index = this.events.findIndex((event) => event.eventCursor === afterCursor);
|
|
253
|
+
if (index < 0) return { state: "resync-required", events: [], nextCursor: latestCursor, latestCursor, firstAvailableSequence: first, lastAvailableSequence: last, reasonCode: "event-cursor-gap" };
|
|
254
|
+
start = index + 1;
|
|
255
|
+
}
|
|
256
|
+
const maximum = Math.max(1, Math.min(10_000, Math.floor(limit)));
|
|
257
|
+
const selected = this.events.slice(start, start + maximum), truncated = start + selected.length < this.events.length;
|
|
258
|
+
const nextCursor = selected.at(-1)?.eventCursor ?? afterCursor ?? emptyCursor(this.options.runtimeInstanceId, this.options.sessionRef);
|
|
259
|
+
return { state: truncated ? "truncated" : "current", events: structuredClone(selected), nextCursor, latestCursor,
|
|
260
|
+
firstAvailableSequence: first, lastAvailableSequence: last, reasonCode: null };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const PINNED_BRIDGE_HOST_VERSION = "0.84.1" as const;
|
|
4
|
+
export const SAME_PROCESS_BRIDGE_PROOF_VERSION = "piagent-webui-same-process-bridge-v1" as const;
|
|
5
|
+
|
|
6
|
+
const HOST_EVENTS = Object.freeze([
|
|
7
|
+
"session_start", "session_info_changed", "session_shutdown", "before_provider_request", "after_provider_response",
|
|
8
|
+
"before_agent_start", "agent_start", "agent_end", "agent_settled", "turn_start", "turn_end", "message_start",
|
|
9
|
+
"message_update", "message_end", "tool_execution_start", "tool_execution_update", "tool_execution_end", "model_select",
|
|
10
|
+
"thinking_level_select", "tool_call", "tool_result", "input"
|
|
11
|
+
]);
|
|
12
|
+
type FeatureState = "proven" | "partial" | "unavailable";
|
|
13
|
+
type FeatureVerdict = { state: FeatureState; reasonCode: string; evidence: string[] };
|
|
14
|
+
export type SameProcessBridgeProof = {
|
|
15
|
+
version: typeof SAME_PROCESS_BRIDGE_PROOF_VERSION;
|
|
16
|
+
hostVersion: string;
|
|
17
|
+
compatible: boolean;
|
|
18
|
+
runtimeInstanceId: string;
|
|
19
|
+
sessionRef: string | null;
|
|
20
|
+
overall: "inspect-only" | "inspect-and-chat-feasible";
|
|
21
|
+
productionControlAllowed: false;
|
|
22
|
+
secondRuntimeAllowed: false;
|
|
23
|
+
features: {
|
|
24
|
+
sameProcessIdentity: FeatureVerdict;
|
|
25
|
+
chatDispatch: FeatureVerdict;
|
|
26
|
+
assistantStreaming: FeatureVerdict;
|
|
27
|
+
toolStreaming: FeatureVerdict;
|
|
28
|
+
providerObservation: FeatureVerdict;
|
|
29
|
+
sessionOptions: FeatureVerdict;
|
|
30
|
+
attachments: FeatureVerdict;
|
|
31
|
+
stop: FeatureVerdict;
|
|
32
|
+
pause: FeatureVerdict;
|
|
33
|
+
resume: FeatureVerdict;
|
|
34
|
+
queueObservation: FeatureVerdict;
|
|
35
|
+
approvalBroker: FeatureVerdict;
|
|
36
|
+
usageTotals: FeatureVerdict;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type PiSurface = Record<string, unknown>;
|
|
41
|
+
type ContextSurface = Record<string, any> & { sessionManager?: Record<string, unknown> };
|
|
42
|
+
const REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
43
|
+
function callable(value: unknown): value is (...args: any[]) => unknown { return typeof value === "function"; }
|
|
44
|
+
function supportsEvents(required: string[]): boolean { return required.every((event) => HOST_EVENTS.includes(event)); }
|
|
45
|
+
function feature(state: FeatureState, reasonCode: string, evidence: string[]): FeatureVerdict { return { state, reasonCode, evidence }; }
|
|
46
|
+
function opaqueSession(value: string): string { return `session.${createHash("sha256").update(`piagent-webui-session\n${value}`).digest("hex")}`; }
|
|
47
|
+
|
|
48
|
+
export function probeSameProcessBridge(input: {
|
|
49
|
+
hostVersion: string;
|
|
50
|
+
runtimeInstanceId: string;
|
|
51
|
+
pi: PiSurface;
|
|
52
|
+
ctx: ContextSurface;
|
|
53
|
+
}): SameProcessBridgeProof {
|
|
54
|
+
const compatible = input.hostVersion === PINNED_BRIDGE_HOST_VERSION;
|
|
55
|
+
let sessionId: string | null = null;
|
|
56
|
+
try {
|
|
57
|
+
const getter = input.ctx.sessionManager?.getSessionId;
|
|
58
|
+
const value = callable(getter) ? getter.call(input.ctx.sessionManager) : null;
|
|
59
|
+
sessionId = typeof value === "string" && value.trim() ? value : null;
|
|
60
|
+
} catch { sessionId = null; }
|
|
61
|
+
const runtimeBound = REF.test(input.runtimeInstanceId), identity = compatible && runtimeBound && sessionId !== null;
|
|
62
|
+
const sameProcessIdentity = identity
|
|
63
|
+
? feature("proven", "current-context-session-bound", ["ExtensionContext.sessionManager.getSessionId", "in-process runtime instance"])
|
|
64
|
+
: feature("unavailable", !compatible ? "unsupported-host-version" : !runtimeBound ? "runtime-instance-unavailable" : "session-identity-unavailable", []);
|
|
65
|
+
const send = identity && callable(input.pi.sendUserMessage), eventRegistration = callable(input.pi.on);
|
|
66
|
+
const chatEvents = eventRegistration && supportsEvents(["input", "before_agent_start", "agent_start", "agent_settled", "message_start", "message_update", "message_end"]);
|
|
67
|
+
const chatDispatch = send && chatEvents
|
|
68
|
+
? feature("proven", "same-process-dispatch-feasible", ["ExtensionAPI.sendUserMessage", "deliverAs steer/followUp", "input/agent/message events"])
|
|
69
|
+
: feature("unavailable", !identity ? sameProcessIdentity.reasonCode : !send ? "send-user-message-unavailable" : "chat-events-unavailable", []);
|
|
70
|
+
const assistantStreaming = identity && eventRegistration && supportsEvents(["message_start", "message_update", "message_end", "turn_start", "turn_end"])
|
|
71
|
+
? feature("proven", "assistant-events-available", ["message_start/update/end", "turn_start/end"])
|
|
72
|
+
: feature("unavailable", "assistant-events-unavailable", []);
|
|
73
|
+
const toolStreaming = identity && eventRegistration && supportsEvents(["tool_execution_start", "tool_execution_update", "tool_execution_end", "tool_call", "tool_result"])
|
|
74
|
+
? feature("proven", "tool-events-available", ["tool_execution_start/update/end", "tool_call/result"])
|
|
75
|
+
: feature("unavailable", "tool-events-unavailable", []);
|
|
76
|
+
const providerObservation = identity && eventRegistration && supportsEvents(["before_provider_request", "after_provider_response"])
|
|
77
|
+
? feature("proven", "provider-events-available", ["before_provider_request", "after_provider_response"])
|
|
78
|
+
: feature("unavailable", "provider-events-unavailable", []);
|
|
79
|
+
const sessionOptions = identity && callable(input.pi.setModel) && callable(input.pi.setThinkingLevel)
|
|
80
|
+
&& Array.isArray(input.ctx.scopedModels) && Object.hasOwn(input.ctx, "thinkingLevel")
|
|
81
|
+
? feature("proven", "session-options-feasible", ["ExtensionAPI.setModel/setThinkingLevel", "ExtensionContext.scopedModels/thinkingLevel", "selection events"])
|
|
82
|
+
: feature("unavailable", "session-option-surface-incomplete", []);
|
|
83
|
+
const attachments = send
|
|
84
|
+
? feature("proven", "content-array-dispatch-feasible", ["ExtensionAPI.sendUserMessage TextContent/ImageContent[]"])
|
|
85
|
+
: feature("unavailable", "attachment-dispatch-unavailable", []);
|
|
86
|
+
const stop = identity && callable(input.ctx.abort) && eventRegistration && supportsEvents(["agent_settled"])
|
|
87
|
+
? feature("partial", "void-abort-without-operation-ack", ["ExtensionContext.abort(): void", "agent_settled", "TUI clears queued messages before agent.abort"])
|
|
88
|
+
: feature("unavailable", "abort-surface-unavailable", []);
|
|
89
|
+
const pause = feature("unavailable", "semantic-pause-api-unavailable", ["no durable pause barrier in ExtensionAPI/ExtensionContext"]);
|
|
90
|
+
const resume = feature("unavailable", "semantic-resume-api-unavailable", ["resume depends on durable acknowledged pause"]);
|
|
91
|
+
const queueObservation = identity && callable(input.ctx.hasPendingMessages)
|
|
92
|
+
? feature("partial", "queue-boolean-only", ["ExtensionContext.hasPendingMessages", "no extension queue_update/count/content/revision"])
|
|
93
|
+
: feature("unavailable", "queue-observation-unavailable", []);
|
|
94
|
+
const approvalBroker = feature("unavailable", "approval-injection-api-unavailable", ["ExtensionUI confirmation has no shared external decision hook"]);
|
|
95
|
+
const usageTotals = identity && callable(input.ctx.getContextUsage) && callable(input.ctx.sessionManager?.getBranch) && providerObservation.state === "proven"
|
|
96
|
+
? feature("partial", "derived-session-usage-without-host-total-api", ["getContextUsage", "session branch assistant usage", "provider request events"])
|
|
97
|
+
: feature("unavailable", "usage-observation-incomplete", []);
|
|
98
|
+
const features = { sameProcessIdentity, chatDispatch, assistantStreaming, toolStreaming, providerObservation, sessionOptions, attachments,
|
|
99
|
+
stop, pause, resume, queueObservation, approvalBroker, usageTotals };
|
|
100
|
+
return { version: SAME_PROCESS_BRIDGE_PROOF_VERSION, hostVersion: input.hostVersion, compatible, runtimeInstanceId: input.runtimeInstanceId,
|
|
101
|
+
sessionRef: sessionId ? opaqueSession(sessionId) : null, overall: chatDispatch.state === "proven" ? "inspect-and-chat-feasible" : "inspect-only",
|
|
102
|
+
productionControlAllowed: false, secondRuntimeAllowed: false, features };
|
|
103
|
+
}
|