@piagent/platform 1.37.1 → 1.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +5 -3
- package/README.vi.md +2 -2
- package/catalog/capabilities.json +2 -2
- package/docs/command-reference-vietnamese.md +4 -3
- package/docs/distribution-standard.md +6 -6
- package/docs/en/maintainer-guide.md +8 -0
- package/docs/en/workspace-editor.md +222 -0
- package/docs/languages.json +13 -4
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/research/ide-m0-spike.md +101 -0
- package/docs/team-onboarding.md +2 -2
- package/docs/vi/maintainer-guide.md +8 -0
- package/docs/vi/workspace-editor.md +217 -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/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +17 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +17 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.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/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- 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 +18 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- 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 +18 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/fixtures/piagent-webui/checkpoint-v1.invalid.json +21 -0
- package/evals/fixtures/piagent-webui/checkpoint-v1.valid.json +20 -0
- package/evals/fixtures/piagent-webui/terminal-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/terminal-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/workspace-file-v1.invalid.json +24 -0
- package/evals/fixtures/piagent-webui/workspace-file-v1.valid.json +23 -0
- package/evals/fixtures/piagent-webui/workspace-paths-v1.invalid.json +24 -0
- package/evals/fixtures/piagent-webui/workspace-paths-v1.valid.json +24 -0
- package/evals/fixtures/piagent-webui/workspace-search-v1.invalid.json +26 -0
- package/evals/fixtures/piagent-webui/workspace-search-v1.valid.json +26 -0
- package/evals/fixtures/piagent-webui/workspace-tree-v1.invalid.json +28 -0
- package/evals/fixtures/piagent-webui/workspace-tree-v1.valid.json +27 -0
- package/evals/fixtures/piagent-webui/workspace-write-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/workspace-write-v1.valid.json +19 -0
- package/evals/long-task-soak-v1/README.md +17 -0
- package/evals/long-task-soak-v1/lib/session-loop.mjs +19 -3
- package/evals/long-task-soak-v1/scenarios/ide-editor-terminal.json +19 -0
- package/evals/long-task-soak-v1/surfaces/webui-gateway.mjs +17 -2
- package/evals/long-task-soak-v1/surfaces/workspace-actions.mjs +95 -0
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +7 -3
- package/packages/piagent-core/extensions/fallback-base-policy.ts +1 -1
- package/packages/piagent-core/extensions/guard-types.ts +3 -1
- package/packages/piagent-core/extensions/path-suggestions.ts +137 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +85 -7
- package/packages/piagent-core/extensions/workspace-inspection.ts +145 -0
- package/packages/piagent-core/extensions/workspace-policy.d.ts +7 -0
- package/packages/piagent-core/extensions/workspace-policy.js +3 -0
- package/packages/piagent-core/extensions/workspace-search-index.mjs +8 -0
- package/packages/piagent-core/extensions/workspace-search.ts +74 -0
- package/packages/piagent-core/extensions/workspace-write.ts +116 -0
- package/packages/piagent-core/managed/background-process.mjs +9 -4
- package/packages/piagent-core/managed/boundary-package-proxy.mjs +21 -0
- package/packages/piagent-core/managed/boundary-queue.mjs +24 -0
- package/packages/piagent-core/managed/checkpoint-git.mjs +16 -0
- package/packages/piagent-core/managed/checkpoints.mjs +17 -64
- package/packages/piagent-core/managed/command-tools.mjs +6 -6
- package/packages/piagent-core/managed/continuation.mjs +3 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/backend-api/profile.json +88 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/be-readonly-fe/profile.json +130 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/data/profile.json +88 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/devops/profile.json +90 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/docs/profile.json +74 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/fullstack/profile.json +92 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/generic/profile.json +95 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/mobile/profile.json +86 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/node-typescript/profile.json +74 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/python/profile.json +75 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/web-frontend/profile.json +99 -0
- package/packages/piagent-core/managed/dist/workspace/assets/local-state-path-BrmghxEZ.js +284 -0
- package/packages/piagent-core/managed/dist/workspace/assets/packages/piagent-core/policies/base-policy.json +351 -0
- package/packages/piagent-core/managed/dist/workspace/manifest.json +65 -0
- package/packages/piagent-core/managed/dist/workspace/worker.mjs +7374 -0
- package/packages/piagent-core/managed/dist/workspace/workspace-search-index.mjs +1007 -0
- package/packages/piagent-core/managed/patch-snapshot.mjs +6 -8
- package/packages/piagent-core/managed/permission.mjs +29 -6
- package/packages/piagent-core/managed/repository-operation.mjs +2 -1
- package/packages/piagent-core/managed/review-snapshot-request.mjs +21 -0
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +2 -0
- package/packages/piagent-core/managed/session-mutation-observations.mjs +37 -0
- package/packages/piagent-core/managed/session.mjs +2 -2
- package/packages/piagent-core/managed/source-revert-requests.mjs +21 -0
- package/packages/piagent-core/managed/terminal-boundary.mjs +88 -0
- package/packages/piagent-core/managed/terminal-channel.mjs +66 -0
- package/packages/piagent-core/managed/terminal-runtime.mjs +64 -0
- package/packages/piagent-core/managed/terminal-seatbelt.mjs +48 -0
- package/packages/piagent-core/managed/terminal-worker.mjs +70 -0
- package/packages/piagent-core/managed/tool-boundary.mjs +45 -35
- package/packages/piagent-core/managed/tool-freshness.mjs +47 -0
- package/packages/piagent-core/managed/tool-worker.mjs +18 -3
- package/packages/piagent-core/managed/workspace-worker-pins.mjs +26 -0
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/policies/base-policy.json +2 -1
- package/packages/piagent-core/runtime/checkpoints/checkpoint-authority.ts +43 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-refs.mjs +51 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-store.mjs +179 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-worker.ts +28 -0
- package/packages/piagent-core/runtime/checkpoints/git-adapter.mjs +36 -0
- package/packages/piagent-core/runtime/checkpoints/persistent-history.mjs +96 -0
- package/packages/piagent-core/runtime/checkpoints/personal-git.ts +272 -0
- package/packages/piagent-core/runtime/checkpoints/personal-runtime.ts +158 -0
- package/packages/piagent-core/runtime/checkpoints/prune.mjs +29 -0
- package/packages/piagent-core/runtime/checkpoints/restore-preview.mjs +46 -0
- package/packages/piagent-core/runtime/checkpoints/turn-checkpoints.mjs +167 -0
- package/packages/piagent-core/runtime/hooks/session-hooks.ts +19 -16
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +2 -2
- package/packages/piagent-core/runtime/inspection/diff-projection.ts +2 -1
- package/packages/piagent-core/runtime/inspection/managed-source-revert.ts +90 -0
- package/packages/piagent-core/runtime/inspection/review-state-projection.ts +19 -8
- package/packages/piagent-core/runtime/inspection/session-diff-projection.ts +47 -0
- package/packages/piagent-core/runtime/inspection/session-hunk-provenance.ts +56 -0
- package/packages/piagent-core/runtime/inspection/session-mutation-observer.ts +125 -0
- package/packages/piagent-core/runtime/inspection/source-action-contract.ts +123 -0
- package/packages/piagent-core/runtime/inspection/source-change-projection.ts +3 -9
- package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +3 -41
- package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +6 -36
- package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +36 -30
- package/packages/piagent-core/runtime/inspection/text-diff.ts +5 -4
- package/packages/piagent-core/runtime/inspection/workspace-worker.ts +79 -0
- package/packages/piagent-core/runtime/policy/source-index-transaction.ts +3 -10
- package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +27 -2
- package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +42 -12
- package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +10 -5
- package/packages/piagent-core/runtime/policy/workspace-write-authority.ts +51 -0
- package/packages/piagent-core/runtime/process-owner.mjs +20 -0
- package/packages/piagent-core/runtime/process-tree-watch.mjs +46 -0
- package/packages/piagent-core/runtime/quality/model-mutation-proof.ts +8 -5
- package/packages/piagent-core/runtime/registration/permission-commands.ts +10 -2
- package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
- package/packages/piagent-core/runtime/session/workspace-member-edits.d.mts +7 -0
- package/packages/piagent-core/runtime/session/workspace-member-edits.mjs +41 -0
- package/packages/piagent-core/security/workspace-file-reader.ts +2 -1
- package/packages/piagent-webui/dist/client/assets/Alert-DXF8-XqE.js +1 -0
- package/packages/piagent-webui/dist/client/assets/Button-DZJosXQi.js +1 -0
- package/packages/piagent-webui/dist/client/assets/DialogTitle-CL-fzCnT.js +3 -0
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-k4tq1y8s.js +29 -0
- package/packages/piagent-webui/dist/client/assets/TerminalPanel-DvOPgrGI.js +2 -0
- package/packages/piagent-webui/dist/client/assets/TerminalView-B_a16moC.js +9 -0
- package/packages/piagent-webui/dist/client/assets/TerminalView-kHJ-D0s7.css +1 -0
- package/packages/piagent-webui/dist/client/assets/Typography-DMZL1kLS.js +1 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-BCiYM9NA.js +2 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-DttpQtpf.js +5 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-fgXwQ46B.js +1 -0
- package/packages/piagent-webui/dist/client/assets/bootstrap-CedkNtRG.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-B1M7IjfA.js +10 -0
- package/packages/piagent-webui/dist/client/assets/dist-BUYtGaUP.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-C82AtDDT.js +6 -0
- package/packages/piagent-webui/dist/client/assets/dist-D9b8j6pJ.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-DLfpWwLs.js +23 -0
- package/packages/piagent-webui/dist/client/assets/dist-DcEu_WYV.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-_JsfplOG.js +9 -0
- package/packages/piagent-webui/dist/client/assets/dist-idN8f9Fc.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-nvgpa2Us.js +1 -0
- package/packages/piagent-webui/dist/client/assets/index-BW8-BUMx.js +102 -0
- package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +1 -0
- package/packages/piagent-webui/dist/client/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/packages/piagent-webui/dist/client/assets/tab-close-HE0XxoJf.js +1 -0
- package/packages/piagent-webui/dist/client/assets/ui-preferences-DiddStu0.js +124 -0
- package/packages/piagent-webui/dist/client/assets/useTheme-_ESOj7MI.js +10 -0
- package/packages/piagent-webui/dist/client/assets/workspace-api-O7auv_td.js +1 -0
- package/packages/piagent-webui/dist/client/index.html +10 -2
- package/packages/piagent-webui/extension/same-session-bridge.ts +2 -3
- package/packages/piagent-webui/extension/source-revert-controller.ts +14 -208
- package/packages/piagent-webui/gateway/checkpoint-sessions.ts +99 -0
- package/packages/piagent-webui/gateway/checkpoint-store.ts +6 -0
- package/packages/piagent-webui/gateway/company-relay.ts +5 -0
- package/packages/piagent-webui/gateway/company-terminal-cache.ts +54 -0
- package/packages/piagent-webui/gateway/company-terminal-channel.ts +117 -0
- package/packages/piagent-webui/gateway/company-terminal-relay.ts +75 -0
- package/packages/piagent-webui/gateway/gateway-protocol-service.ts +12 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +48 -4
- package/packages/piagent-webui/gateway/managed-gateway.mjs +30 -3
- package/packages/piagent-webui/gateway/managed-projection.ts +5 -3
- package/packages/piagent-webui/gateway/managed-source-revert-controller.ts +45 -0
- package/packages/piagent-webui/gateway/managed-source-revert-sessions.ts +74 -0
- package/packages/piagent-webui/gateway/managed-terminal-binding.ts +30 -0
- package/packages/piagent-webui/gateway/managed-terminal-processes.ts +74 -0
- package/packages/piagent-webui/gateway/managed-terminal-sessions.ts +9 -0
- package/packages/piagent-webui/gateway/path-suggestions.ts +1 -124
- package/packages/piagent-webui/gateway/personal-terminal-binding.ts +36 -0
- package/packages/piagent-webui/gateway/personal-terminal-diagnostic.mjs +16 -0
- package/packages/piagent-webui/gateway/personal-terminal-processes.ts +119 -0
- package/packages/piagent-webui/gateway/personal-terminal-sessions.ts +22 -0
- package/packages/piagent-webui/gateway/runtime-session-controls.ts +2 -1
- package/packages/piagent-webui/gateway/session-catalog.ts +3 -2
- package/packages/piagent-webui/gateway/session-command-controller.ts +4 -4
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +29 -5
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +8 -8
- package/packages/piagent-webui/gateway/signed-session-journal.ts +77 -0
- package/packages/piagent-webui/gateway/source-revert-sessions.ts +74 -0
- package/packages/piagent-webui/gateway/source-revert-store.ts +47 -0
- package/packages/piagent-webui/gateway/terminal-command-ledger.ts +33 -0
- package/packages/piagent-webui/gateway/terminal-mentions.ts +29 -0
- package/packages/piagent-webui/gateway/terminal-output-buffer.ts +58 -0
- package/packages/piagent-webui/gateway/terminal-router.ts +12 -0
- package/packages/piagent-webui/gateway/terminal-sessions.ts +247 -0
- package/packages/piagent-webui/gateway/workspace-write-sessions.ts +69 -0
- package/packages/piagent-webui/gateway/workspace-write-store.ts +6 -0
- package/packages/piagent-webui/package.json +14 -2
- package/packages/piagent-webui/server/checkpoint-command.ts +30 -0
- package/packages/piagent-webui/server/checkpoint-controller.ts +87 -0
- package/packages/piagent-webui/server/checkpoint-routes.ts +38 -0
- package/packages/piagent-webui/server/control-command-digest.ts +5 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +16 -22
- package/packages/piagent-webui/server/gateway-websocket.ts +36 -8
- package/packages/piagent-webui/server/loopback-server.ts +9 -0
- package/packages/piagent-webui/server/read-model-provider.ts +3 -0
- package/packages/piagent-webui/server/read-only-router.ts +3 -0
- package/packages/piagent-webui/server/session-auth.ts +4 -1
- package/packages/piagent-webui/server/session-source-revert-routes.ts +54 -0
- package/packages/piagent-webui/server/source-projection-binding.ts +18 -0
- package/packages/piagent-webui/server/source-revert-controller.ts +225 -0
- package/packages/piagent-webui/server/terminal-command.ts +35 -0
- package/packages/piagent-webui/server/terminal-websocket-stream.ts +56 -0
- package/packages/piagent-webui/server/transcript-projection.ts +2 -1
- package/packages/piagent-webui/server/workspace-read-routes.ts +32 -0
- package/packages/piagent-webui/server/workspace-reader.ts +23 -0
- package/packages/piagent-webui/server/workspace-write-controller.ts +118 -0
- package/packages/piagent-webui/server/workspace-write-routes.ts +38 -0
- package/packages/piagent-webui/shared/terminal-message.ts +23 -0
- package/schemas/piagent-webui/catalog-v1.json +63 -0
- package/schemas/piagent-webui/checkpoint-v1.schema.json +466 -0
- package/schemas/piagent-webui/control-command-v1.schema.json +68 -3
- package/schemas/piagent-webui/diff-v1.schema.json +4 -0
- package/schemas/piagent-webui/gateway-protocol-v1.schema.json +123 -3
- package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
- package/schemas/piagent-webui/session-command-v1.schema.json +1 -1
- package/schemas/piagent-webui/snapshot-v1.schema.json +2 -1
- package/schemas/piagent-webui/source-revert-v1.schema.json +22 -3
- package/schemas/piagent-webui/terminal-v1.schema.json +495 -0
- package/schemas/piagent-webui/workspace-file-v1.schema.json +79 -0
- package/schemas/piagent-webui/workspace-paths-v1.schema.json +71 -0
- package/schemas/piagent-webui/workspace-search-v1.schema.json +80 -0
- package/schemas/piagent-webui/workspace-tree-v1.schema.json +87 -0
- package/schemas/piagent-webui/workspace-write-v1.schema.json +276 -0
- package/schemas/project-profile.schema.json +8 -3
- package/scripts/company-terminal.mjs +16 -11
- package/scripts/pi-auto.sh +5 -1
- package/scripts/team-doctor.sh +8 -0
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-CfgyQQbW.js +0 -29
- package/packages/piagent-webui/dist/client/assets/index-HyzWKvDu.js +0 -234
- package/packages/piagent-webui/dist/client/assets/index-_0nGP2n3.css +0 -1
|
@@ -1,212 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SourceRevertAuthority } from "../../piagent-core/runtime/inspection/source-action-contract.ts";
|
|
2
|
+
import { appendSourceMutationEvidence, readSourceMutationEvidence } from "../../piagent-core/runtime/inspection/source-mutation-store.ts";
|
|
3
|
+
import { SourceRevertController as SharedSourceRevertController } from "../server/source-revert-controller.ts";
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
import { canonicalReviewValue, reviewDigest } from "../../piagent-core/runtime/inspection/review-state-contract.ts";
|
|
5
|
-
import type { SourceRevertAuthority, SourceRevertProjection, SourceRevertTarget } from "../../piagent-core/runtime/inspection/source-revert-projection.ts";
|
|
6
|
-
import type { SourceIndexTransactionResult } from "../../piagent-core/runtime/policy/source-index-transaction.ts";
|
|
7
|
-
import { controlActionDigest, type BridgeIdentity, type BridgeRevisions, type SameSessionPiBridge } from "./same-session-bridge.ts";
|
|
5
|
+
export type { SourceRevertReceipt } from "../server/source-revert-controller.ts";
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
expectedWorkspacePreimage: string; previewRef: string; confirmedPreviewDigest: string;
|
|
20
|
-
authority: SourceRevertAuthority }) => Promise<SourceIndexTransactionResult>;
|
|
21
|
-
export type SourceRevertReceipt = { schemaVersion: 1; version: "piagent-webui-control-v1"; messageType: "receipt"; commandId: string;
|
|
22
|
-
idempotencyKeyDigest: string; action: "source.revert"; actionDigest: string; identity: BridgeIdentity; phase: "settled" | "rejected" | "uncertain";
|
|
23
|
-
resultCode: "reverted" | "effect-unknown" | "stale-revision" | "identity-mismatch" | "capability-unavailable" | "idempotency-payload-mismatch"
|
|
24
|
-
| "expired" | "invalid-command" | "resync-required" | "terminal-task"; requestedAt: string; settledAt: string;
|
|
25
|
-
observedRevisionsBefore: BridgeRevisions; observedRevisionsAfter: BridgeRevisions; deduplicated: boolean; auditRef: string | null;
|
|
26
|
-
settlementEvidenceRef: string | null; error: { code: string; message: string; retryable: boolean } | null };
|
|
27
|
-
|
|
28
|
-
function sha(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
|
|
29
|
-
function same(left: unknown, right: unknown): boolean { return canonicalReviewValue(left) === canonicalReviewValue(right); }
|
|
30
|
-
function exactKeys(value: unknown, keys: string[]): boolean { return Boolean(value && typeof value === "object" && !Array.isArray(value)
|
|
31
|
-
&& same(Object.keys(value as object).sort(), [...keys].sort())); }
|
|
32
|
-
function timestamp(value: unknown): value is string {
|
|
33
|
-
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
|
|
34
|
-
const parsed = Date.parse(value); return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
35
|
-
}
|
|
36
|
-
function wireIdentity(value: BridgeIdentity): BridgeIdentity { return { projectRef: value.projectRef, runtimeInstanceId: value.runtimeInstanceId,
|
|
37
|
-
sessionRef: value.sessionRef, taskId: value.taskId, taskRunId: value.taskRunId, agentOperationId: null, toolCallId: null }; }
|
|
38
|
-
function wireRevisions(value: Record<string, any>): BridgeRevisions { return { runtimeRevision: value.runtimeRevision, taskRevision: value.taskRevision,
|
|
39
|
-
controlRevision: value.controlRevision, workspaceRevision: value.workspaceRevision, indexRevision: value.indexRevision,
|
|
40
|
-
approvalRevision: value.approvalRevision, sessionOptionRevision: value.sessionOptionRevision, queueRevision: value.queueRevision }; }
|
|
41
|
-
function replayIdentityDisposition(record: Pick<SourceMutationEvidenceRecord, "projectRef" | "runtimeInstanceId" | "sessionRef" | "taskId" | "taskRunId">,
|
|
42
|
-
identity: BridgeIdentity): "task-identity-mismatch" | "runtime-or-session-replaced" | null {
|
|
43
|
-
if (record.taskId !== identity.taskId || record.taskRunId !== identity.taskRunId) return "task-identity-mismatch";
|
|
44
|
-
if (record.projectRef !== identity.projectRef || record.runtimeInstanceId !== identity.runtimeInstanceId || record.sessionRef !== identity.sessionRef)
|
|
45
|
-
return "runtime-or-session-replaced";
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
function receipt(record: SourceMutationEvidenceRecord, deduplicated: boolean): SourceRevertReceipt {
|
|
49
|
-
const identity = { projectRef: record.projectRef, runtimeInstanceId: record.runtimeInstanceId, sessionRef: record.sessionRef,
|
|
50
|
-
taskId: record.taskId, taskRunId: record.taskRunId, agentOperationId: null, toolCallId: null } as BridgeIdentity;
|
|
51
|
-
const before = wireRevisions(record.observedRevisionsBefore), after = wireRevisions(record.observedRevisionsAfter ?? record.observedRevisionsBefore);
|
|
52
|
-
if (record.phase === "settled") return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt", commandId: record.commandId,
|
|
53
|
-
idempotencyKeyDigest: record.idempotencyKeyDigest, action: "source.revert", actionDigest: record.actionDigest, identity, phase: "settled", resultCode: "reverted",
|
|
54
|
-
requestedAt: record.requestedAt, settledAt: record.recordedAt, observedRevisionsBefore: before, observedRevisionsAfter: after, deduplicated,
|
|
55
|
-
auditRef: record.evidenceRef, settlementEvidenceRef: record.evidenceRef, error: null };
|
|
56
|
-
if (record.phase === "rejected") {
|
|
57
|
-
const stale = ["mutation-preimage-stale", "mutation-guard-precondition-stale"].includes(record.failureCode ?? "");
|
|
58
|
-
return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt", commandId: record.commandId,
|
|
59
|
-
idempotencyKeyDigest: record.idempotencyKeyDigest, action: "source.revert", actionDigest: record.actionDigest, identity, phase: "rejected",
|
|
60
|
-
resultCode: stale ? "stale-revision" : "capability-unavailable", requestedAt: record.requestedAt, settledAt: record.recordedAt,
|
|
61
|
-
observedRevisionsBefore: before, observedRevisionsAfter: after, deduplicated, auditRef: record.evidenceRef, settlementEvidenceRef: null,
|
|
62
|
-
error: { code: record.failureCode ?? "source-revert-rejected", message: "The Pi guard rejected the confirmed source revert before commit.", retryable: true } };
|
|
63
|
-
}
|
|
64
|
-
return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt", commandId: record.commandId,
|
|
65
|
-
idempotencyKeyDigest: record.idempotencyKeyDigest, action: "source.revert", actionDigest: record.actionDigest, identity, phase: "uncertain",
|
|
66
|
-
resultCode: "effect-unknown", requestedAt: record.requestedAt, settledAt: record.recordedAt, observedRevisionsBefore: before,
|
|
67
|
-
observedRevisionsAfter: after, deduplicated, auditRef: record.phase === "uncertain" ? record.evidenceRef : null,
|
|
68
|
-
settlementEvidenceRef: null, error: { code: "source-revert-effect-unknown", message: "The worktree effect could not be confirmed. Inspect the source tabs.", retryable: false } };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export class SourceRevertController {
|
|
72
|
-
readonly #bridge: SameSessionPiBridge; readonly #projectRoot: string;
|
|
73
|
-
readonly #resolve: (fileRef: string, hunkRefs: string[]) => Promise<{ projection: SourceRevertProjection; authority: SourceRevertAuthority | null }>;
|
|
74
|
-
readonly #revisions: () => Promise<Record<string, string | null>>;
|
|
75
|
-
readonly #mutate: RevertMutate;
|
|
76
|
-
readonly #now: () => Date; #serial: Promise<unknown> = Promise.resolve();
|
|
77
|
-
constructor(options: { bridge: SameSessionPiBridge; projectRoot: string;
|
|
78
|
-
resolve(fileRef: string, hunkRefs: string[]): Promise<{ projection: SourceRevertProjection; authority: SourceRevertAuthority | null }>;
|
|
79
|
-
revisions(): Promise<Record<string, string | null>>; mutate: RevertMutate; now?: () => Date }) {
|
|
80
|
-
this.#bridge = options.bridge; this.#projectRoot = options.projectRoot; this.#resolve = options.resolve;
|
|
81
|
-
this.#revisions = options.revisions; this.#mutate = options.mutate; this.#now = options.now ?? (() => new Date());
|
|
82
|
-
}
|
|
83
|
-
execute(value: unknown): Promise<SourceRevertReceipt> {
|
|
84
|
-
const run = this.#serial.then(() => this.#execute(value), () => this.#execute(value)); this.#serial = run.catch(() => undefined); return run;
|
|
85
|
-
}
|
|
86
|
-
async #execute(value: unknown): Promise<SourceRevertReceipt> {
|
|
87
|
-
const command = value as RevertCommand, snapshot = this.#bridge.snapshot(), invalid = this.#validate(value);
|
|
88
|
-
if (snapshot.state !== "ready" || !snapshot.identity || !snapshot.revisions) return this.#reject(command, "resync-required", "revert-binding-unavailable", true);
|
|
89
|
-
if (invalid) return this.#reject(command, "invalid-command", invalid, false);
|
|
90
|
-
const identity = wireIdentity(snapshot.identity), bridgeRevisions = wireRevisions(snapshot.revisions);
|
|
91
|
-
if (!identity.taskId || !identity.taskRunId || !bridgeRevisions.taskRevision) return this.#reject(command, "capability-unavailable", "active-task-required", false);
|
|
92
|
-
if (snapshot.taskState === "terminal") return this.#reject(command, "terminal-task", "task-terminal", false);
|
|
93
|
-
if (snapshot.liveness !== "idle") return this.#reject(command, "capability-unavailable", "agent-not-idle", true);
|
|
94
|
-
const evidence = readSourceMutationEvidence(this.#projectRoot, identity.taskRunId);
|
|
95
|
-
if (evidence.corruptions.length) return this.#reject(command, "resync-required", "mutation-evidence-corrupt", false);
|
|
96
|
-
const keyDigest = sha(command.idempotencyKey), prior = evidence.records.filter((record) => record.commandId === command.commandId || record.idempotencyKeyDigest === keyDigest);
|
|
97
|
-
if (prior.length) {
|
|
98
|
-
if (prior.some((record) => record.commandId !== command.commandId || record.idempotencyKeyDigest !== keyDigest || record.actionDigest !== command.actionDigest
|
|
99
|
-
|| record.action !== command.action)) return this.#reject(command, "idempotency-payload-mismatch", "idempotency-payload-mismatch", false);
|
|
100
|
-
const dispositions = prior.map((record) => replayIdentityDisposition(record, identity)).filter(Boolean);
|
|
101
|
-
if (dispositions.length) return this.#reject(command, "identity-mismatch",
|
|
102
|
-
dispositions.includes("task-identity-mismatch") ? "task-identity-mismatch" : "runtime-or-session-replaced", false);
|
|
103
|
-
return receipt(prior.at(-1)!, true);
|
|
104
|
-
}
|
|
105
|
-
const now = this.#now().getTime();
|
|
106
|
-
if (Date.parse(command.requestedAt) > now + 30_000) return this.#reject(command, "invalid-command", "requested-at-in-future", false);
|
|
107
|
-
if (now > Date.parse(command.expiresAt)) return this.#reject(command, "expired", "command-expired", false);
|
|
108
|
-
if (!same(command.identity, identity)) return this.#reject(command, "identity-mismatch", "identity-mismatch", false);
|
|
109
|
-
if (command.expectedRevisions.runtimeRevision !== bridgeRevisions.runtimeRevision || command.expectedRevisions.taskRevision !== bridgeRevisions.taskRevision
|
|
110
|
-
|| command.expectedRevisions.controlRevision !== bridgeRevisions.controlRevision) return this.#reject(command, "stale-revision", "stale-task-control-revision", true);
|
|
111
|
-
let resolved: { projection: SourceRevertProjection; authority: SourceRevertAuthority | null };
|
|
112
|
-
try { resolved = await this.#resolve(command.payload.fileRef, command.payload.hunkRefs); }
|
|
113
|
-
catch { return this.#reject(command, "capability-unavailable", "revert-preview-unavailable", true); }
|
|
114
|
-
const rebound = this.#bridge.snapshot();
|
|
115
|
-
if (rebound.state !== "ready" || !rebound.identity || !rebound.revisions)
|
|
116
|
-
return this.#reject(command, "resync-required", "revert-binding-unavailable-after-preview", true);
|
|
117
|
-
const reboundIdentity = wireIdentity(rebound.identity), reboundRevisions = wireRevisions(rebound.revisions);
|
|
118
|
-
if (!same(reboundIdentity, identity)) {
|
|
119
|
-
const taskChanged = reboundIdentity.taskId !== identity.taskId || reboundIdentity.taskRunId !== identity.taskRunId;
|
|
120
|
-
return this.#reject(command, "identity-mismatch", taskChanged ? "task-identity-mismatch" : "runtime-or-session-replaced", false);
|
|
121
|
-
}
|
|
122
|
-
if (rebound.taskState === "terminal") return this.#reject(command, "terminal-task", "task-terminal-after-preview", false);
|
|
123
|
-
if (rebound.liveness !== "idle") return this.#reject(command, "capability-unavailable", "agent-not-idle-after-preview", true);
|
|
124
|
-
if (command.expectedRevisions.runtimeRevision !== reboundRevisions.runtimeRevision
|
|
125
|
-
|| command.expectedRevisions.taskRevision !== reboundRevisions.taskRevision
|
|
126
|
-
|| command.expectedRevisions.controlRevision !== reboundRevisions.controlRevision)
|
|
127
|
-
return this.#reject(command, "stale-revision", "stale-task-control-revision-after-preview", true);
|
|
128
|
-
if (!resolved.authority || resolved.projection.state !== "ready" || !resolved.projection.target)
|
|
129
|
-
return this.#reject(command, "capability-unavailable", resolved.projection.reasonCode ?? "revert-preview-unavailable", true);
|
|
130
|
-
const target = resolved.projection.target;
|
|
131
|
-
if (!this.#matches(command, target) || this.#now().getTime() > Date.parse(target.expiresAt))
|
|
132
|
-
return this.#reject(command, "stale-revision", "revert-preview-stale", true);
|
|
133
|
-
const before = { ...bridgeRevisions, workspaceRevision: target.workspaceRevision, indexRevision: target.indexRevision };
|
|
134
|
-
let requested: SourceMutationEvidenceRecord;
|
|
135
|
-
try { requested = appendSourceMutationEvidence({ projectRoot: this.#projectRoot, projectRef: identity.projectRef, runtimeInstanceId: identity.runtimeInstanceId,
|
|
136
|
-
sessionRef: identity.sessionRef, taskId: identity.taskId, taskRunId: identity.taskRunId, commandId: command.commandId, idempotencyKeyDigest: keyDigest,
|
|
137
|
-
actionDigest: command.actionDigest, action: "source.revert", target, selectedHunkRefs: [...command.payload.hunkRefs], phase: "requested",
|
|
138
|
-
resultCode: "mutation-requested", failureCode: null, requestedAt: command.requestedAt, recordedAt: this.#now().toISOString(),
|
|
139
|
-
beforeIndexPreimage: target.indexPreimage, afterIndexPreimage: null, beforeWorkspacePreimage: target.workspacePreimage,
|
|
140
|
-
afterWorkspacePreimage: null, observedRevisionsBefore: before, observedRevisionsAfter: null }); }
|
|
141
|
-
catch { return this.#reject(command, "capability-unavailable", "mutation-evidence-unavailable", false); }
|
|
142
|
-
let effect: SourceIndexTransactionResult;
|
|
143
|
-
try { effect = await this.#mutate({ identity: { projectRef: identity.projectRef, runtimeInstanceId: identity.runtimeInstanceId,
|
|
144
|
-
sessionRef: identity.sessionRef, taskId: identity.taskId, taskRunId: identity.taskRunId }, action: "source.revert",
|
|
145
|
-
expectedTaskRevision: target.taskRevision, expectedControlRevision: bridgeRevisions.controlRevision!, expectedIndexPreimage: target.indexPreimage,
|
|
146
|
-
expectedWorkspacePreimage: target.workspacePreimage, previewRef: target.previewRef, confirmedPreviewDigest: target.confirmedPreviewDigest,
|
|
147
|
-
authority: resolved.authority }); }
|
|
148
|
-
catch { effect = { state: "rejected", reasonCode: "mutation-guard-unavailable", beforeIndexPreimage: target.indexPreimage,
|
|
149
|
-
afterIndexPreimage: null, beforeWorkspacePreimage: target.workspacePreimage, afterWorkspacePreimage: null, executor: "pi-guard", directExecution: false }; }
|
|
150
|
-
if (effect.executor !== "pi-guard" || effect.directExecution !== false) effect = { ...effect, state: "uncertain", reasonCode: "mutation-executor-untrusted",
|
|
151
|
-
afterIndexPreimage: null, afterWorkspacePreimage: null, executor: "pi-guard", directExecution: false };
|
|
152
|
-
let after: BridgeRevisions; try { after = wireRevisions(await this.#revisions()); } catch { after = before; }
|
|
153
|
-
const phase = effect.state, resultCode = effect.state === "settled" ? "reverted" : effect.state === "rejected" ? "mutation-rejected" : "effect-unknown";
|
|
154
|
-
try { const terminal = appendSourceMutationEvidence({ projectRoot: this.#projectRoot, projectRef: identity.projectRef,
|
|
155
|
-
runtimeInstanceId: identity.runtimeInstanceId, sessionRef: identity.sessionRef, taskId: identity.taskId, taskRunId: identity.taskRunId,
|
|
156
|
-
commandId: command.commandId, idempotencyKeyDigest: keyDigest, actionDigest: command.actionDigest, action: "source.revert", target,
|
|
157
|
-
selectedHunkRefs: [...command.payload.hunkRefs], phase, resultCode, failureCode: effect.reasonCode, requestedAt: command.requestedAt,
|
|
158
|
-
recordedAt: this.#now().toISOString(), beforeIndexPreimage: target.indexPreimage, afterIndexPreimage: effect.afterIndexPreimage,
|
|
159
|
-
beforeWorkspacePreimage: target.workspacePreimage, afterWorkspacePreimage: effect.afterWorkspacePreimage,
|
|
160
|
-
observedRevisionsBefore: before, observedRevisionsAfter: after }); return receipt(terminal, false); }
|
|
161
|
-
catch { return receipt(requested, false); }
|
|
162
|
-
}
|
|
163
|
-
#matches(command: RevertCommand, target: SourceRevertTarget): boolean {
|
|
164
|
-
return command.payload.fileRef === target.fileRef && same(command.payload.hunkRefs, target.hunkRefs) && command.payload.previewRef === target.previewRef
|
|
165
|
-
&& command.payload.confirmedPreviewDigest === target.confirmedPreviewDigest && command.payload.contentDigest === target.contentDigest
|
|
166
|
-
&& command.expectedRevisions.taskRevision === target.taskRevision && command.expectedRevisions.workspaceRevision === target.workspaceRevision
|
|
167
|
-
&& command.expectedRevisions.indexRevision === target.indexRevision && command.expectedRevisions.workspacePreimage === target.workspacePreimage
|
|
168
|
-
&& command.expectedRevisions.indexPreimage === target.indexPreimage && command.expectedRevisions.patchPreimage === target.patchPreimage;
|
|
169
|
-
}
|
|
170
|
-
#validate(value: unknown): string | null {
|
|
171
|
-
const command = value as RevertCommand;
|
|
172
|
-
if (!exactKeys(command, ["schemaVersion", "version", "messageType", "commandId", "idempotencyKey", "requestedAt", "expiresAt", "capabilityScope", "action", "actionDigest", "identity", "expectedRevisions", "payload"])
|
|
173
|
-
|| command.schemaVersion !== 1 || command.version !== "piagent-webui-control-v1" || command.messageType !== "command" || command.capabilityScope !== "reviewActions"
|
|
174
|
-
|| command.action !== "source.revert" || !REF.test(command.commandId) || !IDEMPOTENCY.test(command.idempotencyKey) || !timestamp(command.requestedAt)
|
|
175
|
-
|| !timestamp(command.expiresAt) || Date.parse(command.requestedAt) > Date.parse(command.expiresAt) || Date.parse(command.expiresAt) - Date.parse(command.requestedAt) > 300_000)
|
|
176
|
-
return "invalid-command-metadata";
|
|
177
|
-
if (!exactKeys(command.identity, ["projectRef", "runtimeInstanceId", "sessionRef", "taskId", "taskRunId", "agentOperationId", "toolCallId"])
|
|
178
|
-
|| ![command.identity.projectRef, command.identity.runtimeInstanceId, command.identity.sessionRef].every((item) => REF.test(item))
|
|
179
|
-
|| ![command.identity.taskId, command.identity.taskRunId].every((item) => typeof item === "string" && PUBLIC_REF.test(item))
|
|
180
|
-
|| command.identity.agentOperationId !== null || command.identity.toolCallId !== null) return "invalid-command-identity";
|
|
181
|
-
if (!exactKeys(command.expectedRevisions, [...REVISION_KEYS, "workspacePreimage", "indexPreimage", "patchPreimage"])
|
|
182
|
-
|| !REVISION.test(command.expectedRevisions.runtimeRevision) || !REVISION.test(command.expectedRevisions.taskRevision ?? "")
|
|
183
|
-
|| !REVISION.test(command.expectedRevisions.controlRevision ?? "")
|
|
184
|
-
|| !REVISION.test(command.expectedRevisions.workspaceRevision ?? "") || !REVISION.test(command.expectedRevisions.indexRevision ?? "")
|
|
185
|
-
|| [command.expectedRevisions.approvalRevision, command.expectedRevisions.sessionOptionRevision, command.expectedRevisions.queueRevision]
|
|
186
|
-
.some((item) => item !== null && !REVISION.test(String(item)))
|
|
187
|
-
|| !WORKSPACE.test(command.expectedRevisions.workspacePreimage) || !DIGEST.test(command.expectedRevisions.indexPreimage)
|
|
188
|
-
|| !DIGEST.test(command.expectedRevisions.patchPreimage)) return "invalid-revert-authority";
|
|
189
|
-
if (!exactKeys(command.payload, ["fileRef", "hunkRefs", "previewRef", "confirmedPreviewDigest", "contentDigest"])
|
|
190
|
-
|| !REF.test(command.payload.fileRef) || !REF.test(command.payload.previewRef) || !Array.isArray(command.payload.hunkRefs)
|
|
191
|
-
|| command.payload.hunkRefs.length > 1 || new Set(command.payload.hunkRefs).size !== command.payload.hunkRefs.length
|
|
192
|
-
|| command.payload.hunkRefs.some((item) => !REF.test(item)) || !DIGEST.test(command.payload.confirmedPreviewDigest)
|
|
193
|
-
|| !DIGEST.test(command.payload.contentDigest)) return "invalid-revert-payload";
|
|
194
|
-
if (!DIGEST.test(command.actionDigest) || command.actionDigest !== controlActionDigest(command)) return "action-digest-mismatch";
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
#reject(command: RevertCommand, resultCode: SourceRevertReceipt["resultCode"], code: string, retryable: boolean): SourceRevertReceipt {
|
|
198
|
-
const snapshot = this.#bridge.snapshot(), at = this.#now().toISOString(), observed = snapshot.identity ? wireIdentity(snapshot.identity) : null;
|
|
199
|
-
const identity = observed ? { ...observed, taskId: observed.taskId ?? "task.unavailable", taskRunId: observed.taskRunId ?? "task-run.unavailable" }
|
|
200
|
-
: { projectRef: "project.unavailable", runtimeInstanceId: "runtime.unavailable", sessionRef: "session.unavailable", taskId: "task.unavailable",
|
|
201
|
-
taskRunId: "task-run.unavailable", agentOperationId: null, toolCallId: null } as BridgeIdentity;
|
|
202
|
-
const revisions = snapshot.revisions ? wireRevisions(snapshot.revisions) : { runtimeRevision: "runtime.unavailable", taskRevision: null, controlRevision: null,
|
|
203
|
-
workspaceRevision: null, indexRevision: null, approvalRevision: null, sessionOptionRevision: null, queueRevision: null };
|
|
204
|
-
return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt",
|
|
205
|
-
commandId: typeof command?.commandId === "string" && REF.test(command.commandId) ? command.commandId : `revert-rejected.${reviewDigest(command ?? null).slice(7)}`,
|
|
206
|
-
idempotencyKeyDigest: sha(typeof command?.idempotencyKey === "string" ? command.idempotencyKey : canonicalReviewValue(command ?? null)), action: "source.revert",
|
|
207
|
-
actionDigest: typeof command?.actionDigest === "string" && DIGEST.test(command.actionDigest) ? command.actionDigest : sha(code), identity,
|
|
208
|
-
phase: "rejected", resultCode, requestedAt: timestamp(command?.requestedAt) ? command.requestedAt : at, settledAt: at,
|
|
209
|
-
observedRevisionsBefore: revisions, observedRevisionsAfter: revisions, deduplicated: false, auditRef: null, settlementEvidenceRef: null,
|
|
210
|
-
error: { code, message: "The confirmed exact source revert could not be completed.", retryable } };
|
|
7
|
+
// The legacy extension retains its task evidence store; Gateway supplies its own binding.
|
|
8
|
+
export class SourceRevertController extends SharedSourceRevertController {
|
|
9
|
+
constructor(options: Omit<ConstructorParameters<typeof SharedSourceRevertController<SourceRevertAuthority>>[0], "evidence" | "allowSessionReverts">) {
|
|
10
|
+
super({ ...options, allowSessionReverts: false, evidence: {
|
|
11
|
+
read: (root, run) => run ? readSourceMutationEvidence(root, run) : { records: [], corruptions: ["active-task-required"] },
|
|
12
|
+
append: value => {
|
|
13
|
+
if (!value.taskId || !value.taskRunId) throw new Error("active-task-required");
|
|
14
|
+
return appendSourceMutationEvidence({ ...value, taskId: value.taskId, taskRunId: value.taskRunId });
|
|
15
|
+
}
|
|
16
|
+
} });
|
|
211
17
|
}
|
|
212
18
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { checkpointAuthority } from '../../piagent-core/runtime/checkpoints/checkpoint-authority.ts';
|
|
2
|
+
import { workspaceWriteAuthority } from '../../piagent-core/runtime/policy/workspace-write-authority.ts';
|
|
3
|
+
import { CheckpointController } from '../server/checkpoint-controller.ts';
|
|
4
|
+
import { CHECKPOINT_VERSION, sameCheckpointValue } from '../server/checkpoint-command.ts';
|
|
5
|
+
import type { CheckpointPreview } from '../contracts/generated/checkpoint-v1.ts';
|
|
6
|
+
import type { SessionRuntimeSupervisor } from './session-runtime-supervisor.ts';
|
|
7
|
+
import { sessionRefForPath, projectRefForCwd } from './session-catalog.ts';
|
|
8
|
+
import { CheckpointReceiptStore } from './checkpoint-store.ts';
|
|
9
|
+
import { transcriptMessageRef } from '../server/transcript-projection.ts';
|
|
10
|
+
import { webUiSessionRef } from '../../piagent-core/runtime/inspection/webui-snapshot.ts';
|
|
11
|
+
import type { CheckpointPort } from '../../piagent-core/runtime/checkpoints/checkpoint-authority.ts';
|
|
12
|
+
|
|
13
|
+
export type ManagedCheckpointRuntime = { current(): boolean; writable(): Promise<boolean>; port: CheckpointPort };
|
|
14
|
+
|
|
15
|
+
type Options = { root: string; key: Buffer; gatewayInstanceRef: string; managed: boolean;
|
|
16
|
+
runtimes: Pick<SessionRuntimeSupervisor, 'listSessions' | 'acquire' | 'liveSessionManager' | 'ownership' | 'currentOperation'>;
|
|
17
|
+
managedCheckpoint?(id: string): ManagedCheckpointRuntime;
|
|
18
|
+
changed(sessionRef: string): void };
|
|
19
|
+
|
|
20
|
+
export class CheckpointSessions {
|
|
21
|
+
#options: Options;
|
|
22
|
+
#store: CheckpointReceiptStore;
|
|
23
|
+
#controllers = new Map<string, CheckpointController>();
|
|
24
|
+
constructor(options: Options) { this.#options = options; this.#store = new CheckpointReceiptStore(options.root, options.key); }
|
|
25
|
+
async #live(sessionRef: string) {
|
|
26
|
+
const { runtimes, key, gatewayInstanceRef } = this.#options;
|
|
27
|
+
if (this.#options.managed && !this.#options.managedCheckpoint) throw Error('checkpoint-company-boundary-required');
|
|
28
|
+
const matches = (await runtimes.listSessions()).filter(info => sessionRefForPath(key, info.path) === sessionRef);
|
|
29
|
+
if (matches.length !== 1) throw Error('checkpoint-session-unavailable');
|
|
30
|
+
const info = matches[0]!, lease = await runtimes.acquire(sessionRef), manager = runtimes.liveSessionManager(sessionRef);
|
|
31
|
+
if (!manager || !lease.runtimeInstanceRef || manager.getSessionId() !== info.id) throw Error('checkpoint-session-unavailable');
|
|
32
|
+
const managed = this.#options.managed ? this.#options.managedCheckpoint!(info.id) : null;
|
|
33
|
+
const port = managed ? { ...managed.port, current: managed.current } : checkpointAuthority.resolve(info.cwd, info.id);
|
|
34
|
+
const assertCurrent = () => {
|
|
35
|
+
const ownership = runtimes.ownership(sessionRef);
|
|
36
|
+
if (!port.current() || runtimes.liveSessionManager(sessionRef) !== manager || manager.getSessionId() !== info.id
|
|
37
|
+
|| ownership.state !== 'gateway-owned' || ownership.owner.continuity !== 'exact'
|
|
38
|
+
|| ownership.owner.gatewayInstanceRef !== gatewayInstanceRef || ownership.owner.runtimeInstanceRef !== lease.runtimeInstanceRef
|
|
39
|
+
|| ownership.owner.ownerEpoch !== lease.ownerEpoch) throw Error('checkpoint-runtime-replaced');
|
|
40
|
+
if (!managed && manager.buildSessionContext().model?.provider === 'agent_watch_managed') throw Error('checkpoint-company-boundary-required');
|
|
41
|
+
};
|
|
42
|
+
assertCurrent();
|
|
43
|
+
const identity = { projectRef: projectRefForCwd(key, info.cwd), sessionRef, runtimeInstanceId: lease.runtimeInstanceRef,
|
|
44
|
+
taskId: null, taskRunId: null, agentOperationId: null, toolCallId: null };
|
|
45
|
+
const writable = async () => {
|
|
46
|
+
assertCurrent();
|
|
47
|
+
const value = managed ? await managed.writable() : workspaceWriteAuthority.writable(info.cwd, info.id);
|
|
48
|
+
assertCurrent(); return value;
|
|
49
|
+
};
|
|
50
|
+
return { info, port, assertCurrent, identity, writable, managed: !!managed };
|
|
51
|
+
}
|
|
52
|
+
async #facts(sessionRef: string) {
|
|
53
|
+
const binding = await this.#live(sessionRef), listed = await binding.port.list();
|
|
54
|
+
binding.assertCurrent();
|
|
55
|
+
return { identity: binding.identity, revisions: { checkpointRevision: listed.revision },
|
|
56
|
+
points: listed.points.map(point => ({ ...point, turnRef: transcriptMessageRef(webUiSessionRef(binding.info.id), point.turnRef) })),
|
|
57
|
+
available: listed.available, agentRunning: Boolean(this.#options.runtimes.currentOperation(sessionRef)),
|
|
58
|
+
writable: await binding.writable() };
|
|
59
|
+
}
|
|
60
|
+
#controller(sessionRef: string) {
|
|
61
|
+
let controller = this.#controllers.get(sessionRef);
|
|
62
|
+
if (!controller) {
|
|
63
|
+
if (this.#controllers.size >= 1024) throw Error('checkpoint-session-capacity');
|
|
64
|
+
controller = new CheckpointController({
|
|
65
|
+
facts: () => this.#facts(sessionRef), history: () => this.#store.history(sessionRef),
|
|
66
|
+
append: receipt => {
|
|
67
|
+
this.#store.append(sessionRef, receipt);
|
|
68
|
+
if (receipt.phase === 'settled' || receipt.phase === 'uncertain') this.#options.changed(sessionRef);
|
|
69
|
+
},
|
|
70
|
+
restore: async (target, expected, identity) => {
|
|
71
|
+
const binding = await this.#live(sessionRef);
|
|
72
|
+
if (!sameCheckpointValue(identity, binding.identity)) throw Error('checkpoint-runtime-replaced');
|
|
73
|
+
if (!await binding.writable()) throw Error('checkpoint-read-only');
|
|
74
|
+
const guard = () => {
|
|
75
|
+
binding.assertCurrent();
|
|
76
|
+
if (this.#options.runtimes.currentOperation(sessionRef)) throw Error('checkpoint-session-busy');
|
|
77
|
+
if (!binding.managed && !workspaceWriteAuthority.writable(binding.info.cwd, binding.info.id)) throw Error('checkpoint-read-only');
|
|
78
|
+
};
|
|
79
|
+
guard();
|
|
80
|
+
return await binding.port.restoreTo(target, expected, guard);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
this.#controllers.set(sessionRef, controller);
|
|
84
|
+
}
|
|
85
|
+
return controller;
|
|
86
|
+
}
|
|
87
|
+
state(sessionRef: string) { return this.#controller(sessionRef).state(); }
|
|
88
|
+
execute(sessionRef: string, command: unknown) { return this.#controller(sessionRef).execute(command); }
|
|
89
|
+
async preview(sessionRef: string, target: string): Promise<CheckpointPreview> {
|
|
90
|
+
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(target)) throw Error('invalid-checkpoint-target');
|
|
91
|
+
const binding = await this.#live(sessionRef), preview = await binding.port.preview(target);
|
|
92
|
+
binding.assertCurrent();
|
|
93
|
+
return { schemaVersion: 1, version: CHECKPOINT_VERSION, messageType: 'preview', identity: binding.identity,
|
|
94
|
+
revisions: { checkpointRevision: preview.revision }, targetId: target, result: preview.result as CheckpointPreview['result'],
|
|
95
|
+
previewDigest: preview.digest, tree: preview.tree,
|
|
96
|
+
steps: preview.steps.map(step => ({ ...step, turnRef: transcriptMessageRef(webUiSessionRef(binding.info.id), step.turnRef) })), files: preview.files,
|
|
97
|
+
conflicts: preview.conflicts, stoppedAt: preview.stoppedAt };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CheckpointReceipt } from '../contracts/generated/checkpoint-v1.ts';
|
|
2
|
+
import { SignedSessionJournal } from './signed-session-journal.ts';
|
|
3
|
+
|
|
4
|
+
export class CheckpointReceiptStore extends SignedSessionJournal<CheckpointReceipt> {
|
|
5
|
+
constructor(root: string, key: Buffer) { super(root, key, 'checkpoint-receipts'); }
|
|
6
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { WebSocket } from "ws";
|
|
3
|
+
import { CompanyTerminalRelay } from "./company-terminal-relay.ts";
|
|
3
4
|
|
|
4
5
|
import type { Catalog, SessionRow } from "../contracts/generated/session-catalog-v1.ts";
|
|
5
6
|
import { webUiModelRef } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
|
|
@@ -41,6 +42,7 @@ const timedOut = (error: unknown) => error instanceof Error && (error.name === "
|
|
|
41
42
|
const failure = (error: unknown, fallback: string) => error instanceof Error && /^[a-z][a-z0-9:._-]{2,95}$/.test(error.message) ? error.message : fallback;
|
|
42
43
|
|
|
43
44
|
export class CompanyRelay {
|
|
45
|
+
readonly terminals: CompanyTerminalRelay;
|
|
44
46
|
readonly #connector: CompanyConnector;
|
|
45
47
|
readonly #events: GatewayEventStore;
|
|
46
48
|
readonly #hubProject: (cwd: string) => string;
|
|
@@ -70,6 +72,8 @@ export class CompanyRelay {
|
|
|
70
72
|
this.#connector = options.connector; this.#events = options.events;
|
|
71
73
|
this.#hubProject = options.hubProject; this.#folder = options.folder;
|
|
72
74
|
this.#readTimeoutMs = options.readTimeoutMs ?? READ_TIMEOUT_MS; this.#answerTimeoutMs = options.answerTimeoutMs ?? ANSWER_TIMEOUT_MS;
|
|
75
|
+
this.terminals = new CompanyTerminalRelay({ owns: ref => this.owns(ref),
|
|
76
|
+
link: async () => { const link = await this.#ready(); return this.#bootstrap(link.socket); } });
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
status(): CompanyStatus {
|
|
@@ -123,6 +127,7 @@ export class CompanyRelay {
|
|
|
123
127
|
|
|
124
128
|
close(): void {
|
|
125
129
|
this.#closed = true;
|
|
130
|
+
this.terminals.close();
|
|
126
131
|
if (this.#retry) clearTimeout(this.#retry);
|
|
127
132
|
this.#ws?.terminate();
|
|
128
133
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TerminalState, TerminalOutput, TerminalSummary } from '../contracts/generated/terminal-v1.ts';
|
|
2
|
+
import type { TerminalStreamMessage } from '../server/terminal-websocket-stream.ts';
|
|
3
|
+
import { TerminalOutputBuffer } from './terminal-output-buffer.ts';
|
|
4
|
+
|
|
5
|
+
const identityKey = (value: TerminalState['identity']) => JSON.stringify([value.gatewayInstanceRef, value.runtimeInstanceRef, value.sessionRef]);
|
|
6
|
+
type Entry = { buffer: TerminalOutputBuffer; sequence: number; truncated: boolean };
|
|
7
|
+
// The relay keeps only the same bounded, redacted replay the company Gateway
|
|
8
|
+
// provides. Live bytes never enter the durable event stream or browser storage.
|
|
9
|
+
export class CompanyTerminalCache {
|
|
10
|
+
state: TerminalState | null = null;
|
|
11
|
+
readonly #tabs = new Map<string, Entry>();
|
|
12
|
+
accept(message: TerminalStreamMessage) {
|
|
13
|
+
if (message.messageType === 'state') {
|
|
14
|
+
if (this.state && identityKey(this.state.identity) !== identityKey(message.identity)) this.#tabs.clear();
|
|
15
|
+
this.state = message;
|
|
16
|
+
for (const ref of this.#tabs.keys()) if (!(message.terminals as readonly TerminalSummary[]).some(tab => tab.terminalRef === ref)) this.#tabs.delete(ref);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (!this.state || identityKey(this.state.identity) !== identityKey(message.identity)) throw Error('company-terminal-identity-changed');
|
|
20
|
+
let entry = this.#tabs.get(message.terminalRef);
|
|
21
|
+
if (!entry) {
|
|
22
|
+
if (this.#tabs.size >= 4) throw Error('company-terminal-capacity');
|
|
23
|
+
entry = { buffer: new TerminalOutputBuffer(), sequence: 0, truncated: false }; this.#tabs.set(message.terminalRef, entry);
|
|
24
|
+
}
|
|
25
|
+
if (message.messageType === 'output') {
|
|
26
|
+
if (message.replay && message.reset) { entry.buffer = new TerminalOutputBuffer(); entry.truncated = message.truncated; }
|
|
27
|
+
else if (!message.replay && message.sequence <= entry.sequence) return;
|
|
28
|
+
entry.buffer.append(message.data);
|
|
29
|
+
}
|
|
30
|
+
entry.sequence = message.sequence;
|
|
31
|
+
}
|
|
32
|
+
disconnected() {
|
|
33
|
+
if (!this.state) return null;
|
|
34
|
+
this.state = { ...this.state, available: false, reasonCode: 'company-terminal-disconnected' };
|
|
35
|
+
return this.state;
|
|
36
|
+
}
|
|
37
|
+
replay(): TerminalOutput[] {
|
|
38
|
+
if (!this.state) return [];
|
|
39
|
+
const { identity } = this.state;
|
|
40
|
+
return (this.state.terminals as readonly TerminalSummary[]).flatMap(tab => {
|
|
41
|
+
const entry = this.#tabs.get(tab.terminalRef), snapshot = entry?.buffer.snapshot() ?? { text: '', truncated: false };
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
for (let start = 0; start < snapshot.text.length;) {
|
|
44
|
+
let end = Math.min(start + 8192, snapshot.text.length);
|
|
45
|
+
if (end < snapshot.text.length && /[\uD800-\uDBFF]/.test(snapshot.text[end - 1]!)) end--;
|
|
46
|
+
parts.push(snapshot.text.slice(start, end)); start = end;
|
|
47
|
+
}
|
|
48
|
+
if (!parts.length) parts.push('');
|
|
49
|
+
return parts.map((data, index): TerminalOutput => ({ schemaVersion: 1, version: 'piagent-terminal-v1', messageType: 'output', identity,
|
|
50
|
+
terminalRef: tab.terminalRef, sequence: entry?.sequence ?? tab.sequence, data, replay: true,
|
|
51
|
+
reset: index === 0, complete: index === parts.length - 1, truncated: snapshot.truncated || Boolean(entry?.truncated) }));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
import type { TerminalCommand, TerminalReceipt, TerminalState, TerminalSummary } from '../contracts/generated/terminal-v1.ts';
|
|
4
|
+
import type { TerminalStreamMessage } from '../server/terminal-websocket-stream.ts';
|
|
5
|
+
import { isTerminalMessage } from '../shared/terminal-message.ts';
|
|
6
|
+
import { CompanyTerminalCache } from './company-terminal-cache.ts';
|
|
7
|
+
|
|
8
|
+
export type CompanyTerminalLink = { origin: string; cookie: string; csrf: string };
|
|
9
|
+
type Pending = { method: string; resolve(value: unknown): void; reject(error: Error): void; timer: NodeJS.Timeout;
|
|
10
|
+
replay?: Map<string, number>; result?: TerminalState };
|
|
11
|
+
|
|
12
|
+
export class CompanyTerminalChannel {
|
|
13
|
+
readonly cache = new CompanyTerminalCache();
|
|
14
|
+
readonly #socket: WebSocket;
|
|
15
|
+
readonly #ref: string;
|
|
16
|
+
readonly #csrf: string;
|
|
17
|
+
readonly #emit: (message: TerminalStreamMessage) => void;
|
|
18
|
+
readonly #pending = new Map<string, Pending>();
|
|
19
|
+
readonly ready: Promise<void>;
|
|
20
|
+
#read: Promise<TerminalState> | null = null;
|
|
21
|
+
#closed = false;
|
|
22
|
+
get closed() { return this.#closed; }
|
|
23
|
+
constructor(ref: string, link: CompanyTerminalLink, emit: (message: TerminalStreamMessage) => void, disconnected: () => void) {
|
|
24
|
+
if (!/^http:\/\/127\.0\.0\.1:\d{1,5}$/.test(link.origin)) throw Error('company-gateway-invalid');
|
|
25
|
+
this.#ref = ref; this.#csrf = link.csrf; this.#emit = emit;
|
|
26
|
+
const ws = this.#socket = new WebSocket(`${link.origin.replace('http:', 'ws:')}/api/v1/gateway`, 'piagent.gateway.v1',
|
|
27
|
+
{ headers: { Origin: link.origin, Cookie: link.cookie }, maxPayload: 70_000, perMessageDeflate: false });
|
|
28
|
+
this.ready = new Promise((resolve, reject) => {
|
|
29
|
+
let hello = false;
|
|
30
|
+
const timer = setTimeout(() => { reject(Error('company-terminal-connect-timeout')); ws.terminate(); }, 5000);
|
|
31
|
+
ws.on('open', () => ws.send(JSON.stringify({ schemaVersion: 1, version: 'piagent-gateway-protocol-v1', messageType: 'connect',
|
|
32
|
+
clientRef: 'dashboard-company-terminal', minimumProtocol: 1, maximumProtocol: 1, lastEventSequence: null, catalogRevision: null })));
|
|
33
|
+
ws.on('message', data => {
|
|
34
|
+
try {
|
|
35
|
+
const frame = JSON.parse(String(data));
|
|
36
|
+
if (!hello && frame.version === 'piagent-gateway-protocol-v1' && frame.messageType === 'hello') {
|
|
37
|
+
hello = true; clearTimeout(timer); resolve(); return;
|
|
38
|
+
}
|
|
39
|
+
if (!hello) throw Error();
|
|
40
|
+
this.#message(frame);
|
|
41
|
+
} catch { ws.close(1008, 'company-terminal-protocol-invalid'); }
|
|
42
|
+
});
|
|
43
|
+
ws.on('error', () => {});
|
|
44
|
+
ws.once('close', () => {
|
|
45
|
+
clearTimeout(timer); this.#closed = true;
|
|
46
|
+
reject(Error('company-terminal-disconnected'));
|
|
47
|
+
for (const pending of this.#pending.values()) { clearTimeout(pending.timer); pending.reject(Error('company-terminal-disconnected')); }
|
|
48
|
+
this.#pending.clear();
|
|
49
|
+
const state = this.cache.disconnected(); if (state) this.#emit(state);
|
|
50
|
+
disconnected();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
#message(frame: any) {
|
|
55
|
+
if (frame.version === 'piagent-gateway-protocol-v1') {
|
|
56
|
+
if (frame.messageType !== 'response') return; // General session events are owned by CompanyRelay.
|
|
57
|
+
const pending = this.#pending.get(frame.requestId); if (!pending) return;
|
|
58
|
+
if (frame.method !== pending.method) throw Error();
|
|
59
|
+
if (frame.ok !== true) {
|
|
60
|
+
clearTimeout(pending.timer); this.#pending.delete(frame.requestId);
|
|
61
|
+
pending.reject(Error('company-terminal-request-rejected')); return;
|
|
62
|
+
}
|
|
63
|
+
if (pending.method === 'terminals.state') {
|
|
64
|
+
if (!isTerminalMessage(frame.result) || frame.result.messageType !== 'state' || frame.result.identity.sessionRef !== this.#ref) throw Error();
|
|
65
|
+
const state = frame.result as TerminalState; this.cache.accept(state); this.#emit(state);
|
|
66
|
+
pending.result = state; pending.replay = new Map((state.terminals as readonly TerminalSummary[]).map(tab => [tab.terminalRef, tab.sequence]));
|
|
67
|
+
if (pending.replay.size) return;
|
|
68
|
+
}
|
|
69
|
+
clearTimeout(pending.timer); this.#pending.delete(frame.requestId); pending.resolve(frame.result); return;
|
|
70
|
+
}
|
|
71
|
+
if (!isTerminalMessage(frame) || frame.identity.sessionRef !== this.#ref) throw Error();
|
|
72
|
+
const message = frame as TerminalStreamMessage;
|
|
73
|
+
this.cache.accept(message); this.#emit(message);
|
|
74
|
+
if (message.messageType === 'output' && message.replay && message.complete) {
|
|
75
|
+
for (const [id, pending] of this.#pending) if (pending.replay?.get(message.terminalRef) === message.sequence) {
|
|
76
|
+
pending.replay.delete(message.terminalRef);
|
|
77
|
+
if (!pending.replay.size) {
|
|
78
|
+
clearTimeout(pending.timer); this.#pending.delete(id); pending.resolve(this.cache.state ?? pending.result);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async #request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
84
|
+
await this.ready;
|
|
85
|
+
if (this.#closed || this.#socket.readyState !== WebSocket.OPEN) throw Error('company-terminal-disconnected');
|
|
86
|
+
if (this.#pending.size >= 64 || this.#socket.bufferedAmount > 512 * 1024) throw Error('company-terminal-capacity');
|
|
87
|
+
const requestId = `terminal_${randomUUID().replaceAll('-', '')}`;
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
this.#pending.delete(requestId); reject(Error('company-terminal-response-timeout')); this.#socket.terminate();
|
|
91
|
+
}, 15000);
|
|
92
|
+
this.#pending.set(requestId, { method, resolve, reject, timer });
|
|
93
|
+
this.#socket.send(JSON.stringify({ schemaVersion: 1, version: 'piagent-gateway-protocol-v1', messageType: 'request',
|
|
94
|
+
requestId, method, params: { ...params, csrfToken: this.#csrf } }), error => { if (error) this.#socket.terminate(); });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
state(): Promise<TerminalState> {
|
|
98
|
+
this.#read ??= (this.#request('terminals.state', { sessionRef: this.#ref }) as Promise<TerminalState>).finally(() => { this.#read = null; });
|
|
99
|
+
return this.#read;
|
|
100
|
+
}
|
|
101
|
+
async execute(command: TerminalCommand): Promise<TerminalReceipt> {
|
|
102
|
+
if (command.identity.sessionRef !== this.#ref) throw Error('company-terminal-session-mismatch');
|
|
103
|
+
const value = await this.#request('terminals.command', { command }) as TerminalReceipt;
|
|
104
|
+
if (!value || value.schemaVersion !== 1 || value.version !== command.version || value.messageType !== 'receipt'
|
|
105
|
+
|| value.commandId !== command.commandId || value.idempotencyKey !== command.idempotencyKey || value.actionDigest !== command.actionDigest
|
|
106
|
+
|| value.action !== command.action || !value.identity || !Object.entries(command.identity).every(([key, item]) => (value.identity as any)[key] === item)
|
|
107
|
+
|| !value.observedRevisions || typeof value.observedRevisions.terminalRevision !== 'string'
|
|
108
|
+
|| !['settled', 'rejected', 'uncertain'].includes(value.phase) || typeof value.deduplicated !== 'boolean'
|
|
109
|
+
|| !(value.terminalRef === null || typeof value.terminalRef === 'string') || !(value.reasonCode === null || typeof value.reasonCode === 'string'))
|
|
110
|
+
throw Error('company-terminal-receipt-invalid');
|
|
111
|
+
// Project only contract fields; an accidental upstream private field cannot travel to the browser.
|
|
112
|
+
return { schemaVersion: 1, version: value.version, messageType: 'receipt', commandId: value.commandId, idempotencyKey: value.idempotencyKey,
|
|
113
|
+
action: value.action, actionDigest: value.actionDigest, identity: command.identity, observedRevisions: { terminalRevision: value.observedRevisions.terminalRevision },
|
|
114
|
+
phase: value.phase, terminalRef: value.terminalRef, reasonCode: value.reasonCode, deduplicated: value.deduplicated };
|
|
115
|
+
}
|
|
116
|
+
close() { this.#closed = true; this.#socket.terminate(); }
|
|
117
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { TerminalOutput, TerminalState } from '../contracts/generated/terminal-v1.ts';
|
|
2
|
+
import type { TerminalStreamMessage } from '../server/terminal-websocket-stream.ts';
|
|
3
|
+
import { parseTerminalCommand } from '../server/terminal-command.ts';
|
|
4
|
+
import { CompanyTerminalChannel, type CompanyTerminalLink } from './company-terminal-channel.ts';
|
|
5
|
+
|
|
6
|
+
type Entry = { listeners: Set<(message: TerminalStreamMessage) => void>; channel: CompanyTerminalChannel | null;
|
|
7
|
+
connecting: Promise<CompanyTerminalChannel> | null; retry: NodeJS.Timeout | null; idle: NodeJS.Timeout | null };
|
|
8
|
+
|
|
9
|
+
export class CompanyTerminalRelay {
|
|
10
|
+
readonly #link: () => Promise<CompanyTerminalLink>;
|
|
11
|
+
readonly #owns: (ref: string) => boolean;
|
|
12
|
+
readonly #entries = new Map<string, Entry>();
|
|
13
|
+
#closed = false;
|
|
14
|
+
constructor(options: { link(): Promise<CompanyTerminalLink>; owns(ref: string): boolean }) { this.#link = options.link; this.#owns = options.owns; }
|
|
15
|
+
#entry(ref: string): Entry {
|
|
16
|
+
if (this.#closed || !this.#owns(ref)) throw Error('company-terminal-session-unavailable');
|
|
17
|
+
let entry = this.#entries.get(ref);
|
|
18
|
+
if (!entry) {
|
|
19
|
+
if (this.#entries.size >= 32) throw Error('company-terminal-capacity');
|
|
20
|
+
entry = { listeners: new Set(), channel: null, connecting: null, retry: null, idle: null }; this.#entries.set(ref, entry);
|
|
21
|
+
}
|
|
22
|
+
if (entry.idle) clearTimeout(entry.idle);
|
|
23
|
+
const current = entry;
|
|
24
|
+
entry.idle = setTimeout(() => { if (!current.listeners.size) this.#remove(ref, current); }, 30000); entry.idle.unref();
|
|
25
|
+
return entry;
|
|
26
|
+
}
|
|
27
|
+
#retry(ref: string, entry: Entry) {
|
|
28
|
+
if (this.#closed || this.#entries.get(ref) !== entry || !entry.listeners.size || entry.retry) return;
|
|
29
|
+
entry.retry = setTimeout(() => {
|
|
30
|
+
entry.retry = null;
|
|
31
|
+
void this.state(ref).catch(() => this.#retry(ref, entry));
|
|
32
|
+
}, 3000); entry.retry.unref();
|
|
33
|
+
}
|
|
34
|
+
async #channel(ref: string, entry: Entry): Promise<CompanyTerminalChannel> {
|
|
35
|
+
if (entry.channel && !entry.channel.closed) { await entry.channel.ready; return entry.channel; }
|
|
36
|
+
entry.connecting ??= (async () => {
|
|
37
|
+
const link = await this.#link();
|
|
38
|
+
if (this.#closed || this.#entries.get(ref) !== entry || !this.#owns(ref)) throw Error('company-terminal-session-unavailable');
|
|
39
|
+
const channel = new CompanyTerminalChannel(ref, link, message => {
|
|
40
|
+
if (this.#entries.get(ref) !== entry || entry.channel !== channel) return;
|
|
41
|
+
for (const listener of entry.listeners) try { listener(message); } catch { /* browser stream owns its backpressure */ }
|
|
42
|
+
}, () => { if (entry.channel === channel) this.#retry(ref, entry); });
|
|
43
|
+
entry.channel = channel; await channel.ready;
|
|
44
|
+
if (entry.retry) { clearTimeout(entry.retry); entry.retry = null; }
|
|
45
|
+
return channel;
|
|
46
|
+
})().finally(() => { entry.connecting = null; });
|
|
47
|
+
return entry.connecting;
|
|
48
|
+
}
|
|
49
|
+
async state(ref: string): Promise<TerminalState> {
|
|
50
|
+
const entry = this.#entry(ref);
|
|
51
|
+
return (await this.#channel(ref, entry)).state();
|
|
52
|
+
}
|
|
53
|
+
async execute(value: unknown) {
|
|
54
|
+
const command = parseTerminalCommand(value), ref = command.identity.sessionRef, entry = this.#entry(ref);
|
|
55
|
+
const channel = await this.#channel(ref, entry);
|
|
56
|
+
if (!channel.cache.state) await channel.state();
|
|
57
|
+
// Exactly one dispatch. Only an explicit client retry may send this command again.
|
|
58
|
+
return channel.execute(command);
|
|
59
|
+
}
|
|
60
|
+
subscribe(ref: string, listener: (message: TerminalStreamMessage) => void) {
|
|
61
|
+
const entry = this.#entry(ref);
|
|
62
|
+
if (entry.listeners.size >= 32) throw Error('company-terminal-subscription-capacity');
|
|
63
|
+
entry.listeners.add(listener);
|
|
64
|
+
return () => { entry.listeners.delete(listener); if (!entry.listeners.size) this.#remove(ref, entry); };
|
|
65
|
+
}
|
|
66
|
+
replay(ref: string): TerminalOutput[] { return this.#entries.get(ref)?.channel?.cache.replay() ?? []; }
|
|
67
|
+
#remove(ref: string, entry: Entry) {
|
|
68
|
+
if (this.#entries.get(ref) !== entry) return;
|
|
69
|
+
this.#entries.delete(ref);
|
|
70
|
+
if (entry.retry) clearTimeout(entry.retry);
|
|
71
|
+
if (entry.idle) clearTimeout(entry.idle);
|
|
72
|
+
entry.channel?.close();
|
|
73
|
+
}
|
|
74
|
+
close() { this.#closed = true; for (const [ref, entry] of this.#entries) this.#remove(ref, entry); }
|
|
75
|
+
}
|