@piagent/platform 1.37.1 → 1.38.2
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 +60 -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/patch-snapshot.mjs +6 -8
- package/packages/piagent-core/managed/permission.mjs +29 -6
- package/packages/piagent-core/managed/repository-operation.mjs +2 -1
- package/packages/piagent-core/managed/review-snapshot-request.mjs +21 -0
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +2 -0
- package/packages/piagent-core/managed/session-mutation-observations.mjs +37 -0
- package/packages/piagent-core/managed/session.mjs +2 -2
- package/packages/piagent-core/managed/source-revert-requests.mjs +21 -0
- package/packages/piagent-core/managed/terminal-boundary.mjs +88 -0
- package/packages/piagent-core/managed/terminal-channel.mjs +66 -0
- package/packages/piagent-core/managed/terminal-runtime.mjs +64 -0
- package/packages/piagent-core/managed/terminal-seatbelt.mjs +48 -0
- package/packages/piagent-core/managed/terminal-worker.mjs +70 -0
- package/packages/piagent-core/managed/tool-boundary.mjs +45 -35
- package/packages/piagent-core/managed/tool-freshness.mjs +47 -0
- package/packages/piagent-core/managed/tool-worker.mjs +18 -3
- package/packages/piagent-core/managed/workspace-worker-pins.mjs +26 -0
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/policies/base-policy.json +2 -1
- package/packages/piagent-core/runtime/checkpoints/checkpoint-authority.ts +43 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-refs.mjs +51 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-store.mjs +179 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-worker.ts +28 -0
- package/packages/piagent-core/runtime/checkpoints/git-adapter.mjs +36 -0
- package/packages/piagent-core/runtime/checkpoints/persistent-history.mjs +96 -0
- package/packages/piagent-core/runtime/checkpoints/personal-git.ts +272 -0
- package/packages/piagent-core/runtime/checkpoints/personal-runtime.ts +158 -0
- package/packages/piagent-core/runtime/checkpoints/prune.mjs +29 -0
- package/packages/piagent-core/runtime/checkpoints/restore-preview.mjs +46 -0
- package/packages/piagent-core/runtime/checkpoints/turn-checkpoints.mjs +167 -0
- package/packages/piagent-core/runtime/hooks/session-hooks.ts +19 -16
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +2 -2
- package/packages/piagent-core/runtime/inspection/diff-projection.ts +2 -1
- package/packages/piagent-core/runtime/inspection/managed-source-revert.ts +90 -0
- package/packages/piagent-core/runtime/inspection/review-state-projection.ts +19 -8
- package/packages/piagent-core/runtime/inspection/session-diff-projection.ts +47 -0
- package/packages/piagent-core/runtime/inspection/session-hunk-provenance.ts +56 -0
- package/packages/piagent-core/runtime/inspection/session-mutation-observer.ts +125 -0
- package/packages/piagent-core/runtime/inspection/source-action-contract.ts +123 -0
- package/packages/piagent-core/runtime/inspection/source-change-projection.ts +3 -9
- package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +3 -41
- package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +6 -36
- package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +36 -30
- package/packages/piagent-core/runtime/inspection/text-diff.ts +5 -4
- package/packages/piagent-core/runtime/inspection/workspace-worker.ts +79 -0
- package/packages/piagent-core/runtime/policy/source-index-transaction.ts +3 -10
- package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +27 -2
- package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +42 -12
- package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +10 -5
- package/packages/piagent-core/runtime/policy/workspace-write-authority.ts +51 -0
- package/packages/piagent-core/runtime/process-owner.mjs +20 -0
- package/packages/piagent-core/runtime/process-tree-watch.mjs +46 -0
- package/packages/piagent-core/runtime/quality/model-mutation-proof.ts +8 -5
- package/packages/piagent-core/runtime/registration/permission-commands.ts +10 -2
- package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
- package/packages/piagent-core/runtime/session/workspace-member-edits.d.mts +7 -0
- package/packages/piagent-core/runtime/session/workspace-member-edits.mjs +41 -0
- package/packages/piagent-core/security/workspace-file-reader.ts +2 -1
- package/packages/piagent-webui/dist/client/assets/Alert-DGWhP2MM.js +1 -0
- package/packages/piagent-webui/dist/client/assets/Button-QItfZUNj.js +1 -0
- package/packages/piagent-webui/dist/client/assets/DialogTitle-CyaFPaiW.js +3 -0
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-DtEwqme0.js +29 -0
- package/packages/piagent-webui/dist/client/assets/TerminalPanel-C3vkzjQ6.js +2 -0
- package/packages/piagent-webui/dist/client/assets/TerminalView-TNjhBGfa.js +9 -0
- package/packages/piagent-webui/dist/client/assets/TerminalView-kHJ-D0s7.css +1 -0
- package/packages/piagent-webui/dist/client/assets/Typography-B9om7Evq.js +1 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-BAhYwQNM.js +2 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-Coh_GWCO.js +5 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-Bn4miZq4.js +1 -0
- package/packages/piagent-webui/dist/client/assets/bootstrap-CedkNtRG.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-B5MCHZv5.js +9 -0
- package/packages/piagent-webui/dist/client/assets/dist-BM_LL1o9.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-C2TMLUTo.js +23 -0
- package/packages/piagent-webui/dist/client/assets/dist-CqL3udNu.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-D4df0jLQ.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-D6i5SpQu.js +10 -0
- package/packages/piagent-webui/dist/client/assets/dist-DfjUS8LS.js +6 -0
- package/packages/piagent-webui/dist/client/assets/dist-IRUjhd1P.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-sNhoSseW.js +1 -0
- package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +1 -0
- package/packages/piagent-webui/dist/client/assets/index-a-perpOX.js +102 -0
- package/packages/piagent-webui/dist/client/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/packages/piagent-webui/dist/client/assets/tab-close-nAxrK7Lq.js +1 -0
- package/packages/piagent-webui/dist/client/assets/ui-preferences-BO3AV7QE.js +10 -0
- package/packages/piagent-webui/dist/client/assets/useId-DEefKUAT.js +124 -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 +15 -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,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
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { workspacePathFlags, type WorkspacePolicy } from '../../extensions/workspace-inspection.ts';
|
|
7
|
+
import { effectiveProtectedPaths, matchesProtectedPath } from '../../extensions/workspace-policy.js';
|
|
8
|
+
import { inspectWorkspaceEntry, readWorkspaceLink, WorkspaceReadError } from '../../security/workspace-file-reader.ts';
|
|
9
|
+
|
|
10
|
+
// Untracked files above this size (a dataset, a build artifact nobody ignored)
|
|
11
|
+
// stay out of snapshots: undo leaves them alone rather than copying them into
|
|
12
|
+
// Git objects on every turn. Tracked files of any size are included.
|
|
13
|
+
const MAX_UNTRACKED = 100 << 20, MAX_ENTRIES = 500000, OUTPUT = 256 << 20;
|
|
14
|
+
const executable = process.platform === 'darwin' ? '/usr/bin/git' : 'git';
|
|
15
|
+
const oid = (value: string) => { if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)) throw Error('checkpoint-object-invalid'); return value; };
|
|
16
|
+
const internal = (file: string) => file.split('/').includes('.git') || file === '.pi/piagent-state' || file.startsWith('.pi/piagent-state/');
|
|
17
|
+
const safe = (file: string) => Boolean(file) && file.length <= 4096 && !/[\\\x00-\x1f\x7f]/.test(file) && !path.isAbsolute(file)
|
|
18
|
+
&& !file.split('/').some(part => !part || part === '.' || part === '..') && !internal(file);
|
|
19
|
+
function valid(file: string) { if (!safe(file)) throw Error('checkpoint-path-invalid'); }
|
|
20
|
+
type FileState = { mode: string; fingerprint: string; permissions: number };
|
|
21
|
+
type TreeEntry = { mode: string; object: string; file: string };
|
|
22
|
+
type Hashed = { key: string; object: string };
|
|
23
|
+
const statKey = (stat: fs.Stats) => `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.mode}`;
|
|
24
|
+
|
|
25
|
+
// Snapshots hash raw bytes into a private index; neither Git filters nor
|
|
26
|
+
// checkout commands read or write workspace paths. One `hash-object
|
|
27
|
+
// --stdin-paths` per snapshot, and files unchanged since the last snapshot are
|
|
28
|
+
// not read again. Files Git cannot snapshot as blobs (submodules, sockets) are
|
|
29
|
+
// left out, so undo never touches them, instead of failing the turn.
|
|
30
|
+
// With `store`, objects and refs live in a private repository inside the Git
|
|
31
|
+
// directory: `git push --mirror` and the project's refs never see them, and the
|
|
32
|
+
// project's objects serve as alternates so tracked content is not copied.
|
|
33
|
+
export class PersonalCheckpointGit {
|
|
34
|
+
readonly root: string;
|
|
35
|
+
readonly #policy: () => WorkspacePolicy;
|
|
36
|
+
readonly #store: boolean;
|
|
37
|
+
#repository: string | null = null;
|
|
38
|
+
#objects: string | undefined;
|
|
39
|
+
#hashed = new Map<string, Hashed>();
|
|
40
|
+
constructor(root: string, policy: () => WorkspacePolicy, { store = false }: { store?: boolean } = {}) {
|
|
41
|
+
this.root = fs.realpathSync(root); this.#policy = policy; this.#store = store;
|
|
42
|
+
}
|
|
43
|
+
#run(args: string[], input?: Buffer, index?: string, cwd = this.root, objects: string | null | undefined = this.#objects, output?: number): Buffer {
|
|
44
|
+
// `objects` null: the project's own repository (listing files, finding it).
|
|
45
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^GIT_/i.test(name)));
|
|
46
|
+
Object.assign(env, { GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1', ...(index ? { GIT_INDEX_FILE: index } : {}) });
|
|
47
|
+
const argv = ['-C', cwd, ...(objects ? [`--git-dir=${objects}`] : []), ...args];
|
|
48
|
+
return execFileSync(executable, argv, { env, input, timeout: 120000, maxBuffer: OUTPUT,
|
|
49
|
+
stdio: [input ? 'pipe' : 'ignore', output ?? 'pipe', 'pipe'] }) ?? Buffer.alloc(0);
|
|
50
|
+
}
|
|
51
|
+
#top(): string | null {
|
|
52
|
+
if (fs.realpathSync(this.root) !== this.root) throw Error('checkpoint-project-changed');
|
|
53
|
+
let top: string;
|
|
54
|
+
try { top = fs.realpathSync(this.#run(['rev-parse', '--show-toplevel'], undefined, undefined, this.root, null).toString().trim()); }
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!this.#repository && /not a git repository/i.test(String((error as { stderr?: Buffer }).stderr))) return null;
|
|
57
|
+
throw Error('checkpoint-repository-unavailable');
|
|
58
|
+
}
|
|
59
|
+
const relative = path.relative(top, this.root);
|
|
60
|
+
if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative) || this.#repository && this.#repository !== top)
|
|
61
|
+
throw Error('checkpoint-project-changed');
|
|
62
|
+
this.#repository = top;
|
|
63
|
+
if (this.#store && !this.#objects) this.#objects = this.#openStore();
|
|
64
|
+
return top;
|
|
65
|
+
}
|
|
66
|
+
#openStore(): string {
|
|
67
|
+
const common = path.resolve(this.root, this.#run(['rev-parse', '--git-common-dir'], undefined, undefined, this.root, null).toString().trim());
|
|
68
|
+
const parent = path.join(common, 'piagent'), store = path.join(parent, 'checkpoints.git');
|
|
69
|
+
for (const directory of [parent, store]) {
|
|
70
|
+
try { fs.mkdirSync(directory, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
|
|
71
|
+
const stat = fs.lstatSync(directory);
|
|
72
|
+
if (!stat.isDirectory() || typeof process.getuid === 'function' && stat.uid !== process.getuid()) throw Error('checkpoint-store-invalid');
|
|
73
|
+
}
|
|
74
|
+
if (!fs.existsSync(path.join(store, 'HEAD'))) this.#run(['init', '--bare', '--quiet', '--template=', store], undefined, undefined, this.root, null);
|
|
75
|
+
const alternates = path.join(store, 'objects', 'info', 'alternates'), objects = path.join(common, 'objects') + '\n';
|
|
76
|
+
fs.mkdirSync(path.dirname(alternates), { recursive: true });
|
|
77
|
+
if (!fs.existsSync(alternates) || fs.readFileSync(alternates, 'utf8') !== objects) fs.writeFileSync(alternates, objects, { mode: 0o600 });
|
|
78
|
+
return store;
|
|
79
|
+
}
|
|
80
|
+
available() { return this.#top() !== null; }
|
|
81
|
+
#flags(file: string, write = false) {
|
|
82
|
+
valid(file);
|
|
83
|
+
const policy = this.#policy(), flags = workspacePathFlags(file, policy);
|
|
84
|
+
if (flags.protected) throw Error(`checkpoint-path-protected: ${file}`);
|
|
85
|
+
if (write && (flags.readOnly || policy.readOnly === undefined && policy.profile.permissionProfile === 'read-only'))
|
|
86
|
+
throw Error(`checkpoint-read-only: ${file}`);
|
|
87
|
+
}
|
|
88
|
+
// Drift checks compare identity and timestamps, which every write changes,
|
|
89
|
+
// so a restore never reads whole files into memory.
|
|
90
|
+
#state(file: string): FileState | null {
|
|
91
|
+
this.#flags(file);
|
|
92
|
+
let stat: fs.Stats;
|
|
93
|
+
try { stat = inspectWorkspaceEntry(this.root, file); }
|
|
94
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; }
|
|
95
|
+
let mode: string, link = '';
|
|
96
|
+
if (stat.isSymbolicLink()) { mode = '120000'; link = readWorkspaceLink(this.root, file); }
|
|
97
|
+
else if (stat.isFile()) mode = stat.mode & 0o111 ? '100755' : '100644';
|
|
98
|
+
else throw Error(`checkpoint-unsupported-entry: ${file}`);
|
|
99
|
+
return { mode, permissions: stat.mode & 0o777, fingerprint: `${mode}:${statKey(stat)}:${link}` };
|
|
100
|
+
}
|
|
101
|
+
#list(args: string[]) {
|
|
102
|
+
return this.#run(['ls-files', ...args, '-z', '--', '.'], undefined, undefined, this.root, null)
|
|
103
|
+
.toString('utf8').split('\0').filter(Boolean);
|
|
104
|
+
}
|
|
105
|
+
#hash(files: { file: string; key: string }[], top: string): string[] {
|
|
106
|
+
if (!files.length) return [];
|
|
107
|
+
const input = Buffer.from(files.map(item => path.join(this.root, item.file)).join('\n') + '\n');
|
|
108
|
+
try {
|
|
109
|
+
const objects = this.#run(['hash-object', '-w', '--no-filters', '--stdin-paths'], input, undefined, top).toString().trim().split('\n');
|
|
110
|
+
if (objects.length !== files.length) throw Error('checkpoint-object-invalid');
|
|
111
|
+
return objects.map(oid);
|
|
112
|
+
} catch {
|
|
113
|
+
// A file removed while the batch ran: hash the rest one by one.
|
|
114
|
+
return files.map(item => {
|
|
115
|
+
try { return oid(this.#run(['hash-object', '-w', '--no-filters', '--', path.join(this.root, item.file)], undefined, undefined, top).toString().trim()); }
|
|
116
|
+
catch { if (!fs.existsSync(path.join(this.root, item.file))) return ''; throw Error(`checkpoint-hash-failed: ${item.file}`); }
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
tree(): string | null {
|
|
121
|
+
const top = this.#top(); if (!top) return null;
|
|
122
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'piagent-checkpoint-')); fs.chmodSync(temporary, 0o700);
|
|
123
|
+
const index = path.join(temporary, 'index'), policy = this.#policy();
|
|
124
|
+
// workspacePathFlags(...).protected, with each folder decided once per snapshot.
|
|
125
|
+
const patterns = effectiveProtectedPaths(policy.policy, policy.profile).readProtectedPaths, known = new Map<string, boolean>();
|
|
126
|
+
const protectedPath = (file: string) => file.split('/').some((_, i, parts) => {
|
|
127
|
+
const prefix = parts.slice(0, i + 1).join('/');
|
|
128
|
+
if (!known.has(prefix)) known.set(prefix, !!matchesProtectedPath(prefix, patterns));
|
|
129
|
+
return known.get(prefix);
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
const tracked = new Set(this.#list(['--cached'])), records: string[] = [], pending: { file: string; mode: string; key: string }[] = [];
|
|
133
|
+
const listed = [...new Set(this.#list(['--cached', '--others', '--exclude-standard']))].sort();
|
|
134
|
+
if (listed.length > MAX_ENTRIES) throw Error('checkpoint-file-limit');
|
|
135
|
+
const now = Date.now(), seen = new Set<string>();
|
|
136
|
+
for (const file of listed) {
|
|
137
|
+
if (!safe(file) || protectedPath(file)) continue;
|
|
138
|
+
let stat: fs.Stats;
|
|
139
|
+
try { stat = inspectWorkspaceEntry(this.root, file); }
|
|
140
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT' || error instanceof WorkspaceReadError) continue; throw error; }
|
|
141
|
+
if (stat.isSymbolicLink()) {
|
|
142
|
+
let target: string;
|
|
143
|
+
try { target = readWorkspaceLink(this.root, file); } catch { continue; }
|
|
144
|
+
const object = oid(this.#run(['hash-object', '-w', '--no-filters', '--stdin'], Buffer.from(target), undefined, top).toString().trim());
|
|
145
|
+
records.push(`120000 ${object}\t${file}\0`); continue;
|
|
146
|
+
}
|
|
147
|
+
if (!stat.isFile() || !tracked.has(file) && stat.size > MAX_UNTRACKED) continue;
|
|
148
|
+
const mode = stat.mode & 0o111 ? '100755' : '100644', key = statKey(stat), known = this.#hashed.get(file);
|
|
149
|
+
seen.add(file);
|
|
150
|
+
if (known?.key === key) records.push(`${mode} ${known.object}\t${file}\0`);
|
|
151
|
+
// A file written within the last two seconds may change again without
|
|
152
|
+
// moving its timestamp: hash it now, remember it next time.
|
|
153
|
+
else pending.push({ file, mode, key: now - stat.mtimeMs > 2000 ? key : '' });
|
|
154
|
+
}
|
|
155
|
+
this.#hash(pending, top).forEach((object, i) => {
|
|
156
|
+
const item = pending[i]!; if (!object) return;
|
|
157
|
+
if (item.key) this.#hashed.set(item.file, { key: item.key, object }); else this.#hashed.delete(item.file);
|
|
158
|
+
records.push(`${item.mode} ${object}\t${item.file}\0`);
|
|
159
|
+
});
|
|
160
|
+
for (const file of this.#hashed.keys()) if (!seen.has(file)) this.#hashed.delete(file);
|
|
161
|
+
this.#run(['read-tree', '--empty'], undefined, index, top);
|
|
162
|
+
this.#run(['update-index', '-z', '--index-info'], Buffer.from(records.join('')), index, top);
|
|
163
|
+
return oid(this.#run(['write-tree'], undefined, index, top).toString().trim());
|
|
164
|
+
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
|
165
|
+
}
|
|
166
|
+
#entries(tree: string): Map<string, TreeEntry> {
|
|
167
|
+
const records = this.#run(['ls-tree', '--full-tree', '-r', '-z', oid(tree)]).toString('utf8').split('\0').filter(Boolean);
|
|
168
|
+
if (records.length > MAX_ENTRIES) throw Error('checkpoint-file-limit');
|
|
169
|
+
const entries = new Map<string, TreeEntry>();
|
|
170
|
+
for (const record of records) {
|
|
171
|
+
const match = /^(100644|100755|120000) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);
|
|
172
|
+
if (!match) throw Error('checkpoint-tree-invalid');
|
|
173
|
+
const mode = match[1]!, object = match[2]!, file = match[3]!; valid(file);
|
|
174
|
+
if (entries.has(file)) throw Error('checkpoint-tree-invalid');
|
|
175
|
+
entries.set(file, { mode, object, file });
|
|
176
|
+
}
|
|
177
|
+
return entries;
|
|
178
|
+
}
|
|
179
|
+
changed(from: string, to: string) {
|
|
180
|
+
this.#top();
|
|
181
|
+
const before = this.#entries(from), after = this.#entries(to);
|
|
182
|
+
return [...new Set([...before.keys(), ...after.keys()])].sort().flatMap(file => {
|
|
183
|
+
const a = before.get(file), b = after.get(file);
|
|
184
|
+
return a?.object === b?.object && a?.mode === b?.mode ? [] : [{ status: !a ? 'A' : !b ? 'D' : 'M', path: file }];
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
restore(before: string, restore: string[], remove: string[], expectedTree?: string) {
|
|
188
|
+
if (!expectedTree) throw Error('checkpoint-current-tree-required');
|
|
189
|
+
const currentTree = this.tree();
|
|
190
|
+
if (currentTree !== oid(expectedTree)) {
|
|
191
|
+
const affected = new Set([...restore, ...remove]);
|
|
192
|
+
const changed = currentTree ? this.changed(expectedTree, currentTree).map(entry => entry.path).filter(file => affected.has(file)) : [];
|
|
193
|
+
if (!currentTree || changed.length) throw Error(`checkpoint-content-changed${changed.length ? ': ' + changed.slice(0, 5).join(', ') : ''}`);
|
|
194
|
+
}
|
|
195
|
+
const entries = this.#entries(before), files = [...restore, ...remove], current = this.#entries(expectedTree);
|
|
196
|
+
if (files.length > MAX_ENTRIES || new Set(files).size !== files.length) throw Error('checkpoint-file-limit');
|
|
197
|
+
const states = new Map<string, FileState | null>(), objects: string[] = [];
|
|
198
|
+
for (const file of files) {
|
|
199
|
+
this.#flags(file, true); states.set(file, this.#state(file));
|
|
200
|
+
// On disk but outside the current snapshot (ignored since, or too large
|
|
201
|
+
// to snapshot): its present content was never compared, so keep it.
|
|
202
|
+
if (states.get(file) && !current.has(file)) throw Error(`checkpoint-content-changed: ${file}`);
|
|
203
|
+
if (!restore.includes(file)) continue;
|
|
204
|
+
const entry = entries.get(file); if (!entry) throw Error('checkpoint-tree-invalid');
|
|
205
|
+
objects.push(entry.object);
|
|
206
|
+
}
|
|
207
|
+
// Every object must be readable before the first file changes; contents are
|
|
208
|
+
// then streamed straight into each replacement, whatever their size.
|
|
209
|
+
if (objects.length) {
|
|
210
|
+
const missing = this.#run(['cat-file', '--batch-check=%(objectname) %(objecttype)'], Buffer.from(objects.join('\n') + '\n'))
|
|
211
|
+
.toString().trim().split('\n').filter(line => !/^[a-f0-9]+ blob$/.test(line));
|
|
212
|
+
if (missing.length) throw Error('checkpoint-object-missing');
|
|
213
|
+
}
|
|
214
|
+
// Preflight every file before the first effect; recheck it again immediately
|
|
215
|
+
// before its atomic replacement. No Git checkout/filter can bypass policy.
|
|
216
|
+
for (const file of files) {
|
|
217
|
+
this.#flags(file, true);
|
|
218
|
+
if ((this.#state(file)?.fingerprint ?? null) !== (states.get(file)?.fingerprint ?? null)) throw Error(`checkpoint-content-changed: ${file}`);
|
|
219
|
+
}
|
|
220
|
+
// An error after the first file changed says so (checkpoint-partial), so
|
|
221
|
+
// the caller reports an uncertain result rather than a clean refusal.
|
|
222
|
+
let changed = 0;
|
|
223
|
+
try { for (const file of files) {
|
|
224
|
+
this.#flags(file, true);
|
|
225
|
+
if ((this.#state(file)?.fingerprint ?? null) !== (states.get(file)?.fingerprint ?? null)) throw Error(`checkpoint-content-changed: ${file}`);
|
|
226
|
+
const target = path.join(this.root, file);
|
|
227
|
+
if (remove.includes(file)) { if (states.get(file)) { fs.unlinkSync(target); changed++; this.#prune(file, entries); } continue; }
|
|
228
|
+
const parts = file.split('/');
|
|
229
|
+
for (let i = 1; i < parts.length; i++) {
|
|
230
|
+
const parent = parts.slice(0, i).join('/'); this.#flags(parent, true);
|
|
231
|
+
try { fs.mkdirSync(path.join(this.root, parent)); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
|
|
232
|
+
const stat = inspectWorkspaceEntry(this.root, parent);
|
|
233
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('checkpoint-unsafe-parent');
|
|
234
|
+
}
|
|
235
|
+
const parent = path.dirname(target), parentStat = fs.lstatSync(parent);
|
|
236
|
+
const temporary = path.join(parent, `.piagent-checkpoint-${randomUUID()}`), entry = entries.get(file)!;
|
|
237
|
+
try {
|
|
238
|
+
if (entry.mode === '120000') fs.symlinkSync(this.#run(['cat-file', 'blob', entry.object]).toString('utf8'), temporary);
|
|
239
|
+
else {
|
|
240
|
+
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
241
|
+
try {
|
|
242
|
+
this.#run(['cat-file', 'blob', entry.object], undefined, undefined, this.root, this.#objects, fd);
|
|
243
|
+
// A file the turn deleted comes back as a new file would be made.
|
|
244
|
+
const permissions = states.get(file)?.permissions ?? 0o666 & ~process.umask();
|
|
245
|
+
fs.fchmodSync(fd, (permissions & 0o666) | (entry.mode === '100755' ? 0o111 : 0));
|
|
246
|
+
fs.fsyncSync(fd);
|
|
247
|
+
} finally { fs.closeSync(fd); }
|
|
248
|
+
}
|
|
249
|
+
this.#flags(file, true);
|
|
250
|
+
const currentParent = fs.lstatSync(parent);
|
|
251
|
+
if (fs.realpathSync(parent) !== parent || currentParent.dev !== parentStat.dev || currentParent.ino !== parentStat.ino
|
|
252
|
+
|| (this.#state(file)?.fingerprint ?? null) !== (states.get(file)?.fingerprint ?? null)) throw Error(`checkpoint-content-changed: ${file}`);
|
|
253
|
+
fs.renameSync(temporary, target); changed++;
|
|
254
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
255
|
+
} } catch (error) { throw changed ? Error(`checkpoint-partial: ${(error as Error).message}`) : error; }
|
|
256
|
+
}
|
|
257
|
+
// Folders the turn created and its undo emptied go too; a folder that held
|
|
258
|
+
// files before the turn, or holds anything now, stays.
|
|
259
|
+
#prune(file: string, before: Map<string, TreeEntry>) {
|
|
260
|
+
const parts = file.split('/');
|
|
261
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
262
|
+
const folder = parts.slice(0, i).join('/');
|
|
263
|
+
if ([...before.keys()].some(entry => entry.startsWith(folder + '/'))) return;
|
|
264
|
+
try { fs.rmdirSync(path.join(this.root, folder)); } catch { return; }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Only the ref manager receives this plumbing port; it never accepts shell.
|
|
268
|
+
readonly refsExecutor = { run: (args: string[], input?: Buffer) => {
|
|
269
|
+
if (!this.#top() || !['for-each-ref', 'update-ref'].includes(args[0] ?? '')) throw Error('checkpoint-ref-command-invalid');
|
|
270
|
+
return this.#run(args, input).toString('utf8');
|
|
271
|
+
} };
|
|
272
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { WorkspacePolicy } from '../../extensions/workspace-inspection.ts';
|
|
4
|
+
import { isCompanySession } from '../../extensions/company-session-guard.ts';
|
|
5
|
+
import { PersonalCheckpointGit } from './personal-git.ts';
|
|
6
|
+
import { CheckpointStore } from './checkpoint-store.mjs';
|
|
7
|
+
import { CheckpointRefs } from './checkpoint-refs.mjs';
|
|
8
|
+
import { PersistentCheckpointHistory } from './persistent-history.mjs';
|
|
9
|
+
import { pruneStaleCheckpointSessions } from './prune.mjs';
|
|
10
|
+
import { TurnCheckpointService, checkpointOwnershipError } from './turn-checkpoints.mjs';
|
|
11
|
+
import { checkpointAuthority } from './checkpoint-authority.ts';
|
|
12
|
+
import { workspaceMemberEditsSince } from '../session/workspace-member-edits.mjs';
|
|
13
|
+
|
|
14
|
+
type Run = { turnRef: string; checkpoint?: unknown };
|
|
15
|
+
type Binding = { ctx: ExtensionContext; service: TurnCheckpointService; run?: Run; active: boolean; guard?: () => void; unbind(): void };
|
|
16
|
+
|
|
17
|
+
// The extension owns these bindings; refreshed contexts supply live permissions.
|
|
18
|
+
export class PersonalCheckpointRuntime {
|
|
19
|
+
#bindings = new Map<string, Binding>();
|
|
20
|
+
#pruned = new Set<string>();
|
|
21
|
+
#tail = Promise.resolve();
|
|
22
|
+
#pending = 0;
|
|
23
|
+
private policy: (ctx: ExtensionContext) => WorkspacePolicy;
|
|
24
|
+
constructor(policy: (ctx: ExtensionContext) => WorkspacePolicy) { this.policy = policy; }
|
|
25
|
+
#key(ctx: ExtensionContext) {
|
|
26
|
+
if (ctx.model?.provider === 'agent_watch_managed' || isCompanySession(ctx)) throw Error('checkpoint-company-boundary-required');
|
|
27
|
+
const session = ctx.sessionManager.getSessionId();
|
|
28
|
+
if (!session) throw Error('checkpoint-session-unavailable');
|
|
29
|
+
return `${fs.realpathSync(ctx.cwd)}\0${session}`;
|
|
30
|
+
}
|
|
31
|
+
#binding(ctx: ExtensionContext): Binding {
|
|
32
|
+
const key = this.#key(ctx);
|
|
33
|
+
let binding = this.#bindings.get(key);
|
|
34
|
+
if (!binding) {
|
|
35
|
+
if (this.#bindings.size >= 64) throw Error('checkpoint-session-capacity');
|
|
36
|
+
const state = { ctx, active: true } as Binding;
|
|
37
|
+
const git = new PersonalCheckpointGit(ctx.cwd, () => {
|
|
38
|
+
if (!state.active) throw Error('checkpoint-authority-changed');
|
|
39
|
+
state.guard?.();
|
|
40
|
+
return this.policy(state.ctx);
|
|
41
|
+
}, { store: true });
|
|
42
|
+
const store = new CheckpointStore(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
43
|
+
if (!this.#pruned.has(store.directory)) {
|
|
44
|
+
this.#pruned.add(store.directory);
|
|
45
|
+
try { if (git.available()) pruneStaleCheckpointSessions(store.directory, git.refsExecutor, store.key); } catch { /* best effort */ }
|
|
46
|
+
}
|
|
47
|
+
state.service = new TurnCheckpointService(git, {
|
|
48
|
+
busy: () => { state.guard?.(); return !state.active || !state.ctx.isIdle(); },
|
|
49
|
+
history: new PersistentCheckpointHistory(store, new CheckpointRefs(git.refsExecutor, store.key)),
|
|
50
|
+
memberEdits: since => workspaceMemberEditsSince(state.ctx.sessionManager, since),
|
|
51
|
+
});
|
|
52
|
+
state.unbind = checkpointAuthority.bind(ctx.cwd, ctx.sessionManager.getSessionId(), {
|
|
53
|
+
revoke: () => { state.active = false; state.service.revoke(); },
|
|
54
|
+
port: {
|
|
55
|
+
list: () => this.list(state.ctx),
|
|
56
|
+
preview: target => this.preview(state.ctx, target),
|
|
57
|
+
restoreTo: (target, expected, guard) => this.restoreTo(state.ctx, target, expected, guard),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
binding = state;
|
|
61
|
+
this.#bindings.set(key, binding);
|
|
62
|
+
}
|
|
63
|
+
if (!binding.active) throw Error('checkpoint-authority-changed');
|
|
64
|
+
binding.ctx = ctx;
|
|
65
|
+
return binding;
|
|
66
|
+
}
|
|
67
|
+
bind(ctx: ExtensionContext) {
|
|
68
|
+
if (ctx.model?.provider === 'agent_watch_managed' || isCompanySession(ctx)) return;
|
|
69
|
+
this.#binding(ctx);
|
|
70
|
+
}
|
|
71
|
+
async #settledBinding(ctx: ExtensionContext) {
|
|
72
|
+
const binding = this.#binding(ctx);
|
|
73
|
+
if (ctx.isIdle() && binding.run) {
|
|
74
|
+
await binding.service.finish(binding.run);
|
|
75
|
+
binding.run = undefined;
|
|
76
|
+
}
|
|
77
|
+
return binding;
|
|
78
|
+
}
|
|
79
|
+
list(ctx: ExtensionContext) {
|
|
80
|
+
return this.#serial(async () => (await this.#settledBinding(ctx)).service.list());
|
|
81
|
+
}
|
|
82
|
+
preview(ctx: ExtensionContext, target: string) {
|
|
83
|
+
return this.#serial(async () => (await this.#settledBinding(ctx)).service.preview(target));
|
|
84
|
+
}
|
|
85
|
+
restoreTo(ctx: ExtensionContext, target: string, expected: { digest: string; revision: string; tree: string }, guard?: () => void) {
|
|
86
|
+
return this.#serial(async () => {
|
|
87
|
+
guard?.();
|
|
88
|
+
const binding = await this.#settledBinding(ctx);
|
|
89
|
+
binding.guard = guard;
|
|
90
|
+
try { guard?.(); return await binding.service.restoreTo(target, expected); }
|
|
91
|
+
finally { binding.guard = undefined; }
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
#serial<T>(action: () => Promise<T>): Promise<T> {
|
|
95
|
+
if (this.#pending >= 128) return Promise.reject(Error('checkpoint-command-capacity'));
|
|
96
|
+
this.#pending++;
|
|
97
|
+
const operation = this.#tail.then(action, action);
|
|
98
|
+
this.#tail = operation.then(() => {}, () => {});
|
|
99
|
+
return operation.finally(() => { this.#pending--; });
|
|
100
|
+
}
|
|
101
|
+
beforeMutation(ctx: ExtensionContext) {
|
|
102
|
+
return this.#serial(async () => {
|
|
103
|
+
const binding = this.#binding(ctx);
|
|
104
|
+
let turn: string | undefined;
|
|
105
|
+
try {
|
|
106
|
+
const user = [...ctx.sessionManager.getBranch()].reverse().find(entry => entry.type === 'message' && entry.message.role === 'user');
|
|
107
|
+
turn = user?.id;
|
|
108
|
+
if (!user?.id) throw Error('checkpoint-turn-unavailable');
|
|
109
|
+
if (binding.run?.turnRef !== user.id) {
|
|
110
|
+
// Steering can add a user turn before the enclosing agent settles.
|
|
111
|
+
if (binding.run) await binding.service.finish(binding.run);
|
|
112
|
+
binding.run = { turnRef: user.id };
|
|
113
|
+
}
|
|
114
|
+
await binding.service.take(binding.run);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// Only a turn that already has its checkpoint may change files unrecorded.
|
|
117
|
+
if (!checkpointOwnershipError(error) && (!turn || binding.run?.turnRef !== turn || binding.run.checkpoint === undefined)) binding.service.markGap();
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (!binding.active) throw Error('checkpoint-authority-changed');
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
finish(ctx: ExtensionContext) {
|
|
124
|
+
return this.#serial(async () => {
|
|
125
|
+
if (ctx.model?.provider === 'agent_watch_managed' || isCompanySession(ctx)) return;
|
|
126
|
+
const binding = this.#bindings.get(this.#key(ctx));
|
|
127
|
+
if (!binding) return;
|
|
128
|
+
if (!binding.active) throw Error('checkpoint-authority-changed');
|
|
129
|
+
binding.ctx = ctx;
|
|
130
|
+
if (binding.run) await binding.service.finish(binding.run);
|
|
131
|
+
binding.run = undefined;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
undo(ctx: ExtensionContext) {
|
|
135
|
+
return this.#serial(async () => {
|
|
136
|
+
const binding = this.#binding(ctx);
|
|
137
|
+
// A failed settlement must be resolved before exposing the older stack.
|
|
138
|
+
if (ctx.isIdle() && binding.run) {
|
|
139
|
+
await binding.service.finish(binding.run);
|
|
140
|
+
binding.run = undefined;
|
|
141
|
+
}
|
|
142
|
+
return await binding.service.undo();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
close(ctx: ExtensionContext) {
|
|
146
|
+
return this.#serial(async () => {
|
|
147
|
+
if (ctx.model?.provider === 'agent_watch_managed' || isCompanySession(ctx)) return;
|
|
148
|
+
const key = this.#key(ctx), binding = this.#bindings.get(key);
|
|
149
|
+
if (!binding) return;
|
|
150
|
+
binding.ctx = ctx;
|
|
151
|
+
try { if (binding.active && binding.run) await binding.service.finish(binding.run); }
|
|
152
|
+
finally {
|
|
153
|
+
try { await binding.service.close(); }
|
|
154
|
+
finally { binding.unbind(); this.#bindings.delete(key); }
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const STALE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
// Conversations nobody has touched for 30 days give up their checkpoints: the
|
|
7
|
+
// state file goes, and with it the refs that kept their trees alive, so the
|
|
8
|
+
// private store does not grow with every conversation ever held in a project.
|
|
9
|
+
// A session holding its lock, and the caller's own session, are left alone.
|
|
10
|
+
export function pruneStaleCheckpointSessions(directory, refs, keep, now = Date.now()) {
|
|
11
|
+
let names;
|
|
12
|
+
try { names = fs.readdirSync(directory); } catch { return 0; }
|
|
13
|
+
let pruned = 0;
|
|
14
|
+
for (const name of names) {
|
|
15
|
+
const key = /^([a-f0-9]{64})\.json$/.exec(name)?.[1];
|
|
16
|
+
if (!key || key === keep || names.includes(`${key}.lock`)) continue;
|
|
17
|
+
const file = path.join(directory, name);
|
|
18
|
+
try {
|
|
19
|
+
const stat = fs.lstatSync(file);
|
|
20
|
+
if (!stat.isFile() || now - stat.mtimeMs < STALE_MS) continue;
|
|
21
|
+
const prefix = `refs/piagent/checkpoints/${key}/`;
|
|
22
|
+
const lines = refs.run(['for-each-ref', '--format=%(refname) %(objectname)', prefix]).trim().split('\n').filter(Boolean)
|
|
23
|
+
.map(line => { const [ref, object] = line.split(' '); return `delete ${ref} ${object}`; });
|
|
24
|
+
if (lines.length) refs.run(['update-ref', '--stdin'], Buffer.from(['start', ...lines, 'prepare', 'commit', ''].join('\n')));
|
|
25
|
+
fs.unlinkSync(file); pruned++;
|
|
26
|
+
} catch { /* another process is using it; try again next time */ }
|
|
27
|
+
}
|
|
28
|
+
return pruned;
|
|
29
|
+
}
|