@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,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import type { TurnCheckpointService } from './turn-checkpoints.mjs';
|
|
3
|
+
|
|
4
|
+
export type CheckpointPort = Pick<TurnCheckpointService, 'list' | 'preview'> & {
|
|
5
|
+
restoreTo(target: string, expected: { digest: string; revision: string; tree: string }, guard?: () => void): ReturnType<TurnCheckpointService['restoreTo']>;
|
|
6
|
+
};
|
|
7
|
+
type Binding = { port: CheckpointPort; revoke(): void };
|
|
8
|
+
const key = (cwd: string, sessionId: string) => `${fs.realpathSync(cwd)}\0${sessionId}`;
|
|
9
|
+
|
|
10
|
+
// Only the live extension may supply a personal checkpoint port. Gateway must
|
|
11
|
+
// never reconstruct mutation authority from persisted session messages.
|
|
12
|
+
export class CheckpointAuthority {
|
|
13
|
+
#bindings = new Map<string, Binding>();
|
|
14
|
+
bind(cwd: string, sessionId: string, binding: Binding) {
|
|
15
|
+
const identity = key(cwd, sessionId), previous = this.#bindings.get(identity);
|
|
16
|
+
if (!previous && this.#bindings.size >= 1024) throw Error('checkpoint-authority-capacity');
|
|
17
|
+
previous?.revoke();
|
|
18
|
+
this.#bindings.set(identity, binding);
|
|
19
|
+
return () => {
|
|
20
|
+
binding.revoke();
|
|
21
|
+
if (this.#bindings.get(identity) === binding) this.#bindings.delete(identity);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
resolve(cwd: string, sessionId: string) {
|
|
25
|
+
const identity = key(cwd, sessionId), binding = this.#bindings.get(identity);
|
|
26
|
+
if (!binding) throw Error('checkpoint-runtime-unavailable');
|
|
27
|
+
const current = () => this.#bindings.get(identity) === binding;
|
|
28
|
+
const check = () => { if (!current()) throw Error('checkpoint-authority-changed'); };
|
|
29
|
+
return {
|
|
30
|
+
current,
|
|
31
|
+
list: () => { check(); return binding.port.list(); },
|
|
32
|
+
preview: (target: string) => { check(); return binding.port.preview(target); },
|
|
33
|
+
restoreTo: (target: string, expected: { digest: string; revision: string; tree: string }, guard?: () => void) => {
|
|
34
|
+
check(); return binding.port.restoreTo(target, expected, () => { check(); guard?.(); });
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pi's extension loader and the native Gateway can load distinct module copies.
|
|
41
|
+
const AUTHORITY_KEY = Symbol.for('piagent.checkpoint-authority.v1');
|
|
42
|
+
const root = globalThis as typeof globalThis & { [AUTHORITY_KEY]?: CheckpointAuthority };
|
|
43
|
+
export const checkpointAuthority = root[AUTHORITY_KEY] ??= new CheckpointAuthority();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Trees referenced only by a private index are unreachable after it is removed.
|
|
2
|
+
// Separate before/after refs keep their blobs alive through GC without commits,
|
|
3
|
+
// branches, reflog entries, or changes to HEAD and the member's index.
|
|
4
|
+
export class CheckpointRefs {
|
|
5
|
+
constructor(execute, sessionKey) {
|
|
6
|
+
if (!/^[a-f0-9]{64}$/.test(sessionKey)) throw Error('checkpoint-session-invalid');
|
|
7
|
+
this.execute = execute;
|
|
8
|
+
this.prefix = `refs/piagent/checkpoints/${sessionKey}/`;
|
|
9
|
+
}
|
|
10
|
+
#entries(point) {
|
|
11
|
+
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(point.id)) throw Error('checkpoint-state-invalid');
|
|
12
|
+
return ['before', 'after'].flatMap(side => {
|
|
13
|
+
const tree = point[side];
|
|
14
|
+
if (side === 'after' && tree === null) return [];
|
|
15
|
+
if (typeof tree !== 'string' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(tree)) throw Error('checkpoint-state-invalid');
|
|
16
|
+
return [[`${this.prefix}${point.id}/${side}`, tree]];
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async #transaction(lines) {
|
|
20
|
+
const bytes = Buffer.from(['start', ...lines, 'prepare', 'commit', ''].join('\n'));
|
|
21
|
+
if (typeof this.execute !== 'function') return await this.execute.run(['update-ref', '--stdin'], bytes);
|
|
22
|
+
await this.execute(`printf %s ${bytes.toString('base64')} | base64 --decode | git update-ref --stdin`);
|
|
23
|
+
}
|
|
24
|
+
async #current(point) {
|
|
25
|
+
const entries = this.#entries(point), prefix = `${this.prefix}${point.id}/`;
|
|
26
|
+
const output = typeof this.execute === 'function'
|
|
27
|
+
? await this.execute(`git for-each-ref --format='%(refname) %(objectname)' ${prefix}`)
|
|
28
|
+
: await this.execute.run(['for-each-ref', '--format=%(refname) %(objectname)', prefix]);
|
|
29
|
+
const current = new Map();
|
|
30
|
+
for (const line of output.trim().split('\n').filter(Boolean)) {
|
|
31
|
+
const [ref, tree, extra] = line.split(' ');
|
|
32
|
+
if (extra || !entries.some(([name, oid]) => name === ref && oid === tree) || current.has(ref))
|
|
33
|
+
throw Error('checkpoint-ref-changed');
|
|
34
|
+
current.set(ref, tree);
|
|
35
|
+
}
|
|
36
|
+
return { entries, current };
|
|
37
|
+
}
|
|
38
|
+
async pin(point) {
|
|
39
|
+
const { entries, current } = await this.#current(point);
|
|
40
|
+
// Ref creation and existing-value verification are atomic, so a race cannot
|
|
41
|
+
// replace a ref belonging to another retained checkpoint.
|
|
42
|
+
await this.#transaction(entries.map(([ref, tree]) => `${current.has(ref) ? 'verify' : 'create'} ${ref} ${tree}`));
|
|
43
|
+
}
|
|
44
|
+
async release(point) {
|
|
45
|
+
// A crash after deletion may leave cleanup pending. Missing refs are already
|
|
46
|
+
// released; changed values still refuse, including races before deletion.
|
|
47
|
+
const { entries, current } = await this.#current(point);
|
|
48
|
+
const present = entries.filter(([ref]) => current.has(ref));
|
|
49
|
+
if (present.length) await this.#transaction(present.map(([ref, tree]) => `delete ${ref} ${tree}`));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { ensurePrivateStateDirectory, resolveLocalStatePath } from '../../extensions/local-state-path.js';
|
|
5
|
+
import { ownerAlive } from '../process-owner.mjs';
|
|
6
|
+
|
|
7
|
+
export const MAX_CHECKPOINTS = 64;
|
|
8
|
+
const MAX_BYTES = 128 * 1024;
|
|
9
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const oid = value => typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
|
|
11
|
+
const id = value => typeof value === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value);
|
|
12
|
+
const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
&& Object.keys(value).length === keys.length && Object.keys(value).every(key => keys.includes(key));
|
|
14
|
+
|
|
15
|
+
export function checkpointPoint(value) {
|
|
16
|
+
if (!exact(value, ['id', 'turnRef', 'before', 'after', 'at']) || !id(value.id)
|
|
17
|
+
|| typeof value.turnRef !== 'string' || !value.turnRef.length || value.turnRef.length > 160 || /[\x00-\x1f]/.test(value.turnRef)
|
|
18
|
+
|| !oid(value.before) || !oid(value.after) || value.before.length !== value.after.length
|
|
19
|
+
|| !Number.isSafeInteger(value.at) || value.at < 0) throw Error('checkpoint-state-invalid');
|
|
20
|
+
return { ...value };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function refPoint(value) {
|
|
24
|
+
if (value?.after !== null) return checkpointPoint(value);
|
|
25
|
+
checkpointPoint({ ...value, after: value.before });
|
|
26
|
+
return { ...value };
|
|
27
|
+
}
|
|
28
|
+
function captureRecord(value) {
|
|
29
|
+
if (value === null) return null;
|
|
30
|
+
if (!exact(value, ['point', 'pid', 'token']) || !Number.isInteger(value.pid) || value.pid <= 1 || !id(value.token)
|
|
31
|
+
|| value.point?.after !== null) throw Error('checkpoint-state-invalid');
|
|
32
|
+
return { point: refPoint(value.point), pid: value.pid, token: value.token };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.nlink === b.nlink
|
|
36
|
+
&& a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
37
|
+
function privateFile(stat) {
|
|
38
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_BYTES
|
|
39
|
+
|| typeof process.getuid === 'function' && (stat.uid !== process.getuid() || (stat.mode & 0o077)))
|
|
40
|
+
throw Error('checkpoint-state-not-private');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A small synchronous CAS transaction keeps every read/write inside the same
|
|
44
|
+
// owner-only directory. The lock also rejects a second process with stale state.
|
|
45
|
+
export class CheckpointStore {
|
|
46
|
+
#operation = null;
|
|
47
|
+
constructor(projectRoot, sessionId) {
|
|
48
|
+
if (typeof sessionId !== 'string' || !sessionId.length || sessionId.length > 4096) throw Error('checkpoint-session-invalid');
|
|
49
|
+
this.root = fs.realpathSync(projectRoot);
|
|
50
|
+
this.key = hash(`${this.root}\0${sessionId}`);
|
|
51
|
+
this.directory = path.join(this.root, '.pi', 'piagent-state', 'checkpoints');
|
|
52
|
+
this.file = path.join(this.directory, `${this.key}.json`);
|
|
53
|
+
this.lock = path.join(this.directory, `${this.key}.lock`);
|
|
54
|
+
}
|
|
55
|
+
#safe(file, kind) { return resolveLocalStatePath(this.root, file, { label: 'Checkpoint state', kind }); }
|
|
56
|
+
#read(file) {
|
|
57
|
+
const safe = this.#safe(file, 'file');
|
|
58
|
+
const fd = fs.openSync(safe, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
59
|
+
try {
|
|
60
|
+
const before = fs.fstatSync(fd); privateFile(before);
|
|
61
|
+
const bytes = fs.readFileSync(fd);
|
|
62
|
+
if (!same(before, fs.fstatSync(fd)) || !same(before, fs.lstatSync(this.#safe(file, 'file')))) throw Error('checkpoint-state-changed');
|
|
63
|
+
return { bytes, stat: before };
|
|
64
|
+
} finally { fs.closeSync(fd); }
|
|
65
|
+
}
|
|
66
|
+
load() {
|
|
67
|
+
let bytes;
|
|
68
|
+
try { ({ bytes } = this.#read(this.file)); }
|
|
69
|
+
catch (error) { if (error.code === 'ENOENT') return { revision: 'absent', points: [] }; throw error; }
|
|
70
|
+
const value = JSON.parse(bytes.toString('utf8'));
|
|
71
|
+
if (!(value.version === 1 && exact(value, ['version', 'sessionKey', 'points'])
|
|
72
|
+
|| value.version === 2 && exact(value, ['version', 'sessionKey', 'points', 'retired'])
|
|
73
|
+
|| value.version === 3 && exact(value, ['version', 'sessionKey', 'points', 'retired', 'staged'])
|
|
74
|
+
|| value.version === 4 && exact(value, ['version', 'sessionKey', 'points', 'retired', 'staged', 'capture'])) || value.sessionKey !== this.key
|
|
75
|
+
|| !Array.isArray(value.points) || value.points.length > MAX_CHECKPOINTS) throw Error('checkpoint-state-invalid');
|
|
76
|
+
const points = value.points.map(checkpointPoint);
|
|
77
|
+
if (new Set(points.map(point => point.id)).size !== points.length) throw Error('checkpoint-state-invalid');
|
|
78
|
+
const retired = value.version === 1 ? [] : value.retired;
|
|
79
|
+
if (!Array.isArray(retired) || retired.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-invalid');
|
|
80
|
+
const checkedRetired = retired.map(refPoint), ids = new Set(points.map(point => point.id));
|
|
81
|
+
for (const point of checkedRetired) {
|
|
82
|
+
if (ids.has(point.id)) throw Error('checkpoint-state-invalid');
|
|
83
|
+
ids.add(point.id);
|
|
84
|
+
}
|
|
85
|
+
const staged = value.version >= 3 ? value.staged : [];
|
|
86
|
+
if (!Array.isArray(staged) || staged.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-invalid');
|
|
87
|
+
const checkedStaged = staged.map(checkpointPoint);
|
|
88
|
+
for (const point of checkedStaged) {
|
|
89
|
+
if (ids.has(point.id)) throw Error('checkpoint-state-invalid');
|
|
90
|
+
ids.add(point.id);
|
|
91
|
+
}
|
|
92
|
+
const capture = value.version === 4 ? captureRecord(value.capture) : null;
|
|
93
|
+
if (capture && ids.has(capture.point.id)) throw Error('checkpoint-state-invalid');
|
|
94
|
+
return { revision: hash(bytes), points, retired: checkedRetired, staged: checkedStaged, capture };
|
|
95
|
+
}
|
|
96
|
+
#acquire() {
|
|
97
|
+
ensurePrivateStateDirectory(this.root, this.directory, 'Checkpoint directory');
|
|
98
|
+
const directory = fs.statSync(this.#safe(this.directory, 'directory'));
|
|
99
|
+
if (typeof process.getuid === 'function' && (directory.uid !== process.getuid() || (directory.mode & 0o077)))
|
|
100
|
+
throw Error('checkpoint-state-not-private');
|
|
101
|
+
const target = this.#safe(this.lock);
|
|
102
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
103
|
+
try {
|
|
104
|
+
const fd = fs.openSync(target, 'wx', 0o600);
|
|
105
|
+
const token = randomUUID();
|
|
106
|
+
try { fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token })); }
|
|
107
|
+
finally { fs.closeSync(fd); }
|
|
108
|
+
const assertOwner = () => {
|
|
109
|
+
const current = this.#read(this.lock);
|
|
110
|
+
if (JSON.parse(current.bytes).token !== token) throw Error('checkpoint-lock-changed');
|
|
111
|
+
};
|
|
112
|
+
return { assertOwner, release: () => { assertOwner(); fs.unlinkSync(this.#safe(this.lock, 'file')); } };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.code !== 'EEXIST') throw error;
|
|
115
|
+
const current = this.#read(this.lock), owner = JSON.parse(current.bytes);
|
|
116
|
+
if (!exact(owner, ['pid', 'token']) || !Number.isInteger(owner.pid) || owner.pid <= 1 || !id(owner.token)) throw Error('checkpoint-lock-invalid');
|
|
117
|
+
if (ownerAlive(owner.pid, current.stat.mtimeMs)) throw Error('checkpoint-store-busy');
|
|
118
|
+
if (!same(current.stat, fs.lstatSync(this.#safe(this.lock, 'file')))) throw Error('checkpoint-lock-changed');
|
|
119
|
+
fs.unlinkSync(this.lock);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw Error('checkpoint-store-busy');
|
|
123
|
+
}
|
|
124
|
+
// Hold the same lock through asynchronous Git effects and the final CAS save.
|
|
125
|
+
// A second process must refuse before touching files, not only when saving.
|
|
126
|
+
async operation(action) {
|
|
127
|
+
if (this.#operation) throw Error('checkpoint-store-busy');
|
|
128
|
+
const owner = this.#acquire();
|
|
129
|
+
this.#operation = owner;
|
|
130
|
+
try { return await action(); }
|
|
131
|
+
finally { this.#operation = null; owner.release(); }
|
|
132
|
+
}
|
|
133
|
+
save(points, expectedRevision, { clearRetired = false, staged = [], capture = undefined } = {}) {
|
|
134
|
+
if (!Array.isArray(points) || points.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-limit');
|
|
135
|
+
if (!Array.isArray(staged) || staged.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-limit');
|
|
136
|
+
const checkedStaged = staged.map(checkpointPoint);
|
|
137
|
+
const checked = points.map(checkpointPoint);
|
|
138
|
+
if (new Set(checked.map(point => point.id)).size !== checked.length) throw Error('checkpoint-state-invalid');
|
|
139
|
+
const retained = checked.slice(-MAX_CHECKPOINTS), dropped = checked.slice(0, -MAX_CHECKPOINTS);
|
|
140
|
+
const owner = this.#operation ?? this.#acquire();
|
|
141
|
+
owner.assertOwner();
|
|
142
|
+
let temporary;
|
|
143
|
+
try {
|
|
144
|
+
const previous = this.load();
|
|
145
|
+
if (previous.revision !== expectedRevision) throw Error('checkpoint-state-stale');
|
|
146
|
+
const nextCapture = capture === undefined ? previous.capture ?? null : captureRecord(capture);
|
|
147
|
+
const promotedIds = new Set([...retained, ...checkedStaged].map(point => point.id));
|
|
148
|
+
const abandoned = previous.capture && previous.capture.point.id !== nextCapture?.point.id
|
|
149
|
+
&& !promotedIds.has(previous.capture.point.id) ? [previous.capture.point] : [];
|
|
150
|
+
const retainedIds = new Set(retained.map(point => point.id));
|
|
151
|
+
const retired = [...new Map([...(clearRetired ? [] : previous.retired ?? []), ...(clearRetired ? [] : previous.staged ?? []), ...previous.points, ...dropped, ...abandoned]
|
|
152
|
+
.filter(point => !retainedIds.has(point.id)).map(point => [point.id, point])).values()];
|
|
153
|
+
if (retired.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-limit');
|
|
154
|
+
const ids = new Set([...retained, ...retired].map(point => point.id));
|
|
155
|
+
for (const point of checkedStaged) {
|
|
156
|
+
if (ids.has(point.id)) throw Error('checkpoint-state-invalid');
|
|
157
|
+
ids.add(point.id);
|
|
158
|
+
}
|
|
159
|
+
if (nextCapture && ids.has(nextCapture.point.id)) throw Error('checkpoint-state-invalid');
|
|
160
|
+
const bytes = Buffer.from(JSON.stringify({ version: 4, sessionKey: this.key, points: retained, retired, staged: checkedStaged, capture: nextCapture }) + '\n');
|
|
161
|
+
if (bytes.length > MAX_BYTES) throw Error('checkpoint-state-limit');
|
|
162
|
+
temporary = this.#safe(path.join(this.directory, `${this.key}.${randomUUID()}.tmp`));
|
|
163
|
+
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
164
|
+
try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
165
|
+
// Revalidate ancestors and destination after writing the private temp file.
|
|
166
|
+
const target = this.#safe(this.file);
|
|
167
|
+
if (this.load().revision !== expectedRevision) throw Error('checkpoint-state-stale');
|
|
168
|
+
fs.renameSync(this.#safe(temporary, 'file'), target); temporary = null;
|
|
169
|
+
if (process.platform !== 'win32') {
|
|
170
|
+
const directory = fs.openSync(this.#safe(this.directory, 'directory'), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
171
|
+
try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
|
|
172
|
+
}
|
|
173
|
+
return { revision: hash(bytes), points: retained, dropped, retired, staged: checkedStaged, capture: nextCapture };
|
|
174
|
+
} finally {
|
|
175
|
+
if (temporary) fs.unlinkSync(this.#safe(temporary, 'file'));
|
|
176
|
+
if (owner !== this.#operation) owner.release();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { PersonalCheckpointGit } from './personal-git.ts';
|
|
2
|
+
import type { WorkspacePolicy } from '../../extensions/workspace-inspection.ts';
|
|
3
|
+
|
|
4
|
+
// Reuse the guarded raw-byte adapter inside the sandbox. No checkout filters,
|
|
5
|
+
// host fallback or project policy bypass is needed for company restoration.
|
|
6
|
+
export function managedCheckpointOperation(cwd: string, policy: () => WorkspacePolicy, value: unknown) {
|
|
7
|
+
const input = value as Record<string, unknown>;
|
|
8
|
+
if (!input || input.kind !== 'checkpoint') throw Error('checkpoint-request-invalid');
|
|
9
|
+
const fields: Record<string, string[]> = { available: [], tree: [], changed: ['from', 'to'], restore: ['before', 'restore', 'remove', 'expectedTree'] };
|
|
10
|
+
if (typeof input.operation !== 'string' || !Object.hasOwn(fields, input.operation)) throw Error('checkpoint-request-invalid');
|
|
11
|
+
const expected = ['kind', 'operation', ...fields[input.operation]!];
|
|
12
|
+
if (Object.keys(input).length !== expected.length || Object.keys(input).some(key => !expected.includes(key))) throw Error('checkpoint-request-invalid');
|
|
13
|
+
const oid = (key: string) => {
|
|
14
|
+
if (typeof input[key] !== 'string' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(input[key])) throw Error('checkpoint-object-invalid');
|
|
15
|
+
return input[key] as string;
|
|
16
|
+
};
|
|
17
|
+
const paths = (key: string) => {
|
|
18
|
+
const value = input[key];
|
|
19
|
+
if (!Array.isArray(value) || value.length > 10000 || value.some(path => typeof path !== 'string' || !path || path.length > 4096)) throw Error('checkpoint-path-invalid');
|
|
20
|
+
return value as string[];
|
|
21
|
+
};
|
|
22
|
+
const git = new PersonalCheckpointGit(cwd, policy);
|
|
23
|
+
if (input.operation === 'available') return git.available();
|
|
24
|
+
if (input.operation === 'tree') return git.tree();
|
|
25
|
+
if (input.operation === 'changed') return git.changed(oid('from'), oid('to'));
|
|
26
|
+
git.restore(oid('before'), paths('restore'), paths('remove'), oid('expectedTree'));
|
|
27
|
+
return { restored: true };
|
|
28
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Git operations are shared; the caller chooses the execution boundary.
|
|
2
|
+
// Keeping private-index timestamps makes Git recheck same-second, same-size edits.
|
|
3
|
+
// HEAD and the member's index are never used as restoration targets.
|
|
4
|
+
const SNAPSHOT = `set -e; git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo NOREPO; exit 0; }
|
|
5
|
+
top=$(git rev-parse --show-toplevel); cd "$top"; idx="$TMPDIR/piagent-checkpoint-$$"
|
|
6
|
+
real=$(git rev-parse --git-path index); if [ -f "$real" ]; then cp -p "$real" "$idx"; fi
|
|
7
|
+
GIT_INDEX_FILE="$idx" git add -A >/dev/null 2>&1; tree=$(GIT_INDEX_FILE="$idx" git write-tree); rm -f "$idx"; echo "TREE $tree"`;
|
|
8
|
+
const b64 = list => Buffer.from(list.map(p => p + '\0').join(''), 'utf8').toString('base64');
|
|
9
|
+
|
|
10
|
+
export class CheckpointGitAdapter {
|
|
11
|
+
constructor(execute) { this.execute = execute; }
|
|
12
|
+
async available() {
|
|
13
|
+
return (await this.execute('git rev-parse --is-inside-work-tree 2>/dev/null || true')).trim() === 'true';
|
|
14
|
+
}
|
|
15
|
+
async tree() {
|
|
16
|
+
const out = await this.execute(SNAPSHOT);
|
|
17
|
+
return out.match(/^TREE ([0-9a-f]{40,64})$/m)?.[1] ?? null;
|
|
18
|
+
}
|
|
19
|
+
async changed(from, to) {
|
|
20
|
+
const out = await this.execute(`top=$(git rev-parse --show-toplevel) && cd "$top" && git diff --no-renames --name-status -z ${from} ${to}`);
|
|
21
|
+
const parts = out.split('\0').filter(Boolean), files = [];
|
|
22
|
+
for (let i = 0; i + 1 < parts.length; i += 2) files.push({ status: parts[i].trim(), path: parts[i + 1] });
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
async restore(before, restore, remove, expectedTree, guard) {
|
|
26
|
+
const steps = ['set -e', 'top=$(git rev-parse --show-toplevel); cd "$top"; idx="$TMPDIR/piagent-undo-$$"'];
|
|
27
|
+
if (expectedTree) {
|
|
28
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(expectedTree)) throw Error('checkpoint-object-invalid');
|
|
29
|
+
steps.unshift(`current=$(\n${SNAPSHOT}\n)`, `[ "$current" = "TREE ${expectedTree}" ] || { echo checkpoint-content-changed >&2; exit 1; }`);
|
|
30
|
+
}
|
|
31
|
+
if (restore.length) steps.push(`GIT_INDEX_FILE="$idx" git read-tree ${before}`, `printf %s ${b64(restore)} | base64 --decode | GIT_INDEX_FILE="$idx" git checkout-index -f -z --stdin`, 'rm -f "$idx"');
|
|
32
|
+
if (remove.length) steps.push(`printf %s ${b64(remove)} | base64 --decode | xargs -0 rm -f --`);
|
|
33
|
+
guard?.();
|
|
34
|
+
await this.execute(steps.join('\n'), guard);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { ownerAlive } from '../process-owner.mjs';
|
|
3
|
+
// The SDK loader and native Gateway may load separate module instances.
|
|
4
|
+
const ownersKey = Symbol.for('piagent.checkpoint.capture-owners');
|
|
5
|
+
const owners = globalThis[ownersKey] ??= new Set();
|
|
6
|
+
// Publish refs before state; remove retired refs only after state is durable.
|
|
7
|
+
// A failed save may leave extra refs, but must never expose unprotected trees.
|
|
8
|
+
export class PersistentCheckpointHistory {
|
|
9
|
+
#token = randomUUID();
|
|
10
|
+
#revoked = false;
|
|
11
|
+
revoke() { this.#revoked = true; owners.delete(this.#token); }
|
|
12
|
+
constructor(store, refs) {
|
|
13
|
+
this.store = store; this.refs = refs;
|
|
14
|
+
this.current = store.load();
|
|
15
|
+
}
|
|
16
|
+
operation(action) {
|
|
17
|
+
return this.store.operation(async () => {
|
|
18
|
+
// A closed owner may have changed only capture/cleanup metadata. The
|
|
19
|
+
// service's in-memory turn stack remains valid only when points are equal.
|
|
20
|
+
const latest = this.store.load();
|
|
21
|
+
if (latest.revision !== this.current.revision && JSON.stringify(latest.points) === JSON.stringify(this.current.points)) this.current = latest;
|
|
22
|
+
this.assertCurrent(); await this.#checkCapture(); await this.#drainRetired(); return await action();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async close() {
|
|
26
|
+
try {
|
|
27
|
+
if (this.store.load().capture?.token !== this.#token) return;
|
|
28
|
+
await this.store.operation(async () => {
|
|
29
|
+
const latest = this.store.load();
|
|
30
|
+
if (latest.capture?.token === this.#token) this.current = this.store.save(latest.points, latest.revision, { capture: null });
|
|
31
|
+
});
|
|
32
|
+
} finally { this.revoke(); }
|
|
33
|
+
}
|
|
34
|
+
load() { return structuredClone(this.current.points); }
|
|
35
|
+
assertCurrent() {
|
|
36
|
+
if (this.#revoked) throw Error('checkpoint-authority-changed');
|
|
37
|
+
if (this.store.load().revision !== this.current.revision) throw Error('checkpoint-state-stale');
|
|
38
|
+
}
|
|
39
|
+
async #checkCapture() {
|
|
40
|
+
const capture = this.current.capture;
|
|
41
|
+
if (!capture || capture.token === this.#token) return;
|
|
42
|
+
if (capture.pid === process.pid) {
|
|
43
|
+
if (owners.has(capture.token)) throw Error('checkpoint-turn-owned');
|
|
44
|
+
} else if (ownerAlive(capture.pid, capture.point.at)) throw Error('checkpoint-turn-owned');
|
|
45
|
+
// An interrupted turn has no trustworthy after snapshot. Keep its edits,
|
|
46
|
+
// retire only its refs, and leave completed history available for drift checks.
|
|
47
|
+
this.current = this.store.save(this.current.points, this.current.revision, { capture: null });
|
|
48
|
+
await this.#drainRetired();
|
|
49
|
+
}
|
|
50
|
+
async #drainRetired() {
|
|
51
|
+
const retired = [...(this.current.retired ?? []), ...(this.current.staged ?? [])];
|
|
52
|
+
if (!retired.length) return;
|
|
53
|
+
for (const point of retired) await this.refs.release(point);
|
|
54
|
+
// A crash before acknowledgement retries exact-value deletion. Ref release
|
|
55
|
+
// accepts already missing refs, so no durable cleanup entry is lost early.
|
|
56
|
+
this.current = this.store.save(this.current.points, this.current.revision, { clearRetired: true });
|
|
57
|
+
}
|
|
58
|
+
async begin(point) {
|
|
59
|
+
await this.prepare();
|
|
60
|
+
if (this.current.capture) throw Error('checkpoint-turn-owned');
|
|
61
|
+
const capture = { point: { ...point, after: null }, pid: process.pid, token: this.#token };
|
|
62
|
+
if (owners.size >= 1024) throw Error('checkpoint-session-capacity');
|
|
63
|
+
owners.add(this.#token);
|
|
64
|
+
try { this.current = this.store.save(this.current.points, this.current.revision, { capture }); }
|
|
65
|
+
catch (error) { owners.delete(this.#token); throw error; }
|
|
66
|
+
await this.refs.pin(capture.point);
|
|
67
|
+
}
|
|
68
|
+
async prepare() { this.assertCurrent(); await this.#checkCapture(); await this.#drainRetired(); this.assertCurrent(); }
|
|
69
|
+
async abandon(point) {
|
|
70
|
+
this.assertCurrent(); await this.#checkCapture();
|
|
71
|
+
if (this.current.capture?.point.id !== point.id) throw Error('checkpoint-capture-mismatch');
|
|
72
|
+
this.current = this.store.save(this.current.points, this.current.revision, { capture: null });
|
|
73
|
+
owners.delete(this.#token);
|
|
74
|
+
await this.#drainRetired();
|
|
75
|
+
}
|
|
76
|
+
async save(points) {
|
|
77
|
+
this.assertCurrent(); await this.#checkCapture();
|
|
78
|
+
// Do not accumulate another batch while an earlier cleanup is blocked.
|
|
79
|
+
await this.#drainRetired();
|
|
80
|
+
const existing = new Set(this.current.points.map(point => point.id));
|
|
81
|
+
const staged = points.filter(point => !existing.has(point.id));
|
|
82
|
+
// Record new refs before creating them. A failed pin/publication or process
|
|
83
|
+
// exit leaves enough data for exact-value cleanup on the next operation.
|
|
84
|
+
if (staged.length) {
|
|
85
|
+
this.current = this.store.save(this.current.points, this.current.revision, { staged, capture: null });
|
|
86
|
+
owners.delete(this.#token);
|
|
87
|
+
}
|
|
88
|
+
for (const point of points) await this.refs.pin(point);
|
|
89
|
+
const saved = this.store.save(points, this.current.revision);
|
|
90
|
+
this.current = saved;
|
|
91
|
+
// Publication is the commit point. Cleanup failure must not leave the
|
|
92
|
+
// caller using an old stack after files and durable history have advanced.
|
|
93
|
+
try { await this.#drainRetired(); } catch { /* Retry before the next publication or turn. */ }
|
|
94
|
+
return structuredClone(saved.points);
|
|
95
|
+
}
|
|
96
|
+
}
|