@piagent/platform 1.37.0 → 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 +70 -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 +3 -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 +23 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +23 -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 +24 -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 +24 -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/helper-run.mjs +11 -1
- package/packages/piagent-core/managed/helper-settlement.mjs +17 -6
- package/packages/piagent-core/managed/ip-policy.mjs +50 -0
- package/packages/piagent-core/managed/memory-lessons.mjs +4 -1
- package/packages/piagent-core/managed/memory.mjs +1 -1
- package/packages/piagent-core/managed/package-proxy.mjs +12 -14
- 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 +6 -7
- package/packages/piagent-core/managed/skill-feedback.mjs +9 -1
- package/packages/piagent-core/managed/source-revert-requests.mjs +21 -0
- package/packages/piagent-core/managed/studio-link.mjs +16 -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/web-fetch.mjs +3 -14
- 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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { checkpointDigest, previewCheckpointRestore } from './restore-preview.mjs';
|
|
3
|
+
|
|
4
|
+
// Another owner holds this session's history (a second runtime mid-turn, a
|
|
5
|
+
// replaced runtime): the change waits rather than racing it. Any other failure
|
|
6
|
+
// only costs the turn its checkpoint.
|
|
7
|
+
export const checkpointOwnershipError = error =>
|
|
8
|
+
/checkpoint-(?:turn-owned|store-busy|lock-changed|authority-changed|session-capacity|command-capacity)/.test(String(error?.message ?? error));
|
|
9
|
+
|
|
10
|
+
// Shared turn history and drift refusal; Git execution is supplied by an adapter.
|
|
11
|
+
export class TurnCheckpointService {
|
|
12
|
+
#stack = [];
|
|
13
|
+
#tail = Promise.resolve();
|
|
14
|
+
#pending = 0;
|
|
15
|
+
#gap = false;
|
|
16
|
+
constructor(git, { busy = /** @returns {boolean} */ () => false, history = null,
|
|
17
|
+
memberEdits = /** @param {number} _since @returns {Set<string>} */ _since => new Set() } = {}) {
|
|
18
|
+
this.git = git; this.busy = busy; this.history = history; this.memberEdits = memberEdits;
|
|
19
|
+
this.#stack = history?.load() ?? [];
|
|
20
|
+
}
|
|
21
|
+
/** @template T @param {() => Promise<T>} action @returns {Promise<T>} */
|
|
22
|
+
#serial(action) {
|
|
23
|
+
if (this.#pending >= 128) return Promise.reject(Error('checkpoint-command-capacity'));
|
|
24
|
+
this.#pending++;
|
|
25
|
+
const locked = async () => {
|
|
26
|
+
// Non-Git projects remain side-effect-free, including checkpoint metadata.
|
|
27
|
+
if (!this.history || this.git.available && !await this.git.available()) return await action();
|
|
28
|
+
return await this.history.operation(async () => {
|
|
29
|
+
// A turn that ran without a checkpoint (the store refused it) ends the
|
|
30
|
+
// history it would otherwise sit inside.
|
|
31
|
+
if (this.#gap) { this.#gap = false; await this.#barrier(null); }
|
|
32
|
+
return await action();
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
const operation = this.#tail.then(locked, locked);
|
|
36
|
+
this.#tail = operation.then(() => {}, () => {});
|
|
37
|
+
return operation.finally(() => { this.#pending--; });
|
|
38
|
+
}
|
|
39
|
+
async close() { await this.history?.close(); }
|
|
40
|
+
revoke() { this.history?.revoke(); }
|
|
41
|
+
// A change is about to happen without a checkpoint for its turn.
|
|
42
|
+
markGap() { this.#stack = []; this.#gap = true; }
|
|
43
|
+
take(run) {
|
|
44
|
+
return this.#serial(() => this.#take(run)).catch(error => {
|
|
45
|
+
if (run && run.checkpoint === undefined && !checkpointOwnershipError(error)) { run.checkpoint = null; run.checkpointError = error; this.markGap(); }
|
|
46
|
+
throw error;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
finish(run) { return this.#serial(() => this.#finish(run)); }
|
|
50
|
+
undo() { return this.#serial(() => this.#undo()); }
|
|
51
|
+
list() { return this.#serial(async () => {
|
|
52
|
+
this.history?.assertCurrent();
|
|
53
|
+
return { revision: checkpointDigest(this.#stack), available: this.git.available ? await this.git.available() : true,
|
|
54
|
+
points: this.#stack.map(({ id, turnRef, at }) => ({ id, turnRef, at })) };
|
|
55
|
+
}); }
|
|
56
|
+
preview(targetId) { return this.#serial(() => this.#preview(targetId)); }
|
|
57
|
+
async #preview(targetId) {
|
|
58
|
+
this.history?.assertCurrent();
|
|
59
|
+
const preview = await previewCheckpointRestore(this.git, this.#stack, targetId, this.busy(), this.memberEdits);
|
|
60
|
+
this.history?.assertCurrent();
|
|
61
|
+
return preview;
|
|
62
|
+
}
|
|
63
|
+
restoreTo(targetId, expected, guard = undefined) { return this.#serial(async () => {
|
|
64
|
+
guard?.();
|
|
65
|
+
const preview = await this.#preview(targetId);
|
|
66
|
+
const completed = [], files = new Set();
|
|
67
|
+
if (!expected?.digest || !preview.digest || expected.digest !== preview.digest
|
|
68
|
+
|| expected.revision !== preview.revision || expected.tree !== preview.tree)
|
|
69
|
+
return { result: preview.result === 'busy' ? 'busy' : 'stale', completed, files: [] };
|
|
70
|
+
// Every step uses the same drift/policy guard as /undo and publishes its
|
|
71
|
+
// shortened stack. A later refusal never conceals already completed turns.
|
|
72
|
+
for (const step of preview.steps) {
|
|
73
|
+
try {
|
|
74
|
+
if (this.#stack.at(-1)?.id !== step.id) return { result: 'stale', completed, files: [...files] };
|
|
75
|
+
const undo = await this.#undo(guard);
|
|
76
|
+
// `files`: what this restore changed; `conflicts`: why it stopped.
|
|
77
|
+
if (undo.result !== 'done') return { ...undo, completed, files: [...files].sort(), conflicts: undo.result === 'refused' ? undo.files : [], stoppedAt: step.id };
|
|
78
|
+
completed.push(step.id);
|
|
79
|
+
for (const file of [...step.restore, ...step.remove]) files.add(file);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Refused before a file of this step changed: a definite answer.
|
|
82
|
+
const reason = String(error?.message ?? error), definite = !/checkpoint-partial/.test(reason);
|
|
83
|
+
const named = /checkpoint-content-changed: (.+)$/.exec(reason)?.[1]?.split(', ') ?? [];
|
|
84
|
+
if (definite && /checkpoint-content-changed/.test(reason)) return { result: 'refused', completed, files: [...files], conflicts: named, stoppedAt: step.id };
|
|
85
|
+
if (definite && /checkpoint-(?:read-only|path-protected)/.test(reason)) return { result: 'read-only', completed, files: [...files], stoppedAt: step.id, reason };
|
|
86
|
+
if (definite && /^(?:Error: )?checkpoint-/.test(reason)) return { result: 'unavailable', completed, files: [...files], stoppedAt: step.id, reason };
|
|
87
|
+
return { result: 'uncertain', completed, files: [...files], stoppedAt: step.id, reason };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { result: preview.conflicts.length ? 'refused' : 'done', completed, files: [...files].sort(),
|
|
91
|
+
conflicts: preview.conflicts, stoppedAt: preview.stoppedAt };
|
|
92
|
+
}); }
|
|
93
|
+
// Before the first change of a run; once per run. A snapshot that fails
|
|
94
|
+
// never stops the agent's edit: the turn just has no checkpoint, and older
|
|
95
|
+
// checkpoints are dropped with it, since undoing past an unrecorded turn
|
|
96
|
+
// would leave its changes in place while claiming an earlier state.
|
|
97
|
+
async #take(run) {
|
|
98
|
+
if (!run || run.checkpoint !== undefined) return;
|
|
99
|
+
run.checkpoint = null;
|
|
100
|
+
let point = null;
|
|
101
|
+
try {
|
|
102
|
+
const before = await this.git.tree();
|
|
103
|
+
if (before) {
|
|
104
|
+
point = { id: randomUUID(), turnRef: run.turnRef ?? randomUUID(), before, after: null, at: Date.now() };
|
|
105
|
+
await this.history?.begin(point);
|
|
106
|
+
run.checkpoint = point;
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// Another owner: the edit waits and this turn tries again, history intact.
|
|
110
|
+
if (checkpointOwnershipError(error)) { run.checkpoint = undefined; throw error; }
|
|
111
|
+
run.checkpointError = error;
|
|
112
|
+
await this.#barrier(point);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async #barrier(point) {
|
|
116
|
+
this.#stack = [];
|
|
117
|
+
if (!this.history) return;
|
|
118
|
+
try { if (point) await this.history.abandon(point); } catch { /* the capture is retired on the next operation */ }
|
|
119
|
+
try { this.#stack = await this.history.save([]); } catch { /* a stale store refuses later restores itself */ }
|
|
120
|
+
}
|
|
121
|
+
// When the run ends: the tree after it, kept when the run changed something.
|
|
122
|
+
async #finish(run) {
|
|
123
|
+
const point = run?.checkpoint;
|
|
124
|
+
if (!point || run.checkpointFinished) return;
|
|
125
|
+
try { point.after = await this.git.tree(); }
|
|
126
|
+
catch (error) { run.checkpointError = error; run.checkpointFinished = true; await this.#barrier(point); return; }
|
|
127
|
+
if (point.after && point.after !== point.before) {
|
|
128
|
+
const next = [...this.#stack, point];
|
|
129
|
+
this.#stack = this.history ? await this.history.save(next) : next.slice(-64);
|
|
130
|
+
run.checkpointFinished = true;
|
|
131
|
+
} else if (point.after) {
|
|
132
|
+
await this.history?.abandon(point); run.checkpointFinished = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Puts back what the last turn changed: { result, restored, removed, files }
|
|
136
|
+
// for the WebUI note (phase "undo") and `text`, the sentence for the member.
|
|
137
|
+
async #undo(guard = undefined) {
|
|
138
|
+
guard?.();
|
|
139
|
+
const answer = (undo, text) => ({ ...undo, text });
|
|
140
|
+
if (this.busy()) return answer({ result: 'busy', restored: 0, removed: 0, files: [] }, 'Đang có một lượt chạy: chờ nó xong (hoặc dừng nó) rồi /undo.');
|
|
141
|
+
const point = this.#stack.at(-1);
|
|
142
|
+
if (!point) return answer({ result: 'none', restored: 0, removed: 0, files: [] }, 'Không có lượt nào để hoàn tác trong cuộc trò chuyện này (chỉ áp dụng cho project là git repository; checkpoint có từ khi mở cuộc trò chuyện).');
|
|
143
|
+
await this.history?.prepare();
|
|
144
|
+
const now = await this.git.tree();
|
|
145
|
+
if (!now) return answer({ result: 'failed', restored: 0, removed: 0, files: [] }, 'Không đọc được trạng thái hiện tại của project; không hoàn tác.');
|
|
146
|
+
const since = new Set((await this.git.changed(point.after, now)).map(f => f.path));
|
|
147
|
+
// A file already back as it was before the turn (an undo that stopped
|
|
148
|
+
// midway) is done, not a conflict: only the rest is put back.
|
|
149
|
+
const differs = new Set((await this.git.changed(point.before, now)).map(f => f.path));
|
|
150
|
+
const turn = (await this.git.changed(point.before, point.after)).filter(f => differs.has(f.path));
|
|
151
|
+
// A file the member saved after the turn began is theirs, even when the
|
|
152
|
+
// turn's snapshots captured it: refuse rather than put it back.
|
|
153
|
+
const edited = this.memberEdits(point.at);
|
|
154
|
+
const touched = turn.filter(f => since.has(f.path) || edited.has(f.path)).map(f => f.path);
|
|
155
|
+
if (touched.length) return answer({ result: 'refused', restored: 0, removed: 0, files: touched.slice(0, 5) },
|
|
156
|
+
`Không hoàn tác: ${touched.length} file lượt trước đã sửa lại thay đổi sau đó (${touched.slice(0, 5).join(', ')}${touched.length > 5 ? ', …' : ''}). Hoàn tác sẽ ghi đè thay đổi đó.`);
|
|
157
|
+
const restore = turn.filter(f => f.status !== 'A').map(f => f.path), remove = turn.filter(f => f.status === 'A').map(f => f.path);
|
|
158
|
+
if (this.busy()) return answer({ result: 'busy', restored: 0, removed: 0, files: [] }, 'Đang có một lượt chạy: chờ nó xong (hoặc dừng nó) rồi /undo.');
|
|
159
|
+
this.history?.assertCurrent();
|
|
160
|
+
guard?.();
|
|
161
|
+
if (restore.length || remove.length) await this.git.restore(point.before, restore, remove, now, guard);
|
|
162
|
+
const next = this.#stack.slice(0, -1);
|
|
163
|
+
this.#stack = this.history ? await this.history.save(next) : next;
|
|
164
|
+
return answer({ result: 'done', restored: restore.length, removed: remove.length, files: [...restore, ...remove].slice(0, 5), more: this.#stack.length > 0 },
|
|
165
|
+
`Đã hoàn tác lượt trước: ${restore.length} file trả về như trước lượt đó, ${remove.length} file lượt đó tạo đã xoá.${this.#stack.length ? ' /undo lần nữa để hoàn tác lượt trước nữa.' : ''} Commit tạo trong lượt đó (nếu có) vẫn giữ nguyên.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -24,7 +24,7 @@ type SessionHookDependencies = {
|
|
|
24
24
|
event: string
|
|
25
25
|
) => TaskContract | undefined;
|
|
26
26
|
onTurnEnd?: (ctx: ExtensionContext) => void;
|
|
27
|
-
onAgentSettled?: (ctx: ExtensionContext) => void
|
|
27
|
+
onAgentSettled?: (ctx: ExtensionContext) => void | Promise<void>;
|
|
28
28
|
beforeShutdown?: (ctx: ExtensionContext) => void | Promise<void>;
|
|
29
29
|
};
|
|
30
30
|
|
|
@@ -102,7 +102,7 @@ export function registerSessionHooks(pi: ExtensionAPI, dependencies: SessionHook
|
|
|
102
102
|
taskRunId: task?.taskRunId,
|
|
103
103
|
taskOutcome: task?.trace.outcome
|
|
104
104
|
});
|
|
105
|
-
dependencies.onAgentSettled?.(ctx);
|
|
105
|
+
await dependencies.onAgentSettled?.(ctx);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
pi.on("session_compact", async (event, ctx) => {
|
|
@@ -115,19 +115,22 @@ export function registerSessionHooks(pi: ExtensionAPI, dependencies: SessionHook
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
118
|
-
await dependencies.beforeShutdown?.(ctx);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
118
|
+
try { await dependencies.beforeShutdown?.(ctx); }
|
|
119
|
+
finally {
|
|
120
|
+
try {
|
|
121
|
+
flushObservedTaskContext(
|
|
122
|
+
pi,
|
|
123
|
+
ctx,
|
|
124
|
+
state.qualifiedTaskContext(ctx),
|
|
125
|
+
maxManifestFiles,
|
|
126
|
+
"context_observed_before_shutdown"
|
|
127
|
+
);
|
|
128
|
+
telemetry(ctx, {
|
|
129
|
+
event: "session_shutdown",
|
|
130
|
+
reason: event.reason,
|
|
131
|
+
targetSessionFile: event.targetSessionFile ? path.basename(event.targetSessionFile) : undefined
|
|
132
|
+
});
|
|
133
|
+
} finally { state.clearSession(ctx); }
|
|
134
|
+
}
|
|
132
135
|
});
|
|
133
136
|
}
|
|
@@ -319,6 +319,9 @@ export function registerSessionStartHook(pi: ExtensionAPI, dependencies: Session
|
|
|
319
319
|
if (permissionProfile.mode === "trusted-full-access") {
|
|
320
320
|
ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
|
|
321
321
|
}
|
|
322
|
+
if (permissionProfile.mode === "bypass") {
|
|
323
|
+
ctx.ui.notify("Piagent bypass is active: no confirmation, protected-path or policy gate applies in this personal session. Output redaction and /undo checkpoints remain.", "warning");
|
|
324
|
+
}
|
|
322
325
|
const mcpNotice = dependencies.mcpReadinessNotice(ctx.cwd);
|
|
323
326
|
if (mcpNotice) ctx.ui.notify(mcpNotice, "warning");
|
|
324
327
|
const updateNotice = dependencies.updateAvailabilityNotice();
|
|
@@ -18,7 +18,7 @@ type ToolCallHookDependencies = {
|
|
|
18
18
|
beforeAuthorize?: (event: ToolCallEvent, ctx: ExtensionContext) => Promise<ToolCallDecision> | ToolCallDecision;
|
|
19
19
|
authorize: (event: ToolCallEvent, ctx: ExtensionContext) => Promise<ToolCallDecision> | ToolCallDecision;
|
|
20
20
|
reviewBudgetDecision?: (event: ToolCallEvent, ctx: ExtensionContext) => ToolCallDecision;
|
|
21
|
-
beforeStart?: (event: ToolCallEvent, ctx: ExtensionContext) => ToolCallDecision;
|
|
21
|
+
beforeStart?: (event: ToolCallEvent, ctx: ExtensionContext) => Promise<ToolCallDecision> | ToolCallDecision;
|
|
22
22
|
afterDecision?: (
|
|
23
23
|
event: ToolCallEvent,
|
|
24
24
|
ctx: ExtensionContext,
|
|
@@ -74,7 +74,7 @@ export function registerToolCallHook(pi: ExtensionAPI, dependencies: ToolCallHoo
|
|
|
74
74
|
const decision = authorizationDecision && typeof authorizationDecision === "object" && authorizationDecision.block
|
|
75
75
|
? authorizationDecision
|
|
76
76
|
: dependencies.reviewBudgetDecision?.(resolvedEvent, ctx);
|
|
77
|
-
const startDecision = !(decision && typeof decision === "object" && decision.block) ? dependencies.beforeStart?.(resolvedEvent, ctx) : undefined;
|
|
77
|
+
const startDecision = !(decision && typeof decision === "object" && decision.block) ? await dependencies.beforeStart?.(resolvedEvent, ctx) : undefined;
|
|
78
78
|
const finalDecision = startDecision && typeof startDecision === "object" && startDecision.block ? startDecision : decision;
|
|
79
79
|
// Every refusal leaves through here, so this is where it is answered. A
|
|
80
80
|
// remedy attached at the 29 sites that build one is a remedy the next site
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ExactGitPatchAuthority } from "./source-action-contract.ts";
|
|
2
|
+
export type { ExactGitPatchAuthority } from "./source-action-contract.ts";
|
|
1
3
|
import { createHash } from "node:crypto";
|
|
2
4
|
import os from "node:os";
|
|
3
5
|
import { collectGitStatus, runReadOnlyGit, type GitStatusRecord, type GitStatusSnapshot } from "./git-status-adapter.ts";
|
|
@@ -363,7 +365,6 @@ async function patchForUnstagedRecord(snapshot: GitStatusSnapshot, record: GitSt
|
|
|
363
365
|
`--unified=${contextLines}`, "--", ...paths], { timeoutMs, maxBytes });
|
|
364
366
|
}
|
|
365
367
|
|
|
366
|
-
export type ExactGitPatchAuthority = { header: Buffer; hunks: Array<{ hunkRef: string; bytes: Buffer }> };
|
|
367
368
|
type ExactGitPatchOptions = {
|
|
368
369
|
snapshot: GitStatusSnapshot; record: GitStatusRecord; view: "unstaged" | "staged"; fileRef: string;
|
|
369
370
|
contextLines?: number; timeoutMs?: number; maxBytes?: number; maxHunks?: number;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { inspectWorkspaceFile, workspacePathFlags, type WorkspacePolicy } from "../../extensions/workspace-inspection.ts";
|
|
3
|
+
import { redactSensitiveText } from "../../extensions/redaction-core.js";
|
|
4
|
+
import { PiSourceMutationGuard } from "../policy/source-mutation-guard.ts";
|
|
5
|
+
import { collectSourceChangeViews } from "./source-change-projection.ts";
|
|
6
|
+
import { collectSessionFileDiff } from "./session-diff-projection.ts";
|
|
7
|
+
import { collectSourceRevertPreview, validateSourceRevertTarget } from "./source-revert-projection.ts";
|
|
8
|
+
import { SessionMutationObserver } from "./session-mutation-observer.ts";
|
|
9
|
+
import { sessionAuthoredHunkRefs } from "./session-hunk-provenance.ts";
|
|
10
|
+
import { canonicalReviewValue } from "./review-state-contract.ts";
|
|
11
|
+
import type { SourceRevertTarget, WebUiIdentity } from "./source-action-contract.ts";
|
|
12
|
+
|
|
13
|
+
// Construct only inside the sandbox. Identity, observations and confirmation key
|
|
14
|
+
// come from the owning runtime; the browser supplies only a selected target.
|
|
15
|
+
export type ManagedSourceContext = {
|
|
16
|
+
cwd: string; rawSessionId: string; identity: WebUiIdentity; confirmationKey: Buffer;
|
|
17
|
+
controlRevision: string; idle(): boolean; policy(): WorkspacePolicy;
|
|
18
|
+
observation(file: string): unknown;
|
|
19
|
+
};
|
|
20
|
+
export function createManagedSourceRevert(context: ManagedSourceContext) {
|
|
21
|
+
const { cwd, identity } = context;
|
|
22
|
+
if (identity.taskId !== null || identity.taskRunId !== null) throw new Error("source-session-binding-required");
|
|
23
|
+
const relative = (root: string, file: string) => path.relative(cwd, path.resolve(root, file)).split(path.sep).join("/");
|
|
24
|
+
const blocked = (root: string, file: string, writing = true) => {
|
|
25
|
+
const name = relative(root, file);
|
|
26
|
+
if (!name || name === ".." || name.startsWith("../") || path.isAbsolute(name)) return true;
|
|
27
|
+
const flags = workspacePathFlags(name, context.policy());
|
|
28
|
+
return flags.protected || writing && flags.readOnly;
|
|
29
|
+
};
|
|
30
|
+
const observer = new SessionMutationObserver(), loaded = new Set<string>();
|
|
31
|
+
const observation = (file: string) => {
|
|
32
|
+
const policy = context.policy();
|
|
33
|
+
if (!loaded.has(file)) { observer.restore(cwd, context.rawSessionId, file, context.observation(file), policy); loaded.add(file); }
|
|
34
|
+
return observer.current(cwd, context.rawSessionId, file, policy);
|
|
35
|
+
};
|
|
36
|
+
const readBlocked = (root: string, file: string) => blocked(root, file, false);
|
|
37
|
+
const sources = () => collectSourceChangeViews({ cwd, identity, taskRevision: null, isProtectedPath: readBlocked });
|
|
38
|
+
const detail = async (fileRef: string) => {
|
|
39
|
+
const views = await sources(), source = views.workingTree;
|
|
40
|
+
const file = source.files.find(item => item.fileRef === fileRef);
|
|
41
|
+
if (!file) throw new Error("source-file-unavailable");
|
|
42
|
+
const diff = await collectSessionFileDiff({ cwd, identity, sourceView: source, fileRef, taskRevision: null, sessionObservation: observation,
|
|
43
|
+
isProtectedPath: readBlocked, redactLine: redactSensitiveText, revalidationMode: "selected-file",
|
|
44
|
+
selectedRepoPaths: file.pathDisplay === "exact-safe" ? [String(file.path)] : undefined,
|
|
45
|
+
precondition: { expectedViewRevision: source.viewRevision, expectedFileRevision: String(file.fileRevision),
|
|
46
|
+
expectedBaseDigest: file.baseDigest as string | null, expectedCurrentDigest: file.currentDigest as string | null } });
|
|
47
|
+
return { views, source, file, diff };
|
|
48
|
+
};
|
|
49
|
+
const resolve = async (fileRef: string, hunkRefs: string[]) => {
|
|
50
|
+
const { views, source, file, diff } = await detail(fileRef);
|
|
51
|
+
return collectSourceRevertPreview({ cwd, identity, sourceView: source, taskView: null, diff, taskRevision: null,
|
|
52
|
+
workspaceRevision: source.viewRevision, indexRevision: views.staged.viewRevision, selectedHunkRefs: hunkRefs,
|
|
53
|
+
sessionObservation: file.pathDisplay === "exact-safe" ? observation(String(file.path)) ?? undefined : undefined,
|
|
54
|
+
confirmationKey: context.confirmationKey, guardAvailable: context.idle() && context.policy().readOnly !== true,
|
|
55
|
+
isProtectedPath: blocked });
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
sources, observation,
|
|
59
|
+
async diff(fileRef: string) { return (await detail(fileRef)).diff; },
|
|
60
|
+
async preview(fileRef: string, hunkRefs: string[]) { return (await resolve(fileRef, hunkRefs)).projection; },
|
|
61
|
+
async execute(value: unknown) {
|
|
62
|
+
const target: SourceRevertTarget = validateSourceRevertTarget(value);
|
|
63
|
+
if (Date.now() > Date.parse(target.expiresAt)) throw new Error("source-revert-preview-expired");
|
|
64
|
+
const { authority } = await resolve(target.fileRef, target.hunkRefs);
|
|
65
|
+
// The time window is checked separately; every content and identity binding
|
|
66
|
+
// must match the newly constructed, keyed preview before execution.
|
|
67
|
+
if (!authority || canonicalReviewValue({ ...authority.target, expiresAt: target.expiresAt }) !== canonicalReviewValue(target))
|
|
68
|
+
throw new Error("source-revert-preview-stale");
|
|
69
|
+
authority.target.expiresAt = target.expiresAt;
|
|
70
|
+
const guard = new PiSourceMutationGuard();
|
|
71
|
+
const unbind = guard.bind({ cwd, rawSessionId: context.rawSessionId, guardInstanceId: identity.runtimeInstanceId,
|
|
72
|
+
facts: () => ({ taskId: null, taskRunId: null, taskRevision: null, taskState: "none", sessionBound: true,
|
|
73
|
+
writable: context.policy().readOnly !== true, idle: context.idle(), controlRevision: context.controlRevision,
|
|
74
|
+
sessionObservation: observation,
|
|
75
|
+
sessionReverted: (candidate, before, committed) => observer.reverted(cwd, context.rawSessionId,
|
|
76
|
+
relative(candidate.repoRoot, candidate.repoPath), before, committed, context.policy()),
|
|
77
|
+
isProtectedPath: file => blocked(cwd, file), sessionHunkRefs: candidate => {
|
|
78
|
+
const file = relative(candidate.repoRoot, candidate.repoPath), observed = observation(file);
|
|
79
|
+
const current = inspectWorkspaceFile(cwd, file, context.policy());
|
|
80
|
+
return observed && current.content !== null ? sessionAuthoredHunkRefs(observed, current.content, candidate.patchAuthority) : [];
|
|
81
|
+
} }) });
|
|
82
|
+
try {
|
|
83
|
+
return await guard.executeRevert({ cwd, rawSessionId: context.rawSessionId, identity, action: "source.revert",
|
|
84
|
+
expectedTaskRevision: null, expectedControlRevision: context.controlRevision,
|
|
85
|
+
expectedIndexPreimage: target.indexPreimage, expectedWorkspacePreimage: target.workspacePreimage,
|
|
86
|
+
previewRef: target.previewRef, confirmedPreviewDigest: target.confirmedPreviewDigest, authority });
|
|
87
|
+
} finally { unbind(); }
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -16,16 +16,16 @@ export type ReviewStateProjection = {
|
|
|
16
16
|
health: { state: "ok" | "degraded" | "unavailable" | "error"; reasonCode: string | null; message: string | null };
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
type DeriveReviewTargetOptions = {
|
|
19
|
+
type DeriveReviewTargetOptions<Revision extends string | null = string> = {
|
|
20
20
|
diff: DiffDocument;
|
|
21
|
-
taskId: string;
|
|
22
|
-
taskRunId: string;
|
|
23
|
-
taskRevision:
|
|
21
|
+
taskId: string | null;
|
|
22
|
+
taskRunId: string | null;
|
|
23
|
+
taskRevision: Revision;
|
|
24
24
|
workspaceRevision: string;
|
|
25
25
|
indexRevision: string | null;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
function targetCore(options: DeriveReviewTargetOptions): Omit<ReviewTarget, "diffRef" | "patchPreimage" | "contentDigest"> & { basisRef: string } {
|
|
28
|
+
function targetCore<Revision extends string | null>(options: DeriveReviewTargetOptions<Revision>): Omit<ReviewTarget, "taskRevision" | "diffRef" | "patchPreimage" | "contentDigest"> & { taskRevision: Revision; basisRef: string } {
|
|
29
29
|
const diff = options.diff as Record<string, any>, file = diff.file as Record<string, any>, observed = diff.observed as Record<string, any>;
|
|
30
30
|
return {
|
|
31
31
|
view: diff.view as ReviewView, fileRef: String(file.fileRef), taskRevision: options.taskRevision,
|
|
@@ -36,19 +36,30 @@ function targetCore(options: DeriveReviewTargetOptions): Omit<ReviewTarget, "dif
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function
|
|
39
|
+
export function deriveSourceDiffTarget<Revision extends string | null>(options: DeriveReviewTargetOptions<Revision> & {
|
|
40
|
+
sessionIdentity?: Pick<WebUiIdentity, "projectRef" | "runtimeInstanceId" | "sessionRef">;
|
|
41
|
+
}): (Omit<ReviewTarget, "taskRevision"> & { taskRevision: Revision }) | null {
|
|
40
42
|
const diff = options.diff as Record<string, any>;
|
|
41
|
-
|
|
43
|
+
const binding = options.sessionIdentity;
|
|
44
|
+
const session = binding ? { projectRef: binding.projectRef, runtimeInstanceId: binding.runtimeInstanceId, sessionRef: binding.sessionRef } : undefined;
|
|
45
|
+
const sessionBound = session && options.taskId === null && options.taskRunId === null && options.taskRevision === null
|
|
46
|
+
&& ["projectRef", "runtimeInstanceId", "sessionRef"].every(key => typeof session[key] === "string" && session[key] === diff.identity?.[key])
|
|
47
|
+
&& diff.identity?.taskId === null && diff.identity?.taskRunId === null;
|
|
48
|
+
if ((!sessionBound && (!options.taskId || !options.taskRunId || !options.taskRevision)) || !options.workspaceRevision
|
|
42
49
|
|| diff.availability?.state !== "current" || diff.fallback?.kind !== "none"
|
|
43
50
|
|| diff.truncation?.truncated !== false || diff.redaction?.applied !== false || diff.redaction?.truncated !== false
|
|
44
51
|
|| diff.file?.status === "C" || diff.file?.content?.kind !== "text" || diff.file?.content?.access !== "available") return null;
|
|
45
|
-
const core = targetCore(options), preimage = reviewDigest({ schemaVersion: 1, taskId: options.taskId, taskRunId: options.taskRunId, ...core });
|
|
52
|
+
const core = targetCore(options), preimage = reviewDigest({ schemaVersion: 1, taskId: options.taskId, taskRunId: options.taskRunId, ...(sessionBound ? { sessionIdentity: session } : {}), ...core });
|
|
46
53
|
return { view: core.view, fileRef: core.fileRef, diffRef: `diff.${preimage.slice("sha256:".length)}`, taskRevision: core.taskRevision,
|
|
47
54
|
workspaceRevision: core.workspaceRevision, indexRevision: core.indexRevision, viewRevision: core.viewRevision,
|
|
48
55
|
fileRevision: core.fileRevision, baseDigest: core.baseDigest, currentDigest: core.currentDigest,
|
|
49
56
|
patchPreimage: preimage, contentDigest: preimage };
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
export function deriveReviewTarget(options: DeriveReviewTargetOptions): ReviewTarget | null {
|
|
60
|
+
return deriveSourceDiffTarget(options);
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
function sameTarget(left: ReviewTarget, right: ReviewTarget): boolean {
|
|
53
64
|
return canonicalReviewValue(left) === canonicalReviewValue(right);
|
|
54
65
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { collectExactGitPatchAuthority, collectFileDiff, type FileDiffOptions } from "./diff-projection.ts";
|
|
3
|
+
import { collectSelectedSourceFileState } from "./source-change-projection.ts";
|
|
4
|
+
import { readWorkspaceFile } from "./workspace-file-reader.ts";
|
|
5
|
+
import { sessionAuthoredHunkRefs } from "./session-hunk-provenance.ts";
|
|
6
|
+
import type { SessionMutationObservation } from "./session-mutation-observer.ts";
|
|
7
|
+
|
|
8
|
+
// Attribution is per hunk. A mixed file never inherits whole-file authorship
|
|
9
|
+
// merely because an observed agent edit exists somewhere in it.
|
|
10
|
+
export async function collectSessionFileDiff(options: FileDiffOptions & {
|
|
11
|
+
sessionObservation?(file: string): SessionMutationObservation | null | undefined;
|
|
12
|
+
}) {
|
|
13
|
+
const diff = await collectFileDiff(options), result = { ...diff, agentHunkRefs: [] as string[] };
|
|
14
|
+
if (options.sourceView.view !== "working-tree" || diff.availability.state !== "current"
|
|
15
|
+
|| (diff.truncation as any)?.truncated !== false || (diff.redaction as any)?.applied !== false) return result;
|
|
16
|
+
const file = options.sourceView.files.find(item => item.fileRef === options.fileRef);
|
|
17
|
+
if (!file || file.pathDisplay !== "exact-safe" || typeof file.path !== "string") return result;
|
|
18
|
+
try {
|
|
19
|
+
const observation = options.sessionObservation?.(file.path);
|
|
20
|
+
if (!observation) return result;
|
|
21
|
+
const sourceOptions = { cwd: options.cwd, identity: options.identity, taskRevision: options.taskRevision,
|
|
22
|
+
isProtectedPath: options.isProtectedPath, timeoutMs: options.timeoutMs, maxGitBytes: options.maxBytes };
|
|
23
|
+
const selected = await collectSelectedSourceFileState(sourceOptions, "working-tree", options.fileRef, String(file.basisRef), [file.path]);
|
|
24
|
+
if (!selected || selected.file.fileRevision !== file.fileRevision || !selected.record.path.value
|
|
25
|
+
|| options.isProtectedPath?.(selected.snapshot.repoRoot, selected.record.path.value)) return result;
|
|
26
|
+
const bytes = readWorkspaceFile(selected.snapshot.repoRoot, selected.record.path.value, 256 * 1024);
|
|
27
|
+
const current = bytes.toString("utf8"), currentDigest = createHash("sha256").update(bytes).digest("hex");
|
|
28
|
+
if (!Buffer.from(current).equals(bytes) || (diff.file as { currentDigest?: string }).currentDigest !== `sha256:${currentDigest}`) return result;
|
|
29
|
+
let authored: string[];
|
|
30
|
+
if (selected.record.kind === "untracked") {
|
|
31
|
+
// A witnessed creation proves the complete new file for display only.
|
|
32
|
+
// It does not mint a Git patch authority or enable file deletion.
|
|
33
|
+
authored = observation.baseline.digest === "absent" && observation.baseline.content === ""
|
|
34
|
+
&& observation.currentDigest === currentDigest ? diff.hunks.map(hunk => String(hunk.hunkRef)) : [];
|
|
35
|
+
} else {
|
|
36
|
+
const patch = await collectExactGitPatchAuthority({ snapshot: selected.snapshot, record: selected.record, view: "unstaged",
|
|
37
|
+
fileRef: options.fileRef, timeoutMs: options.timeoutMs, maxBytes: options.maxBytes });
|
|
38
|
+
if (!patch) return result;
|
|
39
|
+
authored = sessionAuthoredHunkRefs(observation, current, patch);
|
|
40
|
+
}
|
|
41
|
+
const final = await collectSelectedSourceFileState(sourceOptions, "working-tree", options.fileRef, String(file.basisRef), [file.path]);
|
|
42
|
+
if (!final || final.file.fileRevision !== selected.file.fileRevision
|
|
43
|
+
|| options.isProtectedPath?.(selected.snapshot.repoRoot, selected.record.path.value)) return result;
|
|
44
|
+
const visible = new Set(diff.hunks.map(hunk => hunk.hunkRef));
|
|
45
|
+
return { ...result, agentHunkRefs: authored.filter(ref => visible.has(ref)) };
|
|
46
|
+
} catch { return result; }
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { diffTextBuffers, type TextDiffOperation } from "./text-diff.ts";
|
|
3
|
+
import type { ExactGitPatchAuthority } from "./source-action-contract.ts";
|
|
4
|
+
import type { SessionMutationObservation } from "./session-mutation-observer.ts";
|
|
5
|
+
|
|
6
|
+
type ChangeBlock = { at: number; added: string[]; deleted: string[] };
|
|
7
|
+
const digest = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
8
|
+
function blocks(operations: TextDiffOperation[], start = 1): ChangeBlock[] {
|
|
9
|
+
const result: ChangeBlock[] = [];
|
|
10
|
+
let current = start, block: ChangeBlock | null = null;
|
|
11
|
+
for (const operation of operations) {
|
|
12
|
+
if (operation.kind === "context") { block = null; current++; continue; }
|
|
13
|
+
if (!block) { block = { at: current, added: [], deleted: [] }; result.push(block); }
|
|
14
|
+
if (operation.kind === "added") { block.added.push(operation.text); current++; }
|
|
15
|
+
else block.deleted.push(operation.text);
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
function patchBlocks(bytes: Buffer): ChangeBlock[] | null {
|
|
20
|
+
const text = bytes.toString("utf8");
|
|
21
|
+
if (!Buffer.from(text).equals(bytes) || !text.endsWith("\n")) return null;
|
|
22
|
+
const lines = text.slice(0, -1).split("\n"), header = lines.shift();
|
|
23
|
+
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@.*$/.exec(header ?? "");
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
const operations: TextDiffOperation[] = [];
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
const marker = line[0];
|
|
28
|
+
if (line === "\") {
|
|
29
|
+
const previous = operations.at(-1);
|
|
30
|
+
if (!previous?.text.endsWith("\n")) return null;
|
|
31
|
+
previous.text = previous.text.slice(0, -1);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (![" ", "+", "-"].includes(marker)) return null;
|
|
35
|
+
operations.push({ kind: marker === " " ? "context" : marker === "+" ? "added" : "deleted", text: line.slice(1) + "\n" });
|
|
36
|
+
}
|
|
37
|
+
if (operations.filter(item => item.kind !== "added").length !== Number(match[2] ?? 1)
|
|
38
|
+
|| operations.filter(item => item.kind !== "deleted").length !== Number(match[4] ?? 1)) return null;
|
|
39
|
+
return blocks(operations, Math.max(1, Number(match[3]) + (Number(match[4] ?? 1) === 0 ? 1 : 0)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Index hunks may contain member edits that predate this session. Only grant
|
|
43
|
+
// a hunk when every changed block is an exact delta from the observed baseline.
|
|
44
|
+
export function sessionAuthoredHunkRefs(observation: SessionMutationObservation, current: string,
|
|
45
|
+
patch: ExactGitPatchAuthority): string[] {
|
|
46
|
+
const baseline = observation.baseline;
|
|
47
|
+
if (digest(current) !== observation.currentDigest
|
|
48
|
+
|| (baseline.digest === "absent" ? baseline.content !== "" : digest(baseline.content) !== baseline.digest)) return [];
|
|
49
|
+
const delta = diffTextBuffers(Buffer.from(baseline.content), Buffer.from(current), undefined, true);
|
|
50
|
+
if (!delta.exact) return [];
|
|
51
|
+
const proven = new Set(blocks(delta.operations).map(block => JSON.stringify(block)));
|
|
52
|
+
return patch.hunks.filter(hunk => {
|
|
53
|
+
const changes = patchBlocks(hunk.bytes);
|
|
54
|
+
return changes !== null && changes.length > 0 && changes.every(block => proven.has(JSON.stringify(block)));
|
|
55
|
+
}).map(hunk => hunk.hunkRef);
|
|
56
|
+
}
|