@piagent/platform 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -3
- package/README.md +3 -3
- package/architecture/layers.json +96 -1
- package/docs/README.md +1 -0
- package/docs/architecture.md +1 -0
- package/docs/authority-manifest.md +1 -0
- package/docs/auto-delegation-policy.md +1 -0
- package/docs/capability-packs.md +10 -2
- package/docs/command-reference-vietnamese.md +5 -4
- package/docs/context-engine.md +1 -0
- package/docs/context-window-policy.md +1 -0
- package/docs/distribution-standard.md +8 -7
- package/docs/herdr-workflow.md +1 -0
- package/docs/mcp-and-tools.md +14 -9
- package/docs/memory-policy.md +1 -0
- package/docs/model-options.md +1 -0
- package/docs/oauth-providers.md +1 -0
- package/docs/operator-manual-vietnamese.md +15 -12
- package/docs/package-architecture-notes.md +1 -0
- package/docs/project-adapters.md +1 -0
- package/docs/project-onboarding.md +1 -0
- package/docs/publishing-for-teams.md +1 -0
- package/docs/quality-benchmark.md +1 -0
- package/docs/quickstart-vietnamese.md +3 -2
- package/docs/release-install-policy.md +11 -7
- package/docs/runtime-harness-standard.md +1 -0
- package/docs/runtime-policy-design.md +1 -0
- package/docs/runtime-quality-baseline.md +1 -0
- package/docs/security-redaction-benchmark.md +1 -0
- package/docs/security-threat-model.md +2 -1
- package/docs/subagent-orchestration-capabilities.md +1 -0
- package/docs/subagents-and-multiagent.md +1 -0
- package/docs/task-implementation-contract.md +1 -0
- package/docs/task-lifecycle-vietnamese.md +1 -0
- package/docs/team-onboarding.md +3 -2
- package/docs/third-party-neutrality.md +26 -0
- package/docs/usage-observability.md +1 -0
- package/docs/vercel-docs-site.md +1 -0
- package/docs/workflow-recipes.md +1 -0
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/index.html +1 -1
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +1 -1
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/fixtures/mutation-provenance-record.invalid.json +17 -0
- package/evals/fixtures/mutation-provenance-record.valid.json +27 -0
- package/evals/fixtures/piagent-webui/approval-v1.invalid.json +54 -0
- package/evals/fixtures/piagent-webui/approval-v1.valid.json +58 -0
- package/evals/fixtures/piagent-webui/attachment-v1.invalid.json +25 -0
- package/evals/fixtures/piagent-webui/attachment-v1.valid.json +25 -0
- package/evals/fixtures/piagent-webui/capabilities-v1.invalid.json +51 -0
- package/evals/fixtures/piagent-webui/capabilities-v1.valid.json +122 -0
- package/evals/fixtures/piagent-webui/commit-summary-v1.invalid.json +10 -0
- package/evals/fixtures/piagent-webui/commit-summary-v1.valid.json +25 -0
- package/evals/fixtures/piagent-webui/control-command-v1.invalid.json +37 -0
- package/evals/fixtures/piagent-webui/control-command-v1.valid.json +35 -0
- package/evals/fixtures/piagent-webui/diff-v1.invalid.json +13 -0
- package/evals/fixtures/piagent-webui/diff-v1.valid.json +141 -0
- package/evals/fixtures/piagent-webui/gateway-capabilities-v1.invalid.json +35 -0
- package/evals/fixtures/piagent-webui/gateway-capabilities-v1.valid.json +35 -0
- package/evals/fixtures/piagent-webui/gateway-protocol-v1.invalid.json +14 -0
- package/evals/fixtures/piagent-webui/gateway-protocol-v1.valid.json +14 -0
- package/evals/fixtures/piagent-webui/handoff-history-v1.invalid.json +16 -0
- package/evals/fixtures/piagent-webui/handoff-history-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/model-catalog-v1.invalid.json +31 -0
- package/evals/fixtures/piagent-webui/model-catalog-v1.valid.json +43 -0
- package/evals/fixtures/piagent-webui/queue-v1.invalid.json +29 -0
- package/evals/fixtures/piagent-webui/queue-v1.valid.json +44 -0
- package/evals/fixtures/piagent-webui/recovery-history-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/recovery-history-v1.valid.json +20 -0
- package/evals/fixtures/piagent-webui/release-monitor-v1.invalid.json +12 -0
- package/evals/fixtures/piagent-webui/release-monitor-v1.valid.json +29 -0
- package/evals/fixtures/piagent-webui/review-state-v1.invalid.json +25 -0
- package/evals/fixtures/piagent-webui/review-state-v1.valid.json +38 -0
- package/evals/fixtures/piagent-webui/runtime-event-v2.invalid.json +49 -0
- package/evals/fixtures/piagent-webui/runtime-event-v2.valid.json +49 -0
- package/evals/fixtures/piagent-webui/session-catalog-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/session-catalog-v1.valid.json +58 -0
- package/evals/fixtures/piagent-webui/session-command-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/session-command-v1.valid.json +19 -0
- package/evals/fixtures/piagent-webui/snapshot-v1.invalid.json +5 -0
- package/evals/fixtures/piagent-webui/snapshot-v1.valid.json +191 -0
- package/evals/fixtures/piagent-webui/source-change-v1.invalid.json +57 -0
- package/evals/fixtures/piagent-webui/source-change-v1.valid.json +98 -0
- package/evals/fixtures/piagent-webui/source-mutation-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/source-mutation-v1.valid.json +38 -0
- package/evals/fixtures/piagent-webui/source-revert-v1.invalid.json +20 -0
- package/evals/fixtures/piagent-webui/source-revert-v1.valid.json +62 -0
- package/evals/fixtures/piagent-webui/subagent-tree-v1.invalid.json +26 -0
- package/evals/fixtures/piagent-webui/subagent-tree-v1.valid.json +39 -0
- package/evals/fixtures/piagent-webui/task-index-v1.invalid.json +15 -0
- package/evals/fixtures/piagent-webui/task-index-v1.valid.json +15 -0
- package/evals/fixtures/piagent-webui/task-timeline-v1.invalid.json +14 -0
- package/evals/fixtures/piagent-webui/task-timeline-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/transcript-v1.invalid.json +35 -0
- package/evals/fixtures/piagent-webui/transcript-v1.valid.json +55 -0
- package/evals/fixtures/task-baseline-manifest.invalid.json +16 -0
- package/evals/fixtures/task-baseline-manifest.valid.json +41 -0
- package/evals/fixtures/verifier-file-snapshot.invalid.json +21 -0
- package/evals/fixtures/verifier-file-snapshot.valid.json +21 -0
- package/package.json +30 -3
- package/packages/piagent-core/benchmark/benchmark-candidate.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-forensics.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-ledger.d.ts +4 -0
- package/packages/piagent-core/benchmark/benchmark-record-validation.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-runtime.d.ts +1 -0
- package/packages/piagent-core/capabilities/capability-core.js +2 -2
- package/packages/piagent-core/capabilities/capability-verification-cache.js +280 -0
- package/packages/piagent-core/capabilities/runtime-integrity.js +153 -1
- package/packages/piagent-core/extensions/document-intake.ts +24 -10
- package/packages/piagent-core/extensions/piagent-guard.ts +90 -73
- package/packages/piagent-core/extensions/redaction-core.d.ts +2 -0
- package/packages/piagent-core/extensions/state-retention.js +77 -44
- package/packages/piagent-core/extensions/task-journal.js +30 -1
- package/packages/piagent-core/extensions/task-state.js +33 -36
- package/packages/piagent-core/mcp/mcp-server-catalog.js +5 -5
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -2
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +5 -2
- package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +20 -19
- package/packages/piagent-core/runtime/hooks/tool-result-metadata.ts +13 -0
- package/packages/piagent-core/runtime/input/chat-images.ts +2 -2
- package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +94 -0
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +318 -0
- package/packages/piagent-core/runtime/inspection/commit-summary-projection.ts +59 -0
- package/packages/piagent-core/runtime/inspection/context-telemetry-inspection.ts +37 -0
- package/packages/piagent-core/runtime/inspection/criteria-links.ts +295 -0
- package/packages/piagent-core/runtime/inspection/diff-projection.ts +499 -0
- package/packages/piagent-core/runtime/inspection/git-filter-safety.ts +38 -0
- package/packages/piagent-core/runtime/inspection/git-numstat-adapter.ts +71 -0
- package/packages/piagent-core/runtime/inspection/git-status-adapter.ts +493 -0
- package/packages/piagent-core/runtime/inspection/git-tree-adapter.ts +108 -0
- package/packages/piagent-core/runtime/inspection/mcp-control.ts +10 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-contract.ts +131 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-recorder.ts +48 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-store.ts +161 -0
- package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +36 -0
- package/packages/piagent-core/runtime/inspection/review-state-contract.ts +108 -0
- package/packages/piagent-core/runtime/inspection/review-state-projection.ts +93 -0
- package/packages/piagent-core/runtime/inspection/review-state-store.ts +112 -0
- package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +262 -0
- package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +103 -0
- package/packages/piagent-core/runtime/inspection/source-change-projection.ts +471 -0
- package/packages/piagent-core/runtime/inspection/source-evidence-contract.ts +139 -0
- package/packages/piagent-core/runtime/inspection/source-evidence-store.ts +315 -0
- package/packages/piagent-core/runtime/inspection/source-handoff-store.ts +70 -0
- package/packages/piagent-core/runtime/inspection/source-inspection-plan.ts +34 -0
- package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +164 -0
- package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +168 -0
- package/packages/piagent-core/runtime/inspection/source-open-target.ts +38 -0
- package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +147 -0
- package/packages/piagent-core/runtime/inspection/task-baseline-start-capture.ts +22 -0
- package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +160 -0
- package/packages/piagent-core/runtime/inspection/task-control-journal.ts +131 -0
- package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +95 -0
- package/packages/piagent-core/runtime/inspection/task-provenance-projection.ts +60 -0
- package/packages/piagent-core/runtime/inspection/task-recovery-timeline.ts +88 -0
- package/packages/piagent-core/runtime/inspection/task-run-index.ts +87 -0
- package/packages/piagent-core/runtime/inspection/task-source-projection.ts +401 -0
- package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +101 -0
- package/packages/piagent-core/runtime/inspection/text-diff.ts +60 -0
- package/packages/piagent-core/runtime/inspection/verifier-snapshot-contract.ts +114 -0
- package/packages/piagent-core/runtime/inspection/verifier-snapshot-store.ts +189 -0
- package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +320 -0
- package/packages/piagent-core/runtime/inspection/workspace-file-reader.ts +132 -0
- package/packages/piagent-core/runtime/inspection/workspace-source-projection.ts +96 -0
- package/packages/piagent-core/runtime/inspection/zero-turn-conformance.ts +175 -0
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +68 -4
- package/packages/piagent-core/runtime/policy/source-index-transaction.ts +173 -0
- package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +42 -0
- package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +184 -0
- package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +105 -0
- package/packages/piagent-core/runtime/product/activity-inspector-footer.ts +5 -3
- package/packages/piagent-core/runtime/product/activity-inspector.ts +85 -343
- package/packages/piagent-core/runtime/recovery/resume-state.ts +38 -1
- package/packages/piagent-core/runtime/registration/activity-inspector-command.ts +68 -14
- package/packages/piagent-core/runtime/registration/task-start-tool.ts +13 -14
- package/packages/piagent-core/runtime/session/model-authorship-state.ts +31 -3
- package/packages/piagent-core/runtime/session/runtime-state.ts +11 -1
- package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +172 -0
- package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +1 -0
- package/packages/piagent-webui/dist/client/index.html +16 -0
- package/packages/piagent-webui/extension/attachment-store.ts +323 -0
- package/packages/piagent-webui/extension/held-message-queue.ts +311 -0
- package/packages/piagent-webui/extension/lifecycle-controller.ts +408 -0
- package/packages/piagent-webui/extension/lifecycle-event-adapter.ts +23 -0
- package/packages/piagent-webui/extension/piagent-webui.ts +470 -0
- package/packages/piagent-webui/extension/review-controller.ts +160 -0
- package/packages/piagent-webui/extension/same-session-bridge.ts +500 -0
- package/packages/piagent-webui/extension/session-options-controller.ts +281 -0
- package/packages/piagent-webui/extension/session-stream-adapter.ts +249 -0
- package/packages/piagent-webui/extension/source-mutation-controller.ts +215 -0
- package/packages/piagent-webui/extension/source-open-controller.ts +136 -0
- package/packages/piagent-webui/extension/source-revert-controller.ts +189 -0
- package/packages/piagent-webui/extension/terminal-session-adapter.ts +73 -0
- package/packages/piagent-webui/extension/vscode-handoff.ts +51 -0
- package/packages/piagent-webui/gateway/control-socket.ts +92 -0
- package/packages/piagent-webui/gateway/gateway-events.ts +60 -0
- package/packages/piagent-webui/gateway/gateway-protocol-service.ts +64 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +215 -0
- package/packages/piagent-webui/gateway/gateway-session-stream.ts +110 -0
- package/packages/piagent-webui/gateway/mcp-auth-broker.ts +158 -0
- package/packages/piagent-webui/gateway/native-project-picker.ts +29 -0
- package/packages/piagent-webui/gateway/pi-host.ts +41 -0
- package/packages/piagent-webui/gateway/profile-state.ts +1 -0
- package/packages/piagent-webui/gateway/project-registry.ts +108 -0
- package/packages/piagent-webui/gateway/provider-auth-broker.ts +172 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +128 -0
- package/packages/piagent-webui/gateway/session-command-controller.ts +237 -0
- package/packages/piagent-webui/gateway/session-command-store.ts +224 -0
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +204 -0
- package/packages/piagent-webui/gateway/session-lease-store.ts +1 -0
- package/packages/piagent-webui/gateway/session-metadata-store.ts +152 -0
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +485 -0
- package/packages/piagent-webui/ownership/gateway-runtime-context.ts +8 -0
- package/packages/piagent-webui/ownership/profile-state.ts +117 -0
- package/packages/piagent-webui/ownership/session-lease-store.ts +329 -0
- package/packages/piagent-webui/ownership/session-refs.ts +8 -0
- package/packages/piagent-webui/package.json +33 -0
- package/packages/piagent-webui/server/README.md +4 -0
- package/packages/piagent-webui/server/benchmark-release-monitor.ts +230 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +403 -0
- package/packages/piagent-webui/server/gateway-websocket.ts +143 -0
- package/packages/piagent-webui/server/http-security.ts +27 -0
- package/packages/piagent-webui/server/ipc-read-model-client.ts +89 -0
- package/packages/piagent-webui/server/loopback-server.ts +368 -0
- package/packages/piagent-webui/server/read-model-provider.ts +37 -0
- package/packages/piagent-webui/server/read-only-router.ts +105 -0
- package/packages/piagent-webui/server/session-auth.ts +88 -0
- package/packages/piagent-webui/server/sidecar-main.ts +86 -0
- package/packages/piagent-webui/server/sse-hub.ts +76 -0
- package/packages/piagent-webui/server/static-bundle.ts +40 -0
- package/packages/piagent-webui/server/transcript-projection.ts +110 -0
- package/schemas/mutation-provenance-record.schema.json +100 -0
- package/schemas/piagent-webui/approval-v1.schema.json +654 -0
- package/schemas/piagent-webui/attachment-v1.schema.json +267 -0
- package/schemas/piagent-webui/capabilities-v1.schema.json +729 -0
- package/schemas/piagent-webui/catalog-v1.json +240 -0
- package/schemas/piagent-webui/commit-summary-v1.schema.json +63 -0
- package/schemas/piagent-webui/common-v1.schema.json +479 -0
- package/schemas/piagent-webui/control-command-v1.schema.json +1780 -0
- package/schemas/piagent-webui/diff-v1.schema.json +826 -0
- package/schemas/piagent-webui/gateway-capabilities-v1.schema.json +136 -0
- package/schemas/piagent-webui/gateway-protocol-v1.schema.json +456 -0
- package/schemas/piagent-webui/handoff-history-v1.schema.json +92 -0
- package/schemas/piagent-webui/model-catalog-v1.schema.json +95 -0
- package/schemas/piagent-webui/queue-v1.schema.json +99 -0
- package/schemas/piagent-webui/recovery-history-v1.schema.json +144 -0
- package/schemas/piagent-webui/release-monitor-v1.schema.json +173 -0
- package/schemas/piagent-webui/review-state-v1.schema.json +206 -0
- package/schemas/piagent-webui/runtime-event-v2.schema.json +5206 -0
- package/schemas/piagent-webui/session-catalog-v1.schema.json +222 -0
- package/schemas/piagent-webui/session-command-v1.schema.json +390 -0
- package/schemas/piagent-webui/snapshot-v1.schema.json +2622 -0
- package/schemas/piagent-webui/source-change-v1.schema.json +763 -0
- package/schemas/piagent-webui/source-mutation-v1.schema.json +96 -0
- package/schemas/piagent-webui/source-revert-v1.schema.json +130 -0
- package/schemas/piagent-webui/subagent-tree-v1.schema.json +88 -0
- package/schemas/piagent-webui/task-index-v1.schema.json +74 -0
- package/schemas/piagent-webui/task-timeline-v1.schema.json +49 -0
- package/schemas/piagent-webui/transcript-v1.schema.json +243 -0
- package/schemas/task-baseline-manifest.schema.json +122 -0
- package/schemas/verifier-file-snapshot.schema.json +81 -0
- package/scripts/check-architecture.mjs +1 -0
- package/scripts/check-doc-languages.mjs +19 -2
- package/scripts/check-third-party-neutrality.mjs +43 -0
- package/scripts/install-global.sh +1 -1
- package/scripts/piagent-cli.mjs +17 -3
- package/scripts/piagent-dashboard.mjs +208 -0
- package/scripts/piagent-webui-launcher.mjs +121 -0
- package/scripts/typescript-loader.mjs +26 -1
- package/scripts/verify-local.sh +253 -0
- package/scripts/verify-release-identity.mjs +4 -1
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { collectGitStatus, runReadOnlyGit, type GitStatusRecord, type GitStatusSnapshot } from "./git-status-adapter.ts";
|
|
4
|
+
import {
|
|
5
|
+
collectSourceChangeViews,
|
|
6
|
+
collectSelectedSourceFileState,
|
|
7
|
+
sourceFileRef,
|
|
8
|
+
type SourceChangeDocument,
|
|
9
|
+
type SourceProjectionOptions,
|
|
10
|
+
type WebUiIdentity
|
|
11
|
+
} from "./source-change-projection.ts";
|
|
12
|
+
import { WorkspaceReadError, inspectWorkspaceEntry, readWorkspaceFile } from "./workspace-file-reader.ts";
|
|
13
|
+
import { readTaskFileContents } from "./task-source-projection.ts";
|
|
14
|
+
import { diffTextBuffers } from "./text-diff.ts";
|
|
15
|
+
const DEFAULT_CONTEXT = 3;
|
|
16
|
+
const DEFAULT_MAX_HUNKS = 128;
|
|
17
|
+
const DEFAULT_MAX_LINES = 5_000;
|
|
18
|
+
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
const MAX_LINE_LENGTH = 16_384;
|
|
20
|
+
export type DiffPrecondition = {
|
|
21
|
+
expectedViewRevision: string;
|
|
22
|
+
expectedFileRevision: string;
|
|
23
|
+
expectedBaseDigest: string | null;
|
|
24
|
+
expectedCurrentDigest: string | null;
|
|
25
|
+
};
|
|
26
|
+
export type FileDiffOptions = {
|
|
27
|
+
cwd: string;
|
|
28
|
+
identity: WebUiIdentity;
|
|
29
|
+
sourceView: SourceChangeDocument;
|
|
30
|
+
fileRef: string;
|
|
31
|
+
precondition: DiffPrecondition;
|
|
32
|
+
generatedAt?: string;
|
|
33
|
+
contextLines?: number;
|
|
34
|
+
maxHunks?: number;
|
|
35
|
+
maxLines?: number;
|
|
36
|
+
maxBytes?: number;
|
|
37
|
+
maxSourceBytes?: number;
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
taskRevision?: string | null;
|
|
40
|
+
isProtectedPath?: SourceProjectionOptions["isProtectedPath"];
|
|
41
|
+
redactLine?: (text: string) => { text: string; redacted: boolean };
|
|
42
|
+
revalidationMode?: "full-view" | "selected-file";
|
|
43
|
+
selectedRepoPaths?: string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type DiffDocument = Record<string, unknown> & {
|
|
47
|
+
availability: { state: "current" | "stale" | "unavailable"; reasonCode: string | null; message: string | null; retryable: boolean };
|
|
48
|
+
fallback: { kind: string; reasonCode: string | null; message: string | null };
|
|
49
|
+
hunks: Array<Record<string, unknown>>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function hash(value: string | Buffer): string {
|
|
53
|
+
return createHash("sha256").update(value).digest("hex");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function token(prefix: string, value: string | Buffer): string {
|
|
57
|
+
return `${prefix}.${hash(value)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function integer(value: number | undefined, fallback: number, minimum: number, maximum: number): number {
|
|
61
|
+
return Number.isInteger(value) ? Math.max(minimum, Math.min(maximum, value as number)) : fallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sourceForView(views: Awaited<ReturnType<typeof collectSourceChangeViews>>, view: SourceChangeDocument["view"]): SourceChangeDocument | null {
|
|
65
|
+
if (view === "working-tree") return views.workingTree;
|
|
66
|
+
if (view === "staged") return views.staged;
|
|
67
|
+
return views.task;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findFile(source: SourceChangeDocument | null, fileRef: string): Record<string, any> | null {
|
|
71
|
+
return source?.files.find((file) => file.fileRef === fileRef) as Record<string, any> | undefined ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function observed(source: SourceChangeDocument, file: Record<string, any> | null, fallback: Record<string, any>) {
|
|
75
|
+
return {
|
|
76
|
+
viewRevision: source.viewRevision,
|
|
77
|
+
fileRevision: file?.fileRevision ?? token("file-missing", `${source.viewRevision}:${fallback.fileRef}`),
|
|
78
|
+
baseDigest: file?.baseDigest ?? null,
|
|
79
|
+
currentDigest: file?.currentDigest ?? null
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function samePrecondition(precondition: DiffPrecondition, current: ReturnType<typeof observed>): boolean {
|
|
84
|
+
return precondition.expectedViewRevision === current.viewRevision
|
|
85
|
+
&& precondition.expectedFileRevision === current.fileRevision
|
|
86
|
+
&& precondition.expectedBaseDigest === current.baseDigest
|
|
87
|
+
&& precondition.expectedCurrentDigest === current.currentDigest;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function baseDocument(
|
|
91
|
+
options: FileDiffOptions,
|
|
92
|
+
source: SourceChangeDocument,
|
|
93
|
+
file: Record<string, any>,
|
|
94
|
+
currentObserved: ReturnType<typeof observed>
|
|
95
|
+
): DiffDocument {
|
|
96
|
+
return {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
version: "piagent-webui-diff-v1",
|
|
99
|
+
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
|
100
|
+
identity: options.identity,
|
|
101
|
+
view: source.view,
|
|
102
|
+
basis: (source.bases as Array<Record<string, unknown>>)[0],
|
|
103
|
+
precondition: options.precondition,
|
|
104
|
+
observed: currentObserved,
|
|
105
|
+
file,
|
|
106
|
+
availability: { state: "current", reasonCode: null, message: null, retryable: false },
|
|
107
|
+
fallback: { kind: "none", reasonCode: null, message: null },
|
|
108
|
+
hunks: [],
|
|
109
|
+
unchangedRegions: [],
|
|
110
|
+
truncation: { truncated: false, reasonCode: null, omittedHunks: 0, omittedLines: 0, nextCursor: null },
|
|
111
|
+
redaction: { applied: false, valuesRemoved: 0, truncated: false },
|
|
112
|
+
health: { state: "ok", reasonCode: null, message: null },
|
|
113
|
+
issues: []
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function fallbackDocument(
|
|
118
|
+
options: FileDiffOptions,
|
|
119
|
+
source: SourceChangeDocument,
|
|
120
|
+
file: Record<string, any>,
|
|
121
|
+
currentObserved: ReturnType<typeof observed>,
|
|
122
|
+
state: "current" | "stale" | "unavailable",
|
|
123
|
+
kind: "binary" | "symlink" | "submodule" | "protected" | "oversized" | "conflict" | "unavailable" | "stale",
|
|
124
|
+
reasonCode: string,
|
|
125
|
+
message: string,
|
|
126
|
+
retryable = false
|
|
127
|
+
): DiffDocument {
|
|
128
|
+
const document = baseDocument(options, source, file, currentObserved);
|
|
129
|
+
document.availability = { state, reasonCode: state === "current" ? null : reasonCode, message, retryable };
|
|
130
|
+
document.fallback = { kind, reasonCode, message };
|
|
131
|
+
document.health = state === "unavailable"
|
|
132
|
+
? { state: "unavailable", reasonCode, message }
|
|
133
|
+
: { state: "degraded", reasonCode, message };
|
|
134
|
+
return document;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sanitizeLine(value: string): string {
|
|
138
|
+
return value.replace(/[\u0000-\u0008\u000a-\u001f\u007f]/g, "�");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type ParsedPatch = {
|
|
142
|
+
hunks: Array<Record<string, any>>;
|
|
143
|
+
unchangedRegions: Array<Record<string, any>>;
|
|
144
|
+
omittedHunks: number;
|
|
145
|
+
omittedLines: number;
|
|
146
|
+
redactedLines: number;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function parsePatch(
|
|
150
|
+
raw: Buffer,
|
|
151
|
+
fileRef: string,
|
|
152
|
+
maxHunks: number,
|
|
153
|
+
maxLines: number,
|
|
154
|
+
redactLine?: FileDiffOptions["redactLine"]
|
|
155
|
+
): ParsedPatch {
|
|
156
|
+
const input = raw.toString("utf8").split("\n");
|
|
157
|
+
const hunks: Array<Record<string, any>> = [];
|
|
158
|
+
let current: Record<string, any> | null = null;
|
|
159
|
+
let oldLine = 0;
|
|
160
|
+
let newLine = 0;
|
|
161
|
+
let totalLines = 0;
|
|
162
|
+
let omittedHunks = 0;
|
|
163
|
+
let omittedLines = 0;
|
|
164
|
+
let redactedLines = 0;
|
|
165
|
+
|
|
166
|
+
for (const rawLine of input) {
|
|
167
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
168
|
+
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line);
|
|
169
|
+
if (match) {
|
|
170
|
+
if (hunks.length >= maxHunks) {
|
|
171
|
+
omittedHunks += 1;
|
|
172
|
+
current = null;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
oldLine = Number(match[1]);
|
|
176
|
+
newLine = Number(match[3]);
|
|
177
|
+
current = {
|
|
178
|
+
hunkRef: token("hunk", `${fileRef}:${line}`),
|
|
179
|
+
oldStart: oldLine,
|
|
180
|
+
oldLineCount: Number(match[2] ?? 1),
|
|
181
|
+
newStart: newLine,
|
|
182
|
+
newLineCount: Number(match[4] ?? 1),
|
|
183
|
+
header: sanitizeLine(line).slice(0, 4_000),
|
|
184
|
+
lines: []
|
|
185
|
+
};
|
|
186
|
+
hunks.push(current);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!current || !(line.startsWith(" ") || line.startsWith("+") || line.startsWith("-") || line.startsWith("\\"))) continue;
|
|
190
|
+
if (totalLines >= maxLines || current.lines.length >= 512) {
|
|
191
|
+
omittedLines += 1;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const marker = line[0];
|
|
195
|
+
const sourceText = sanitizeLine(line.slice(marker === "\\" ? 2 : 1));
|
|
196
|
+
const redaction = redactLine?.(sourceText) ?? { text: sourceText, redacted: false };
|
|
197
|
+
let text = sanitizeLine(redaction.text);
|
|
198
|
+
if (text.length > MAX_LINE_LENGTH) {
|
|
199
|
+
text = text.slice(0, MAX_LINE_LENGTH);
|
|
200
|
+
omittedLines += 1;
|
|
201
|
+
}
|
|
202
|
+
if (redaction.redacted) redactedLines += 1;
|
|
203
|
+
const lineRef = token("line", `${current.hunkRef}:${current.lines.length}:${marker}:${oldLine}:${newLine}`);
|
|
204
|
+
if (marker === " ") {
|
|
205
|
+
current.lines.push({ lineRef, kind: "context", marker: " ", oldLineNumber: oldLine++, newLineNumber: newLine++, text, redacted: redaction.redacted });
|
|
206
|
+
} else if (marker === "+") {
|
|
207
|
+
current.lines.push({ lineRef, kind: "added", marker: "+", oldLineNumber: null, newLineNumber: newLine++, text, redacted: redaction.redacted });
|
|
208
|
+
} else if (marker === "-") {
|
|
209
|
+
current.lines.push({ lineRef, kind: "deleted", marker: "-", oldLineNumber: oldLine++, newLineNumber: null, text, redacted: redaction.redacted });
|
|
210
|
+
} else {
|
|
211
|
+
current.lines.push({ lineRef, kind: "no-newline-marker", marker: "\\", oldLineNumber: null, newLineNumber: null, text, redacted: false });
|
|
212
|
+
}
|
|
213
|
+
totalLines += 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const unchangedRegions: Array<Record<string, any>> = [];
|
|
217
|
+
for (let index = 0; index < hunks.length; index += 1) {
|
|
218
|
+
const after = hunks[index];
|
|
219
|
+
const before = hunks[index - 1] ?? null;
|
|
220
|
+
const oldStart = before ? before.oldStart + before.oldLineCount : 1;
|
|
221
|
+
const newStart = before ? before.newStart + before.newLineCount : 1;
|
|
222
|
+
const count = Math.min(after.oldStart - oldStart, after.newStart - newStart);
|
|
223
|
+
if (count <= 0) continue;
|
|
224
|
+
unchangedRegions.push({
|
|
225
|
+
regionRef: token("region", `${fileRef}:${oldStart}:${newStart}:${count}`),
|
|
226
|
+
oldStart,
|
|
227
|
+
newStart,
|
|
228
|
+
lineCount: count,
|
|
229
|
+
beforeHunkRef: before?.hunkRef ?? null,
|
|
230
|
+
afterHunkRef: after.hunkRef,
|
|
231
|
+
contentState: "unavailable",
|
|
232
|
+
expansionRef: null,
|
|
233
|
+
reasonCode: "lazy-expansion-unavailable"
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return { hunks, unchangedRegions, omittedHunks, omittedLines, redactedLines };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseTaskPatch(
|
|
240
|
+
base: Buffer | null,
|
|
241
|
+
current: Buffer | null,
|
|
242
|
+
fileRef: string,
|
|
243
|
+
contextLines: number,
|
|
244
|
+
maxHunks: number,
|
|
245
|
+
maxLines: number,
|
|
246
|
+
redactLine?: FileDiffOptions["redactLine"]
|
|
247
|
+
): ParsedPatch | null {
|
|
248
|
+
const result = diffTextBuffers(base ?? Buffer.alloc(0), current ?? Buffer.alloc(0));
|
|
249
|
+
if (!result.exact) return null;
|
|
250
|
+
const changed = result.operations.flatMap((operation, index) => operation.kind === "context" ? [] : [index]);
|
|
251
|
+
const ranges: Array<{ start: number; end: number }> = [];
|
|
252
|
+
for (const index of changed) {
|
|
253
|
+
const start = Math.max(0, index - contextLines);
|
|
254
|
+
const end = Math.min(result.operations.length, index + contextLines + 1);
|
|
255
|
+
const previous = ranges.at(-1);
|
|
256
|
+
if (previous && start <= previous.end) previous.end = Math.max(previous.end, end);
|
|
257
|
+
else ranges.push({ start, end });
|
|
258
|
+
}
|
|
259
|
+
const hunks: Array<Record<string, any>> = [];
|
|
260
|
+
let omittedHunks = 0;
|
|
261
|
+
let omittedLines = 0;
|
|
262
|
+
let emittedLines = 0;
|
|
263
|
+
let redactedLines = 0;
|
|
264
|
+
for (const range of ranges.flatMap(({ start, end }) => {
|
|
265
|
+
const chunks = [];
|
|
266
|
+
for (let offset = start; offset < end; offset += 512) chunks.push({ start: offset, end: Math.min(end, offset + 512) });
|
|
267
|
+
return chunks;
|
|
268
|
+
})) {
|
|
269
|
+
if (hunks.length >= maxHunks) { omittedHunks += 1; continue; }
|
|
270
|
+
let oldStart = 1;
|
|
271
|
+
let newStart = 1;
|
|
272
|
+
for (const operation of result.operations.slice(0, range.start)) {
|
|
273
|
+
if (operation.kind !== "added") oldStart += 1;
|
|
274
|
+
if (operation.kind !== "deleted") newStart += 1;
|
|
275
|
+
}
|
|
276
|
+
const selected = result.operations.slice(range.start, range.end);
|
|
277
|
+
const oldCount = selected.filter((operation) => operation.kind !== "added").length;
|
|
278
|
+
const newCount = selected.filter((operation) => operation.kind !== "deleted").length;
|
|
279
|
+
const hunkRef = token("hunk", `${fileRef}:${range.start}:${range.end}`);
|
|
280
|
+
const lines: Array<Record<string, any>> = [];
|
|
281
|
+
let oldLine = oldStart;
|
|
282
|
+
let newLine = newStart;
|
|
283
|
+
for (const operation of selected) {
|
|
284
|
+
if (emittedLines >= maxLines) { omittedLines += 1; continue; }
|
|
285
|
+
const redaction = redactLine?.(operation.text) ?? { text: operation.text, redacted: false };
|
|
286
|
+
const text = sanitizeLine(redaction.text).slice(0, MAX_LINE_LENGTH);
|
|
287
|
+
if (redaction.redacted) redactedLines += 1;
|
|
288
|
+
const lineRef = token("line", `${hunkRef}:${lines.length}:${oldLine}:${newLine}`);
|
|
289
|
+
if (operation.kind === "context") lines.push({ lineRef, kind: "context", marker: " ", oldLineNumber: oldLine++, newLineNumber: newLine++, text, redacted: redaction.redacted });
|
|
290
|
+
else if (operation.kind === "added") lines.push({ lineRef, kind: "added", marker: "+", oldLineNumber: null, newLineNumber: newLine++, text, redacted: redaction.redacted });
|
|
291
|
+
else lines.push({ lineRef, kind: "deleted", marker: "-", oldLineNumber: oldLine++, newLineNumber: null, text, redacted: redaction.redacted });
|
|
292
|
+
emittedLines += 1;
|
|
293
|
+
}
|
|
294
|
+
hunks.push({ hunkRef, oldStart, oldLineCount: oldCount, newStart, newLineCount: newCount, header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, lines });
|
|
295
|
+
}
|
|
296
|
+
const unchangedRegions: Array<Record<string, any>> = [];
|
|
297
|
+
for (let index = 1; index < hunks.length; index += 1) {
|
|
298
|
+
const before = hunks[index - 1];
|
|
299
|
+
const after = hunks[index];
|
|
300
|
+
const oldStart = before.oldStart + before.oldLineCount;
|
|
301
|
+
const newStart = before.newStart + before.newLineCount;
|
|
302
|
+
const count = Math.min(after.oldStart - oldStart, after.newStart - newStart);
|
|
303
|
+
if (count > 0) unchangedRegions.push({ regionRef: token("region", `${fileRef}:${oldStart}:${newStart}:${count}`), oldStart, newStart, lineCount: count,
|
|
304
|
+
beforeHunkRef: before.hunkRef, afterHunkRef: after.hunkRef, contentState: "unavailable", expansionRef: null, reasonCode: "lazy-expansion-unavailable" });
|
|
305
|
+
}
|
|
306
|
+
return { hunks, unchangedRegions, omittedHunks, omittedLines, redactedLines };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function allAddedPatch(content: Buffer): Buffer {
|
|
310
|
+
if (content.length === 0) return Buffer.alloc(0);
|
|
311
|
+
const text = content.toString("utf8");
|
|
312
|
+
const lines = text.split("\n");
|
|
313
|
+
const hasTrailingNewline = text.endsWith("\n");
|
|
314
|
+
if (hasTrailingNewline) lines.pop();
|
|
315
|
+
const body = lines.map((line) => `+${line}`).join("\n");
|
|
316
|
+
const marker = !hasTrailingNewline && text.length > 0 ? "\n\" : "";
|
|
317
|
+
return Buffer.from(`@@ -0,0 +1,${lines.length} @@\n${body}${marker}\n`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function emptyTree(snapshot: GitStatusSnapshot, timeoutMs: number, maxBytes: number): Promise<string> {
|
|
321
|
+
if (snapshot.headOid) return snapshot.headOid;
|
|
322
|
+
const output = await runReadOnlyGit(snapshot.repoRoot, ["hash-object", "-t", "tree", os.devNull], { timeoutMs, maxBytes });
|
|
323
|
+
return output.toString("ascii").trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function patchForRecord(
|
|
327
|
+
snapshot: GitStatusSnapshot,
|
|
328
|
+
record: GitStatusRecord,
|
|
329
|
+
view: "working-tree" | "staged",
|
|
330
|
+
contextLines: number,
|
|
331
|
+
timeoutMs: number,
|
|
332
|
+
maxBytes: number
|
|
333
|
+
): Promise<Buffer> {
|
|
334
|
+
const repoPath = record.path.value;
|
|
335
|
+
if (!repoPath) throw new Error("Selected Git path is not addressable");
|
|
336
|
+
if (record.kind === "untracked") {
|
|
337
|
+
const stat = inspectWorkspaceEntry(snapshot.repoRoot, repoPath);
|
|
338
|
+
if (!stat.isFile() || stat.size > maxBytes) throw Object.assign(new Error("Diff exceeds the byte cap"), { code: "output-limit" });
|
|
339
|
+
return allAddedPatch(readWorkspaceFile(snapshot.repoRoot, repoPath, maxBytes));
|
|
340
|
+
}
|
|
341
|
+
const base = await emptyTree(snapshot, timeoutMs, maxBytes);
|
|
342
|
+
const paths = [...new Set([record.oldPath?.value, repoPath].filter((value): value is string => Boolean(value)))];
|
|
343
|
+
return await runReadOnlyGit(snapshot.repoRoot, [
|
|
344
|
+
"diff",
|
|
345
|
+
...(view === "staged" ? ["--cached"] : []),
|
|
346
|
+
"--no-ext-diff",
|
|
347
|
+
"--no-textconv",
|
|
348
|
+
"--find-renames",
|
|
349
|
+
`--unified=${contextLines}`,
|
|
350
|
+
base,
|
|
351
|
+
"--",
|
|
352
|
+
...paths
|
|
353
|
+
], { timeoutMs, maxBytes });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function patchForUnstagedRecord(snapshot: GitStatusSnapshot, record: GitStatusRecord, contextLines: number,
|
|
357
|
+
timeoutMs: number, maxBytes: number): Promise<Buffer> {
|
|
358
|
+
const repoPath = record.path.value;
|
|
359
|
+
if (!repoPath) throw new Error("Selected Git path is not addressable");
|
|
360
|
+
if (record.kind === "untracked") return allAddedPatch(readWorkspaceFile(snapshot.repoRoot, repoPath, maxBytes));
|
|
361
|
+
const paths = [...new Set([record.oldPath?.value, repoPath].filter((value): value is string => Boolean(value)))];
|
|
362
|
+
return runReadOnlyGit(snapshot.repoRoot, ["diff", "--no-ext-diff", "--no-textconv", "--find-renames",
|
|
363
|
+
`--unified=${contextLines}`, "--", ...paths], { timeoutMs, maxBytes });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export type ExactGitPatchAuthority = { header: Buffer; hunks: Array<{ hunkRef: string; bytes: Buffer }> };
|
|
367
|
+
type ExactGitPatchOptions = {
|
|
368
|
+
snapshot: GitStatusSnapshot; record: GitStatusRecord; view: "unstaged" | "staged"; fileRef: string;
|
|
369
|
+
contextLines?: number; timeoutMs?: number; maxBytes?: number; maxHunks?: number;
|
|
370
|
+
};
|
|
371
|
+
export async function collectExactGitPatchAuthority(input: ExactGitPatchOptions): Promise<ExactGitPatchAuthority | null> {
|
|
372
|
+
const contextLines = integer(input.contextLines, DEFAULT_CONTEXT, 0, 100), timeoutMs = integer(input.timeoutMs, 5_000, 100, 60_000);
|
|
373
|
+
const maxBytes = integer(input.maxBytes, DEFAULT_MAX_BYTES, 1_024, 16 * 1024 * 1024), maxHunks = integer(input.maxHunks, DEFAULT_MAX_HUNKS, 1, 128);
|
|
374
|
+
const raw = input.view === "unstaged" ? await patchForUnstagedRecord(input.snapshot, input.record, contextLines, timeoutMs, maxBytes)
|
|
375
|
+
: await patchForRecord(input.snapshot, input.record, "staged", contextLines, timeoutMs, maxBytes);
|
|
376
|
+
const text = raw.toString("utf8");
|
|
377
|
+
if (!Buffer.from(text, "utf8").equals(raw)) return null;
|
|
378
|
+
const lines = text.match(/[^\n]*(?:\n|$)/g)?.filter(Boolean) ?? [];
|
|
379
|
+
const header: string[] = [], hunks: Array<{ hunkRef: string; bytes: Buffer }> = [];
|
|
380
|
+
let current: { hunkRef: string; lines: string[] } | null = null;
|
|
381
|
+
for (const item of lines) {
|
|
382
|
+
const line = item.replace(/\n$/, "").replace(/\r$/, "");
|
|
383
|
+
if (/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(line)) {
|
|
384
|
+
if (current) hunks.push({ hunkRef: current.hunkRef, bytes: Buffer.from(current.lines.join("")) });
|
|
385
|
+
current = { hunkRef: token("hunk", `${input.fileRef}:${line}`), lines: [item] };
|
|
386
|
+
} else if (current) current.lines.push(item);
|
|
387
|
+
else header.push(item);
|
|
388
|
+
}
|
|
389
|
+
if (current) hunks.push({ hunkRef: current.hunkRef, bytes: Buffer.from(current.lines.join("")) });
|
|
390
|
+
if (!hunks.length || hunks.length > maxHunks || !header.some((line) => line.startsWith("diff --git "))) return null;
|
|
391
|
+
return { header: Buffer.from(header.join("")), hunks };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function sourceOptions(options: FileDiffOptions): SourceProjectionOptions {
|
|
395
|
+
return {
|
|
396
|
+
cwd: options.cwd,
|
|
397
|
+
identity: options.identity,
|
|
398
|
+
generatedAt: options.generatedAt,
|
|
399
|
+
taskRevision: options.taskRevision,
|
|
400
|
+
isProtectedPath: options.isProtectedPath,
|
|
401
|
+
timeoutMs: options.timeoutMs,
|
|
402
|
+
maxGitBytes: options.maxSourceBytes
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function collectFileDiff(options: FileDiffOptions): Promise<DiffDocument> {
|
|
407
|
+
const originalFile = findFile(options.sourceView, options.fileRef);
|
|
408
|
+
if (!originalFile) throw new Error("fileRef is not present in the canonical source view");
|
|
409
|
+
const timeoutMs = integer(options.timeoutMs, 5_000, 100, 60_000);
|
|
410
|
+
const maxBytes = integer(options.maxBytes, DEFAULT_MAX_BYTES, 1_024, 16 * 1024 * 1024);
|
|
411
|
+
const maxSourceBytes = integer(options.maxSourceBytes, 16 * 1024 * 1024, 1_024, 64 * 1024 * 1024);
|
|
412
|
+
const maxHunks = integer(options.maxHunks, DEFAULT_MAX_HUNKS, 1, 128);
|
|
413
|
+
const maxLines = integer(options.maxLines, DEFAULT_MAX_LINES, 1, 65_536);
|
|
414
|
+
const contextLines = integer(options.contextLines, DEFAULT_CONTEXT, 0, 100);
|
|
415
|
+
|
|
416
|
+
const selectedMode = options.revalidationMode === "selected-file" && options.sourceView.view !== "task";
|
|
417
|
+
const selectedState = selectedMode
|
|
418
|
+
? await collectSelectedSourceFileState(sourceOptions(options), options.sourceView.view as "working-tree" | "staged", options.fileRef, String(originalFile.basisRef), options.selectedRepoPaths)
|
|
419
|
+
: null;
|
|
420
|
+
const currentViews = selectedMode ? null : await collectSourceChangeViews(sourceOptions(options));
|
|
421
|
+
const currentSource = selectedMode ? options.sourceView : sourceForView(currentViews as Awaited<ReturnType<typeof collectSourceChangeViews>>, options.sourceView.view);
|
|
422
|
+
if (!currentSource) {
|
|
423
|
+
return fallbackDocument(options, options.sourceView, originalFile, observed(options.sourceView, originalFile, originalFile), "unavailable", "unavailable", "source-view-unavailable", "The source view is unavailable");
|
|
424
|
+
}
|
|
425
|
+
const currentFile = selectedMode ? selectedState?.file as Record<string, any> | undefined ?? null : findFile(currentSource, options.fileRef);
|
|
426
|
+
const currentObserved = observed(currentSource, currentFile, originalFile);
|
|
427
|
+
if (currentSource.availability.state !== "current" || !currentFile || !samePrecondition(options.precondition, currentObserved)) {
|
|
428
|
+
return fallbackDocument(options, currentSource, currentFile ?? originalFile, currentObserved, "stale", "stale", "stale-retry", "Source revisions changed; refresh and retry", true);
|
|
429
|
+
}
|
|
430
|
+
const content = currentFile.content as Record<string, unknown>;
|
|
431
|
+
if (content.access === "protected") {
|
|
432
|
+
return fallbackDocument(options, currentSource, currentFile, currentObserved, "unavailable", "protected", "protected-path", "Diff content is protected");
|
|
433
|
+
}
|
|
434
|
+
if (currentFile.status === "C") {
|
|
435
|
+
return fallbackDocument(options, currentSource, currentFile, currentObserved, "current", "conflict", "git-conflict", "Resolve the Git conflict before rendering a canonical diff");
|
|
436
|
+
}
|
|
437
|
+
if (["binary", "symlink", "submodule"].includes(String(content.kind))) {
|
|
438
|
+
const kind = content.kind as "binary" | "symlink" | "submodule";
|
|
439
|
+
return fallbackDocument(options, currentSource, currentFile, currentObserved, "current", kind, `${kind}-content`, `A textual diff is not available for ${kind} content`);
|
|
440
|
+
}
|
|
441
|
+
if (content.access !== "available") {
|
|
442
|
+
return fallbackDocument(options, currentSource, currentFile, currentObserved, "unavailable", "unavailable", "content-unavailable", "Diff content is unavailable");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let parsed: ParsedPatch;
|
|
446
|
+
try {
|
|
447
|
+
if (currentSource.view === "task") {
|
|
448
|
+
const contentPair = await readTaskFileContents(sourceOptions(options), options.fileRef);
|
|
449
|
+
const taskPatch = contentPair ? parseTaskPatch(contentPair.base, contentPair.current, options.fileRef, contextLines, maxHunks, maxLines, options.redactLine) : null;
|
|
450
|
+
if (!taskPatch) return fallbackDocument(options, currentSource, currentFile, currentObserved, "unavailable", "oversized", "diff-complexity-limit", "Exact task diff exceeds its complexity or content cap");
|
|
451
|
+
parsed = taskPatch;
|
|
452
|
+
} else {
|
|
453
|
+
const snapshot = selectedState?.snapshot ?? await collectGitStatus(options.cwd, { timeoutMs, maxBytes: maxSourceBytes });
|
|
454
|
+
const record = selectedState?.record ?? snapshot.records.find((candidate) => sourceFileRef(snapshot, candidate) === options.fileRef) ?? null;
|
|
455
|
+
if (!record) return fallbackDocument(options, currentSource, currentFile, currentObserved, "stale", "stale", "stale-retry", "The selected file changed during lookup", true);
|
|
456
|
+
const rawPatch = await patchForRecord(snapshot, record, currentSource.view, contextLines, timeoutMs, maxBytes);
|
|
457
|
+
parsed = parsePatch(rawPatch, options.fileRef, maxHunks, maxLines, options.redactLine);
|
|
458
|
+
}
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if ((error as any)?.code === "output-limit" || (error instanceof WorkspaceReadError && error.code === "oversized")) {
|
|
461
|
+
return fallbackDocument(options, currentSource, currentFile, currentObserved, "unavailable", "oversized", "diff-output-limit", "Diff exceeds the configured byte cap");
|
|
462
|
+
}
|
|
463
|
+
if (error instanceof WorkspaceReadError && error.code === "changed-during-read") {
|
|
464
|
+
return fallbackDocument(options, currentSource, currentFile, currentObserved, "stale", "stale", "stale-retry", "The selected file changed while rendering the diff", true);
|
|
465
|
+
}
|
|
466
|
+
if (error instanceof WorkspaceReadError) {
|
|
467
|
+
return fallbackDocument(options, currentSource, currentFile, currentObserved, "unavailable", "unavailable", "unsafe-workspace-path", "The selected file could not be read safely");
|
|
468
|
+
}
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const finalSelected = selectedMode
|
|
473
|
+
? await collectSelectedSourceFileState(sourceOptions(options), currentSource.view as "working-tree" | "staged", options.fileRef, String(currentFile.basisRef), options.selectedRepoPaths)
|
|
474
|
+
: null;
|
|
475
|
+
const finalViews = selectedMode ? null : await collectSourceChangeViews(sourceOptions(options));
|
|
476
|
+
const finalSource = selectedMode ? currentSource : sourceForView(finalViews as Awaited<ReturnType<typeof collectSourceChangeViews>>, currentSource.view);
|
|
477
|
+
const finalFile = selectedMode ? finalSelected?.file as Record<string, any> | undefined ?? null : findFile(finalSource, options.fileRef);
|
|
478
|
+
if (!finalSource || !finalFile || finalSource.viewRevision !== currentSource.viewRevision || finalFile.fileRevision !== currentFile.fileRevision) {
|
|
479
|
+
const latestObserved = finalSource ? observed(finalSource, finalFile, currentFile) : currentObserved;
|
|
480
|
+
return fallbackDocument(options, finalSource ?? currentSource, finalFile ?? currentFile, latestObserved, "stale", "stale", "stale-retry", "Source revisions changed while rendering the diff", true);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const document = baseDocument(options, currentSource, currentFile, currentObserved);
|
|
484
|
+
document.hunks = parsed.hunks;
|
|
485
|
+
document.unchangedRegions = parsed.unchangedRegions;
|
|
486
|
+
const truncated = parsed.omittedHunks > 0 || parsed.omittedLines > 0;
|
|
487
|
+
document.truncation = truncated
|
|
488
|
+
? {
|
|
489
|
+
truncated: true,
|
|
490
|
+
reasonCode: "diff-line-limit",
|
|
491
|
+
omittedHunks: parsed.omittedHunks,
|
|
492
|
+
omittedLines: parsed.omittedLines,
|
|
493
|
+
nextCursor: token("diff-page", `${currentObserved.fileRevision}:${parsed.hunks.length}:${parsed.omittedLines}`)
|
|
494
|
+
}
|
|
495
|
+
: { truncated: false, reasonCode: null, omittedHunks: 0, omittedLines: 0, nextCursor: null };
|
|
496
|
+
document.redaction = { applied: parsed.redactedLines > 0, valuesRemoved: parsed.redactedLines, truncated: false };
|
|
497
|
+
if (truncated) document.health = { state: "degraded", reasonCode: "diff-line-limit", message: "Diff output was truncated by its line or hunk cap" };
|
|
498
|
+
return document;
|
|
499
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
|
|
4
|
+
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
5
|
+
const DRIVER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
|
+
|
|
7
|
+
function environment(): NodeJS.ProcessEnv {
|
|
8
|
+
return { PATH: process.env.PATH, LC_ALL: "C", LANG: "C", GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_NOSYSTEM: "1",
|
|
9
|
+
GIT_CONFIG_GLOBAL: os.devNull, GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "",
|
|
10
|
+
...(process.platform === "win32" ? { SystemRoot: process.env.SystemRoot, ComSpec: process.env.ComSpec } : {}) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function localFilterDisableArgs(cwd: string, timeoutMs: number): Promise<string[]> {
|
|
14
|
+
const names = await new Promise<Buffer>((resolve, reject) => {
|
|
15
|
+
const child = spawn("git", ["--no-pager", "--no-optional-locks", "-c", "core.fsmonitor=false", "-C", cwd,
|
|
16
|
+
"config", "--local", "--includes", "--null", "--name-only", "--get-regexp", "^filter\\..*\\.(clean|smudge|process|required)$"],
|
|
17
|
+
{ cwd, env: environment(), shell: false, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
18
|
+
const chunks: Buffer[] = []; let bytes = 0, settled = false;
|
|
19
|
+
const timer = setTimeout(() => { if (!settled) { settled = true; child.kill("SIGKILL"); reject(new Error("git-filter-config-timeout")); } }, timeoutMs);
|
|
20
|
+
timer.unref?.();
|
|
21
|
+
child.stdout.on("data", (chunk: Buffer) => { bytes += chunk.length;
|
|
22
|
+
if (bytes > MAX_CONFIG_BYTES && !settled) { settled = true; child.kill("SIGKILL"); reject(new Error("git-filter-config-oversized")); }
|
|
23
|
+
else chunks.push(chunk); });
|
|
24
|
+
child.once("error", () => { clearTimeout(timer); if (!settled) { settled = true; reject(new Error("git-filter-config-unavailable")); } });
|
|
25
|
+
child.once("close", (code) => { clearTimeout(timer); if (settled) return; settled = true;
|
|
26
|
+
if (code === 0) resolve(Buffer.concat(chunks));
|
|
27
|
+
else if (code === 1 && bytes === 0) resolve(Buffer.alloc(0));
|
|
28
|
+
else reject(new Error("git-filter-config-unavailable")); });
|
|
29
|
+
});
|
|
30
|
+
const drivers = new Set<string>();
|
|
31
|
+
for (const raw of names.toString("utf8").split("\0").filter(Boolean)) {
|
|
32
|
+
const match = /^filter\.(.+)\.(?:clean|smudge|process|required)$/i.exec(raw);
|
|
33
|
+
if (!match || !DRIVER.test(match[1])) throw new Error("git-filter-driver-invalid");
|
|
34
|
+
drivers.add(match[1]);
|
|
35
|
+
}
|
|
36
|
+
return [...drivers].sort().flatMap((driver) => ["-c", `filter.${driver}.clean=`, "-c", `filter.${driver}.smudge=`,
|
|
37
|
+
"-c", `filter.${driver}.process=`, "-c", `filter.${driver}.required=false`]);
|
|
38
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import {
|
|
3
|
+
GitInspectionError,
|
|
4
|
+
type GitStatusSnapshot,
|
|
5
|
+
runReadOnlyGit
|
|
6
|
+
} from "./git-status-adapter.ts";
|
|
7
|
+
|
|
8
|
+
export type LineStat = { additions: number | null; deletions: number | null; binary: boolean };
|
|
9
|
+
|
|
10
|
+
function parseNumstat(raw: Buffer): Map<string, LineStat> {
|
|
11
|
+
const tokens = raw.toString("utf8").split("\0");
|
|
12
|
+
const result = new Map<string, LineStat>();
|
|
13
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
14
|
+
const tokenValue = tokens[index];
|
|
15
|
+
if (!tokenValue) continue;
|
|
16
|
+
const first = tokenValue.indexOf("\t");
|
|
17
|
+
const second = first < 0 ? -1 : tokenValue.indexOf("\t", first + 1);
|
|
18
|
+
if (first < 0 || second < 0) continue;
|
|
19
|
+
const added = tokenValue.slice(0, first);
|
|
20
|
+
const removed = tokenValue.slice(first + 1, second);
|
|
21
|
+
let file = tokenValue.slice(second + 1);
|
|
22
|
+
if (!file) {
|
|
23
|
+
index += 1;
|
|
24
|
+
const oldPath = tokens[index] ?? "";
|
|
25
|
+
index += 1;
|
|
26
|
+
file = tokens[index] ?? oldPath;
|
|
27
|
+
}
|
|
28
|
+
if (!file) continue;
|
|
29
|
+
const binary = added === "-" || removed === "-";
|
|
30
|
+
result.set(file, {
|
|
31
|
+
additions: binary || !/^\d+$/.test(added) ? null : Number(added),
|
|
32
|
+
deletions: binary || !/^\d+$/.test(removed) ? null : Number(removed),
|
|
33
|
+
binary
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function emptyTree(snapshot: GitStatusSnapshot, timeoutMs?: number, maxBytes?: number): Promise<string> {
|
|
40
|
+
if (snapshot.headOid) return snapshot.headOid;
|
|
41
|
+
const output = await runReadOnlyGit(snapshot.repoRoot, ["hash-object", "-t", "tree", os.devNull], { timeoutMs, maxBytes });
|
|
42
|
+
const oid = output.toString("ascii").trim();
|
|
43
|
+
if (!/^[a-f0-9]{40,64}$/.test(oid)) {
|
|
44
|
+
throw new GitInspectionError("invalid-output", "Git returned an invalid empty-tree object ID");
|
|
45
|
+
}
|
|
46
|
+
return oid;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function collectNumstats(
|
|
50
|
+
snapshot: GitStatusSnapshot,
|
|
51
|
+
view: "working-tree" | "staged",
|
|
52
|
+
repoPaths: string[],
|
|
53
|
+
timeoutMs?: number,
|
|
54
|
+
maxBytes?: number
|
|
55
|
+
): Promise<Map<string, LineStat>> {
|
|
56
|
+
if (repoPaths.length === 0) return new Map();
|
|
57
|
+
const base = await emptyTree(snapshot, timeoutMs, maxBytes);
|
|
58
|
+
const args = [
|
|
59
|
+
"diff",
|
|
60
|
+
...(view === "staged" ? ["--cached"] : []),
|
|
61
|
+
"--numstat",
|
|
62
|
+
"-z",
|
|
63
|
+
"--no-ext-diff",
|
|
64
|
+
"--no-textconv",
|
|
65
|
+
"--find-renames",
|
|
66
|
+
base,
|
|
67
|
+
"--",
|
|
68
|
+
...repoPaths
|
|
69
|
+
];
|
|
70
|
+
return parseNumstat(await runReadOnlyGit(snapshot.repoRoot, args, { timeoutMs, maxBytes }));
|
|
71
|
+
}
|