mixdog 1.2.1 → 1.2.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/package.json +1 -1
- package/scripts/gate-local.mjs +19 -3
- package/scripts/model-catalog-audit.mjs +1 -15
- package/src/agents/reviewer/AGENT.md +3 -3
- package/src/agents/reviewer/agent.json +1 -1
- package/src/defaults/shipped-history.json +1 -0
- package/src/repl/slash-commands.mjs +16 -1
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
- package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
- package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
- package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
- package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
- package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -7
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
- package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
- package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
- package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
- package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +77 -17
- package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
- package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
- package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
- package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
- package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +56 -23
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +4 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
- package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +4 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -3
- package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
- package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
- package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
- package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +31 -20
- package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
- package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
- package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
- package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
- package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
- package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
- package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
- package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
- package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +41 -21
- package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
- package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/memory/lib/pg/process.mjs +34 -24
- package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
- package/src/runtime/office/README.md +1 -1
- package/src/runtime/office/shared/values.mjs +0 -5
- package/src/runtime/shared/background-tasks.mjs +92 -14
- package/src/runtime/shared/err-text.mjs +25 -0
- package/src/runtime/shared/error-presentation.mjs +159 -19
- package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
- package/src/runtime/shared/llm/anthropic-effort.mjs +123 -134
- package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
- package/src/runtime/shared/llm/model-catalog.mjs +53 -40
- package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
- package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
- package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
- package/src/runtime/shared/model-display.mjs +13 -0
- package/src/runtime/shared/quota-resume-store.mjs +11 -0
- package/src/runtime/shared/session-baseline-store.mjs +960 -0
- package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
- package/src/runtime/shared/task-notification-envelope.mjs +2 -2
- package/src/runtime/shared/turn-changes.mjs +447 -219
- package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
- package/src/runtime/shared/turn-snapshot.mjs +170 -48
- package/src/runtime/shared/turn-worktree-snapshot.mjs +135 -46
- package/src/runtime/shared/worktrees/contract.mjs +0 -24
- package/src/runtime/shared/worktrees/test-support.mjs +19 -4
- package/src/session-runtime/boot/apis.mjs +6 -0
- package/src/session-runtime/boot/infrastructure.mjs +6 -0
- package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
- package/src/session-runtime/lifecycle/teardown.mjs +1 -1
- package/src/session-runtime/mcp-glue/connect.mjs +29 -9
- package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
- package/src/session-runtime/mcp-glue.mjs +3 -0
- package/src/session-runtime/mcp-oauth-login.mjs +174 -0
- package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
- package/src/session-runtime/model-route/set-route.mjs +83 -15
- package/src/session-runtime/model-route/web-search-route.mjs +1 -1
- package/src/session-runtime/native-web-search/routes.mjs +22 -3
- package/src/session-runtime/provider-models/model-meta.mjs +12 -3
- package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
- package/src/session-runtime/quick-web-search-models.mjs +13 -38
- package/src/session-runtime/resource-extension-sync.mjs +4 -0
- package/src/session-runtime/resource-mcp-api.mjs +98 -2
- package/src/session-runtime/runtime-review-api.mjs +13 -0
- package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
- package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
- package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
- package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
- package/src/session-runtime/settings-compaction-api.mjs +1 -0
- package/src/standalone/daemon-drain-policy.mjs +8 -0
- package/src/standalone/daemon-telemetry.mjs +55 -0
- package/src/standalone/daemon.mjs +3 -7
- package/src/standalone/session-protocol.mjs +7 -0
- package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
- package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
- package/src/standalone/session-service/agent-tree.mjs +12 -0
- package/src/standalone/session-service/quota-resume.mjs +8 -0
- package/src/standalone/session-service/retention/busy.mjs +15 -1
- package/src/standalone/session-service-api.mjs +5 -0
- package/src/standalone/session-service.mjs +1 -0
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/create-app-pickers.mjs +4 -0
- package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
- package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
- package/src/tui/app/extension-pickers.mjs +2 -1
- package/src/tui/app/model-options.mjs +11 -16
- package/src/tui/app/slash-commands.mjs +7 -2
- package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
- package/src/tui/components/StatusLine.jsx +2 -0
- package/src/tui/components/status-line/local-line.mjs +5 -2
- package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
- package/src/tui/dist/index.mjs +816 -43
- package/src/tui/session/quota-resume.mjs +46 -14
- package/src/tui/session/session-api/commands.mjs +33 -24
- package/src/tui/session/session-api/extensions.mjs +22 -0
- package/src/tui/session/session-api/integrations.mjs +7 -0
- package/src/tui/session/session-api/lifecycle.mjs +5 -1
- package/src/tui/session/session-api/routes.mjs +71 -7
- package/src/tui/session/session-api/settings.mjs +5 -14
- package/src/tui/session/session-flow/drain.mjs +7 -1
- package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
- package/src/tui/session/turn.mjs +38 -15
- package/src/tui/statusline-ansi-bridge.mjs +1 -1
- package/src/ui/statusline-agents.mjs +15 -4
- package/src/ui/statusline.mjs +7 -3
- package/src/ui/streaming-markdown-heal.mjs +34 -3
- package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
- package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
- package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// turn-snapshot-store.mjs. Every file carries its own complete patch, separate
|
|
6
6
|
// from the bounded display patch the review bar renders, so a historical turn
|
|
7
7
|
// stays undoable after later turns, cache eviction or a runtime restart.
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
8
9
|
import { lstat, open, realpath, stat } from 'node:fs/promises';
|
|
9
|
-
import { isAbsolute, relative, resolve } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
11
|
+
import { structuredPatch } from 'diff';
|
|
11
12
|
import { cleanString as clean } from './clean.mjs';
|
|
12
13
|
import { runGitOffThread } from './git-runner.mjs';
|
|
13
14
|
|
|
@@ -344,25 +345,32 @@ export function turnChangesReply({ status, checkpointId, record = null, patch =
|
|
|
344
345
|
return reply;
|
|
345
346
|
}
|
|
346
347
|
|
|
347
|
-
// ── Session review:
|
|
348
|
+
// ── Session review: first-seen baselines against the disk ───────────────────
|
|
348
349
|
|
|
349
350
|
export const NET_TEXT_MAX_BYTES = 1024 * 1024;
|
|
350
351
|
/** Budget of the merged session patch, in UTF-8 bytes. */
|
|
351
352
|
export const MERGED_PATCH_MAX_BYTES = 2 * 1024 * 1024;
|
|
352
353
|
const VIEW_PATCH_MAX_BYTES = 256 * 1024;
|
|
353
354
|
|
|
354
|
-
/**
|
|
355
|
-
|
|
356
|
-
* Buffers or null (absent). */
|
|
357
|
-
export function namedChangePatch({ before, after, oldName, newName, maxBytes = VIEW_PATCH_MAX_BYTES }) {
|
|
358
|
-
const renamed = oldName !== newName;
|
|
355
|
+
/** Counts and a placeholder-named patch of exact before/after bytes. */
|
|
356
|
+
function measureChange(before, after, renamed) {
|
|
359
357
|
const measured = trackedChangeFile({
|
|
360
358
|
oldPath: renamed ? 'mx-old-file' : 'mx-file',
|
|
361
359
|
newPath: renamed ? 'mx-new-file' : 'mx-file',
|
|
362
360
|
before,
|
|
363
361
|
after,
|
|
364
362
|
});
|
|
365
|
-
|
|
363
|
+
return {
|
|
364
|
+
status: measured.status,
|
|
365
|
+
additions: measured.additions,
|
|
366
|
+
deletions: measured.deletions,
|
|
367
|
+
...(measured.binary ? { binary: true } : {}),
|
|
368
|
+
text: measured.patch ? measured.patch.toString('utf8') : '',
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** A measured patch with its placeholders replaced by the names a viewer shows. */
|
|
373
|
+
function nameMeasuredPatch(text, oldName, newName) {
|
|
366
374
|
const split = text.search(/\n@@ /);
|
|
367
375
|
const head = (split >= 0 ? text.slice(0, split) : text)
|
|
368
376
|
.split('mx-old-file')
|
|
@@ -371,19 +379,21 @@ export function namedChangePatch({ before, after, oldName, newName, maxBytes = V
|
|
|
371
379
|
.join(newName)
|
|
372
380
|
.split('mx-file')
|
|
373
381
|
.join(newName);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
};
|
|
382
|
+
return head + (split >= 0 ? text.slice(split) : '');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** A git-format patch of exact before/after text under the names a viewer
|
|
386
|
+
* shows (absolute for a file outside the worktree). `before`/`after` are
|
|
387
|
+
* Buffers or null (absent). */
|
|
388
|
+
export function namedChangePatch({ before, after, oldName, newName, maxBytes = VIEW_PATCH_MAX_BYTES }) {
|
|
389
|
+
const { text, ...measured } = measureChange(before, after, oldName !== newName);
|
|
390
|
+
const patch = nameMeasuredPatch(text, oldName, newName);
|
|
391
|
+
return { ...measured, patch: Buffer.byteLength(patch, 'utf8') > maxBytes ? '' : patch };
|
|
382
392
|
}
|
|
383
393
|
|
|
384
394
|
const slashPath = (value) => clean(value).replace(/\\/g, '/');
|
|
385
395
|
const isOutsidePath = (path) => isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path);
|
|
386
|
-
const
|
|
396
|
+
const absolutePath = (root, path) => (isOutsidePath(path) ? resolve(path) : resolve(root, path));
|
|
387
397
|
const patchOf = (file) =>
|
|
388
398
|
changePatchBuffer(file)?.toString('utf8') || (typeof file?.viewPatch === 'string' ? file.viewPatch : '');
|
|
389
399
|
|
|
@@ -407,12 +417,13 @@ async function readBounded(file) {
|
|
|
407
417
|
}
|
|
408
418
|
}
|
|
409
419
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
|
|
420
|
+
const missingFile = (error) => error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
|
|
421
|
+
|
|
422
|
+
/** The recorded file's absolute path when it may be read: null when absent,
|
|
423
|
+
* undefined when it is reached through a link. An in-root file is followed
|
|
424
|
+
* only while it stays inside the recorded root; an outside file is read only
|
|
425
|
+
* at its exact recorded path, never through a link. */
|
|
426
|
+
async function safeCurrentFile(root, path) {
|
|
416
427
|
const outside = isOutsidePath(path);
|
|
417
428
|
const file = outside ? resolve(path) : resolve(root, path);
|
|
418
429
|
try {
|
|
@@ -426,10 +437,22 @@ export async function currentText(root, path) {
|
|
|
426
437
|
const [realRoot, realFile] = await Promise.all([realpath(root), realpath(file)]);
|
|
427
438
|
if (!insideDirectory(realRoot, realFile)) return undefined;
|
|
428
439
|
}
|
|
440
|
+
return file;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
return missingFile(error) ? null : undefined;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The file's current text: a string, null when absent, undefined when it
|
|
447
|
+
* cannot be netted (unreadable, binary, large, or reached through a link). */
|
|
448
|
+
export async function currentText(root, path) {
|
|
449
|
+
const file = await safeCurrentFile(root, path);
|
|
450
|
+
if (!file) return file;
|
|
451
|
+
try {
|
|
429
452
|
const bytes = await readBounded(file);
|
|
430
453
|
return bytes === undefined ? undefined : (decodeText(bytes) ?? undefined);
|
|
431
454
|
} catch (error) {
|
|
432
|
-
return error
|
|
455
|
+
return missingFile(error) ? null : undefined;
|
|
433
456
|
}
|
|
434
457
|
}
|
|
435
458
|
|
|
@@ -459,248 +482,453 @@ export async function measureLegacyChangeFile(file, root) {
|
|
|
459
482
|
return { ...file, additions: named.additions, deletions: named.deletions, viewPatch: named.patch };
|
|
460
483
|
}
|
|
461
484
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (section.hunks.length) {
|
|
488
|
-
const result = applyPatch(text, reversePatch(section));
|
|
489
|
-
if (result === false) return undefined;
|
|
490
|
-
text = result;
|
|
485
|
+
// Largest current file hashed against a baseline whose bytes were not kept.
|
|
486
|
+
const DIGEST_MAX_BYTES = 64 * 1024 * 1024;
|
|
487
|
+
// Largest measured patch a review keeps cached between polls.
|
|
488
|
+
const CACHED_PATCH_MAX_BYTES = 256 * 1024;
|
|
489
|
+
const READ_CONCURRENCY = 16;
|
|
490
|
+
const TOO_LARGE_REASON = 'file is too large to diff';
|
|
491
|
+
const UNREADABLE_REASON = 'the file cannot be read safely now';
|
|
492
|
+
const SEVERAL_TURNS_REASON = 'edited in several turns before its session baseline was recorded';
|
|
493
|
+
/** Why a listed file has no exact diff against the session's first state. */
|
|
494
|
+
const BASELINE_REASONS = {
|
|
495
|
+
'omitted-binary': "the session's original binary content was not captured",
|
|
496
|
+
'omitted-oversized': "the session's original content was too large to capture",
|
|
497
|
+
evicted: "the session's original content was evicted to bound storage",
|
|
498
|
+
missing: "the session's original state was not recorded",
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
502
|
+
|
|
503
|
+
async function mapLimit(items, limit, task) {
|
|
504
|
+
const results = new Array(items.length);
|
|
505
|
+
let next = 0;
|
|
506
|
+
const worker = async () => {
|
|
507
|
+
while (next < items.length) {
|
|
508
|
+
const index = next++;
|
|
509
|
+
results[index] = await task(items[index]);
|
|
491
510
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
511
|
+
};
|
|
512
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
513
|
+
return results;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** An absolute file under the link rules of currentText: relative to its
|
|
517
|
+
* recorded root when inside it, else its exact path. */
|
|
518
|
+
const fileRef = (root, abs) =>
|
|
519
|
+
root && insideDirectory(root, abs) ? { root, path: relative(root, abs) } : { root: dirname(abs), path: abs };
|
|
520
|
+
|
|
521
|
+
/** `{ state: 'absent' | 'unsafe' }` or `{ state: 'present', file, size, sig }`. */
|
|
522
|
+
async function currentFile({ root, path }) {
|
|
523
|
+
const file = await safeCurrentFile(root, path);
|
|
524
|
+
if (file === null) return { state: 'absent' };
|
|
525
|
+
if (file === undefined) return { state: 'unsafe' };
|
|
526
|
+
try {
|
|
527
|
+
const info = await stat(file);
|
|
528
|
+
if (!info.isFile()) return { state: 'unsafe' };
|
|
529
|
+
return { state: 'present', file, size: info.size, sig: `${info.size}:${info.mtimeMs}:${info.ctimeMs}` };
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return { state: missingFile(error) ? 'absent' : 'unsafe' };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const currentSig = (current) => (current?.state === 'present' ? current.sig : current?.state || '');
|
|
536
|
+
|
|
537
|
+
/** Bytes of a present file up to NET_TEXT_MAX_BYTES; undefined past it or
|
|
538
|
+
* when it cannot be read. */
|
|
539
|
+
async function currentBytes(current) {
|
|
540
|
+
if (current.size > NET_TEXT_MAX_BYTES) return undefined;
|
|
541
|
+
try {
|
|
542
|
+
return await readBounded(current.file);
|
|
543
|
+
} catch {
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function fileDigest(file) {
|
|
549
|
+
let handle;
|
|
550
|
+
try {
|
|
551
|
+
handle = await open(file, 'r');
|
|
552
|
+
const info = await handle.stat();
|
|
553
|
+
if (!info.isFile() || info.size > DIGEST_MAX_BYTES) return undefined;
|
|
554
|
+
const hash = createHash('sha256');
|
|
555
|
+
const buffer = Buffer.alloc(1024 * 1024);
|
|
556
|
+
for (let position = 0; ; ) {
|
|
557
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
558
|
+
if (!bytesRead) break;
|
|
559
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
560
|
+
position += bytesRead;
|
|
495
561
|
}
|
|
562
|
+
return hash.digest('hex');
|
|
563
|
+
} catch {
|
|
564
|
+
return undefined;
|
|
565
|
+
} finally {
|
|
566
|
+
await handle?.close().catch(() => {});
|
|
496
567
|
}
|
|
497
|
-
return text;
|
|
498
568
|
}
|
|
499
569
|
|
|
500
|
-
/**
|
|
501
|
-
*
|
|
502
|
-
function
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
570
|
+
/** Whether a present file still holds the baseline's bytes: true, false, or
|
|
571
|
+
* undefined when that cannot be told. */
|
|
572
|
+
async function sameAsBaseline(entry, current, after) {
|
|
573
|
+
if (entry.size > 0 && entry.size !== current.size) return false;
|
|
574
|
+
if (!entry.sha256) return undefined;
|
|
575
|
+
const hash = after ? sha256(after) : await fileDigest(current.file);
|
|
576
|
+
return hash === undefined ? undefined : hash === entry.sha256;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** A row the review cannot measure exactly: it never vouches for the set. */
|
|
580
|
+
const partialChange = (status, reason, extra = {}) => ({
|
|
581
|
+
status,
|
|
582
|
+
additions: 0,
|
|
583
|
+
deletions: 0,
|
|
584
|
+
text: '',
|
|
585
|
+
reason,
|
|
586
|
+
partial: true,
|
|
587
|
+
...extra,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const baselineGap = (entry, status) =>
|
|
591
|
+
partialChange(status, BASELINE_REASONS[entry.status] || BASELINE_REASONS.missing, {
|
|
592
|
+
baseline: entry.status,
|
|
593
|
+
...(entry.status === 'omitted-binary' ? { binary: true } : {}),
|
|
515
594
|
});
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
async function
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
});
|
|
546
|
-
const omitted = !net.patch && net.additions + net.deletions > 0;
|
|
547
|
-
return {
|
|
548
|
-
path,
|
|
549
|
-
...identity,
|
|
550
|
-
...(oldName !== path ? { oldPath: oldName } : {}),
|
|
551
|
-
...net,
|
|
552
|
-
...(omitted ? { patchOmitted: true } : {}),
|
|
553
|
-
};
|
|
595
|
+
|
|
596
|
+
/** A file matching the hash of a first state whose bytes were not kept: not
|
|
597
|
+
* listed, but it does not let the review vouch for the set either. */
|
|
598
|
+
const UNVOUCHED_UNCHANGED = Object.freeze({ hidden: true, partial: true });
|
|
599
|
+
|
|
600
|
+
/** One file (or rename) measured from its first-seen state `entry` to the
|
|
601
|
+
* disk now (`current`); null when the session left it as it found it,
|
|
602
|
+
* UNVOUCHED_UNCHANGED when only a hash says so. */
|
|
603
|
+
async function measureBaseline(entry, current, renamed, readBlob) {
|
|
604
|
+
const changed = renamed ? 'R' : 'M';
|
|
605
|
+
if (current.state === 'unsafe') {
|
|
606
|
+
return partialChange(entry.state === 'absent' ? 'A' : changed, UNREADABLE_REASON);
|
|
607
|
+
}
|
|
608
|
+
const present = current.state === 'present';
|
|
609
|
+
const after = present ? await currentBytes(current) : null;
|
|
610
|
+
if (entry.state === 'absent') {
|
|
611
|
+
if (!present) return null;
|
|
612
|
+
if (after === undefined) return { status: 'A', additions: 0, deletions: 0, text: '', reason: TOO_LARGE_REASON };
|
|
613
|
+
return measureChange(null, after, false);
|
|
614
|
+
}
|
|
615
|
+
if (present) {
|
|
616
|
+
const same = await sameAsBaseline(entry, current, after);
|
|
617
|
+
const exact = entry.status === 'captured';
|
|
618
|
+
if (same === true && !renamed) return exact ? null : UNVOUCHED_UNCHANGED;
|
|
619
|
+
if (same === true) {
|
|
620
|
+
const moved = await measureChange(Buffer.alloc(0), Buffer.alloc(0), true);
|
|
621
|
+
return exact
|
|
622
|
+
? moved
|
|
623
|
+
: { ...moved, partial: true, baseline: entry.status, reason: BASELINE_REASONS[entry.status] };
|
|
554
624
|
}
|
|
625
|
+
if (same === undefined || !exact) return baselineGap(entry, changed);
|
|
555
626
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
627
|
+
const before = entry.status === 'captured' ? await readBlob(entry) : undefined;
|
|
628
|
+
if (!before)
|
|
629
|
+
return baselineGap(entry.status === 'captured' ? { ...entry, status: 'evicted' } : entry, present ? changed : 'D');
|
|
630
|
+
if (after === undefined) return { status: changed, additions: 0, deletions: 0, text: '', reason: TOO_LARGE_REASON };
|
|
631
|
+
return measureChange(before, after, renamed);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Every baseline not `covered` by another source, measured against the disk.
|
|
635
|
+
* A path holding a renamed file pairs with the path it came from when that
|
|
636
|
+
* one is gone. Measurements are reused while the files' stats are unchanged. */
|
|
637
|
+
async function baselineChanges(baselines, covered, unvouched) {
|
|
638
|
+
const entries = new Map();
|
|
639
|
+
for (const entry of baselines.files) if (!covered(entry.path)) entries.set(entry.key, entry);
|
|
640
|
+
const list = [...entries.values()];
|
|
641
|
+
const states = await mapLimit(list, READ_CONCURRENCY, (entry) => currentFile(fileRef(entry.root, entry.path)));
|
|
642
|
+
const current = new Map(list.map((entry, index) => [entry.key, states[index]]));
|
|
643
|
+
const paired = new Set();
|
|
644
|
+
const units = [];
|
|
645
|
+
for (const [destinationKey, originKey] of baselines.renames) {
|
|
646
|
+
const destination = entries.get(destinationKey);
|
|
647
|
+
const origin = entries.get(originKey);
|
|
648
|
+
if (!destination || !origin || paired.has(destinationKey) || paired.has(originKey)) continue;
|
|
649
|
+
if (destination.state !== 'absent' || origin.state === 'absent') continue;
|
|
650
|
+
if (current.get(destinationKey).state === 'absent' || current.get(originKey).state !== 'absent') continue;
|
|
651
|
+
paired.add(destinationKey);
|
|
652
|
+
paired.add(originKey);
|
|
653
|
+
units.push({ key: `${originKey}\0${destinationKey}`, before: origin, after: destination });
|
|
654
|
+
}
|
|
655
|
+
for (const entry of list) if (!paired.has(entry.key)) units.push({ key: entry.key, before: entry, after: entry });
|
|
656
|
+
const measured = await mapLimit(units, READ_CONCURRENCY, async (unit) => {
|
|
657
|
+
const renamed = unit.before !== unit.after;
|
|
658
|
+
const now = current.get(unit.after.key);
|
|
659
|
+
const sig = [
|
|
660
|
+
unit.before.state,
|
|
661
|
+
unit.before.status,
|
|
662
|
+
unit.before.sha256 || '',
|
|
663
|
+
unit.before.blob || '',
|
|
664
|
+
unit.before.at,
|
|
665
|
+
unit.after.at,
|
|
666
|
+
currentSig(now),
|
|
667
|
+
currentSig(current.get(unit.before.key)),
|
|
668
|
+
].join('|');
|
|
669
|
+
const cached = baselines.cache?.get(unit.key);
|
|
670
|
+
let change = cached?.sig === sig ? cached.change : undefined;
|
|
671
|
+
if (change === undefined) {
|
|
672
|
+
change = await measureBaseline(unit.before, now, renamed, baselines.readBlob);
|
|
673
|
+
if (Buffer.byteLength(change?.text || '', 'utf8') <= CACHED_PATCH_MAX_BYTES) {
|
|
674
|
+
baselines.cache?.set(unit.key, { sig, change });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (change?.hidden) unvouched.add(unit.after.key);
|
|
678
|
+
if (!change || change.hidden) return null;
|
|
679
|
+
return {
|
|
680
|
+
key: unit.after.key,
|
|
681
|
+
abs: unit.after.path,
|
|
682
|
+
root: unit.after.root,
|
|
683
|
+
...(renamed ? { oldAbs: unit.before.path, oldRoot: unit.before.root } : {}),
|
|
684
|
+
change,
|
|
685
|
+
};
|
|
686
|
+
});
|
|
687
|
+
return measured.filter(Boolean);
|
|
581
688
|
}
|
|
582
689
|
|
|
583
|
-
/**
|
|
584
|
-
*
|
|
585
|
-
|
|
586
|
-
|
|
690
|
+
/**
|
|
691
|
+
* Files of the retained turn records that no baseline lists — a session (or
|
|
692
|
+
* turns) recorded before baselines existed. Never exact: a file edited in one
|
|
693
|
+
* turn keeps that turn's diff, one edited in several is listed unmeasured.
|
|
694
|
+
* Also returns the baseline keys such older records touched: those baselines
|
|
695
|
+
* were first seen after an unrecorded edit.
|
|
696
|
+
*/
|
|
697
|
+
async function legacyChanges(records, { kinds, listed, covered, since }) {
|
|
698
|
+
const groups = new Map();
|
|
699
|
+
const late = new Set();
|
|
587
700
|
for (const record of records) {
|
|
588
701
|
const root = clean(record?.root);
|
|
589
702
|
const entries = [
|
|
590
|
-
...(
|
|
703
|
+
...(kinds.has(record?.kind) ? record.files || [] : []),
|
|
591
704
|
...(Array.isArray(record?.outside) ? record.outside : []),
|
|
592
705
|
];
|
|
593
706
|
for (const file of entries) {
|
|
594
707
|
if (!file?.path || file.state === 'reverted') continue;
|
|
595
|
-
const
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
708
|
+
const abs = absolutePath(root, file.path);
|
|
709
|
+
const key = changePathKey(abs);
|
|
710
|
+
const oldAbs = file.oldPath ? absolutePath(root, file.oldPath) : '';
|
|
711
|
+
if (covered(abs)) continue;
|
|
712
|
+
if (listed.has(key) || (oldAbs && listed.has(changePathKey(oldAbs)))) {
|
|
713
|
+
if (!(Number(record.createdAt) >= since)) late.add(key);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const prior = groups.get(key) || (oldAbs ? groups.get(changePathKey(oldAbs)) : undefined);
|
|
717
|
+
if (prior) groups.delete(prior.key);
|
|
718
|
+
let origin = {};
|
|
719
|
+
if (prior) origin = { oldAbs: prior.oldAbs, oldRoot: prior.oldRoot };
|
|
720
|
+
else if (oldAbs) origin = { oldAbs, oldRoot: root };
|
|
721
|
+
groups.set(key, {
|
|
722
|
+
key,
|
|
723
|
+
abs,
|
|
724
|
+
root,
|
|
725
|
+
file,
|
|
726
|
+
first: prior?.first || file,
|
|
727
|
+
turns: (prior?.turns || 0) + 1,
|
|
728
|
+
...origin,
|
|
729
|
+
});
|
|
602
730
|
}
|
|
603
731
|
}
|
|
604
|
-
|
|
732
|
+
const changes = [];
|
|
733
|
+
for (const group of groups.values()) {
|
|
734
|
+
const { file, first } = group;
|
|
735
|
+
if (group.turns > 1 && first.status === 'A' && file.status === 'D') continue;
|
|
736
|
+
const unit = {
|
|
737
|
+
key: group.key,
|
|
738
|
+
abs: group.abs,
|
|
739
|
+
root: group.root,
|
|
740
|
+
...(group.oldAbs ? { oldAbs: group.oldAbs, oldRoot: group.oldRoot } : {}),
|
|
741
|
+
};
|
|
742
|
+
if (group.turns > 1) {
|
|
743
|
+
const status = first.status === 'A' ? 'A' : file.status;
|
|
744
|
+
changes.push({
|
|
745
|
+
...unit,
|
|
746
|
+
change: partialChange(status, SEVERAL_TURNS_REASON, { baseline: 'missing', unmeasured: true }),
|
|
747
|
+
});
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const measured = await measureLegacyChangeFile(file, group.root);
|
|
751
|
+
const recordPatch = patchOf(measured);
|
|
752
|
+
const counts = (Number(measured.additions) || 0) + (Number(measured.deletions) || 0);
|
|
753
|
+
changes.push({
|
|
754
|
+
...unit,
|
|
755
|
+
change: {
|
|
756
|
+
status: file.status,
|
|
757
|
+
additions: Number(measured.additions) || 0,
|
|
758
|
+
deletions: Number(measured.deletions) || 0,
|
|
759
|
+
...(measured.binary ? { binary: true } : {}),
|
|
760
|
+
text: '',
|
|
761
|
+
recordPatch,
|
|
762
|
+
recordName: slashPath(file.path),
|
|
763
|
+
partial: true,
|
|
764
|
+
baseline: 'missing',
|
|
765
|
+
...(!recordPatch && counts === 0 && !measured.binary
|
|
766
|
+
? { unmeasured: true, reason: clean(file.reason) || BASELINE_REASONS.missing }
|
|
767
|
+
: {}),
|
|
768
|
+
...(!recordPatch && file.patchOmitted === true ? { patchOmitted: true } : {}),
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
return { changes, late };
|
|
605
773
|
}
|
|
606
774
|
|
|
607
|
-
/**
|
|
608
|
-
* lists the
|
|
609
|
-
|
|
610
|
-
const
|
|
775
|
+
/** Files (`{ root, abs }`) the session claims but no baseline or record
|
|
776
|
+
* lists: the ones still present are listed without a diff. */
|
|
777
|
+
async function missingChanges(refs, { listed, covered }) {
|
|
778
|
+
const files = [];
|
|
779
|
+
for (const { root, abs } of refs) {
|
|
780
|
+
const key = changePathKey(abs);
|
|
781
|
+
if (listed.has(key) || covered(abs)) continue;
|
|
782
|
+
listed.add(key);
|
|
783
|
+
files.push({ key, abs, root });
|
|
784
|
+
}
|
|
785
|
+
const states = await mapLimit(files, READ_CONCURRENCY, (file) => currentFile(fileRef(file.root, file.abs)));
|
|
786
|
+
return files.map((file, index) =>
|
|
787
|
+
states[index].state === 'absent'
|
|
788
|
+
? { ...file, gone: true }
|
|
789
|
+
: { ...file, change: partialChange('M', BASELINE_REASONS.missing, { baseline: 'missing' }) }
|
|
790
|
+
);
|
|
791
|
+
}
|
|
611
792
|
|
|
612
793
|
/**
|
|
613
|
-
* The session-wide review
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
* file carries an `id` (root + path); when two roots would show the same
|
|
794
|
+
* The session-wide review: the Git baseline review (`base`) completed with
|
|
795
|
+
* every file it does not cover — a non-Git root, or a file outside the Git
|
|
796
|
+
* root — measured from its first-seen state in `baselines` (the Lead's and
|
|
797
|
+
* its attributed workers' edits together) against the disk now. `base.source`
|
|
798
|
+
* says what the Git side vouches for (`git`: its root; `empty`: nothing was
|
|
799
|
+
* owned). Retained `records` only list files no baseline knows (a session
|
|
800
|
+
* recorded before baselines existed) and never make the result complete.
|
|
801
|
+
* `complete` is set only when every listed file was measured exactly from a
|
|
802
|
+
* first state the session itself recorded; otherwise the result is `partial`.
|
|
803
|
+
* A file the user undid is back at its first state and is not listed. Each
|
|
804
|
+
* listed file carries an `id` (its absolute path); two roots sharing a
|
|
625
805
|
* relative name both show their absolute path.
|
|
626
806
|
*/
|
|
627
|
-
export async function
|
|
628
|
-
const source = base
|
|
807
|
+
export async function sessionChangeReview(base, { baselines = null, records = [] } = {}) {
|
|
808
|
+
const { source = '', reason: unavailableReason, owned, ...kept } = base || {};
|
|
629
809
|
const gitBacked = source === 'git';
|
|
630
|
-
const cleared = source === 'tool' ? { ...base, files: [], patch: '' } : base;
|
|
631
|
-
const retained = Array.isArray(records) ? records : [];
|
|
632
|
-
const withLive =
|
|
633
|
-
live && !retained.some((record) => record?.checkpointId === live.checkpointId) ? [...retained, live] : retained;
|
|
634
|
-
let inRootKinds = new Set(['tracked']);
|
|
635
|
-
if (gitBacked) inRootKinds = new Set();
|
|
636
|
-
else if (source === 'absent' || source === 'unavailable') inRootKinds = new Set(['git', 'tracked']);
|
|
637
|
-
const baseFiles = Array.isArray(cleared?.files) ? cleared.files : [];
|
|
638
|
-
const basePatch = typeof cleared?.patch === 'string' ? cleared.patch : '';
|
|
639
810
|
const baseRoot = clean(base?.root);
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
811
|
+
const covered = (abs) => gitBacked && Boolean(baseRoot) && insideDirectory(baseRoot, abs);
|
|
812
|
+
const listed = new Set((baselines?.files || []).map((entry) => entry.key));
|
|
813
|
+
let kinds = new Set(['tracked']);
|
|
814
|
+
if (gitBacked) kinds = new Set();
|
|
815
|
+
else if (source === 'absent' || source === 'unavailable') kinds = new Set(['git', 'tracked']);
|
|
816
|
+
// Baselines nothing is listed for, yet whose emptiness cannot be vouched for.
|
|
817
|
+
const unvouched = new Set();
|
|
818
|
+
const measured = baselines ? await baselineChanges(baselines, covered, unvouched) : [];
|
|
819
|
+
const legacy = await legacyChanges(Array.isArray(records) ? records : [], {
|
|
820
|
+
kinds,
|
|
821
|
+
listed,
|
|
822
|
+
covered,
|
|
823
|
+
since: Number(baselines?.createdAt) || Number.POSITIVE_INFINITY,
|
|
824
|
+
});
|
|
825
|
+
for (const change of legacy.changes) listed.add(change.key);
|
|
826
|
+
// Owned files of the non-Git root, and paths whose first state a clear in
|
|
827
|
+
// another process removed (the baselines are incomplete for them).
|
|
828
|
+
const refs = (baselines?.missing || []).map((entry) => ({ root: entry.root, abs: entry.path }));
|
|
829
|
+
if (!gitBacked && Array.isArray(owned)) {
|
|
830
|
+
for (const path of owned) refs.push({ root: baseRoot, abs: absolutePath(baseRoot, path) });
|
|
831
|
+
}
|
|
832
|
+
const missing = await missingChanges(refs, { listed, covered });
|
|
833
|
+
for (const change of measured) {
|
|
834
|
+
if (legacy.late.has(change.key)) change.change = { ...change.change, partial: true, reason: SEVERAL_TURNS_REASON };
|
|
835
|
+
}
|
|
836
|
+
const changes = [...measured, ...legacy.changes, ...missing.filter((file) => file.change)];
|
|
837
|
+
const baseFiles = gitBacked && Array.isArray(base.files) ? base.files : [];
|
|
838
|
+
const basePatch = gitBacked && typeof base.patch === 'string' ? base.patch : '';
|
|
839
|
+
// A relative name shared by different roots (or with a Git file) is shown
|
|
840
|
+
// absolute, so each file stays distinct.
|
|
841
|
+
const nameOf = (abs, root) => (root && insideDirectory(root, abs) ? slashPath(relative(root, abs)) : slashPath(abs));
|
|
648
842
|
const owners = new Map();
|
|
649
|
-
const claim = (
|
|
650
|
-
|
|
651
|
-
|
|
843
|
+
const claim = (name, abs) => {
|
|
844
|
+
if (isOutsidePath(name)) return;
|
|
845
|
+
const key = changePathKey(name);
|
|
846
|
+
owners.set(key, new Set([...(owners.get(key) || []), changePathKey(abs)]));
|
|
652
847
|
};
|
|
653
|
-
for (const file of baseFiles) if (file?.path) claim(file.path,
|
|
654
|
-
for (const
|
|
655
|
-
|
|
656
|
-
claim(
|
|
657
|
-
const origin = chain.entries[0];
|
|
658
|
-
if (origin.file.oldPath) claim(origin.file.oldPath, absoluteKey(origin.root, origin.file.oldPath));
|
|
659
|
-
}
|
|
660
|
-
const show = (root, rel) =>
|
|
661
|
-
isOutsidePath(rel) || (owners.get(changePathKey(rel))?.size || 0) <= 1
|
|
662
|
-
? slashPath(rel)
|
|
663
|
-
: slashPath(resolve(root, rel));
|
|
664
|
-
const nets = [];
|
|
665
|
-
for (const chain of chains) {
|
|
666
|
-
const net = await netChain(chain, show);
|
|
667
|
-
if (net) nets.push(net);
|
|
668
|
-
}
|
|
669
|
-
const { source: _source, reason: unavailableReason, ...kept } = cleared || {};
|
|
670
|
-
const complete = knownSources.has(source) || withLive.length > 0;
|
|
671
|
-
if (nets.length === 0) {
|
|
672
|
-
// Nothing recorded to add: the baseline answer stands (an unavailable
|
|
673
|
-
// baseline stays unavailable, never an invented empty set).
|
|
674
|
-
if (cleared?.supported === false) return { ...kept, reason: unavailableReason };
|
|
675
|
-
return { ...kept, ...(complete ? { complete: true } : {}) };
|
|
848
|
+
for (const file of baseFiles) if (file?.path) claim(file.path, resolve(baseRoot, file.path));
|
|
849
|
+
for (const change of changes) {
|
|
850
|
+
claim(nameOf(change.abs, change.root), change.abs);
|
|
851
|
+
if (change.oldAbs) claim(nameOf(change.oldAbs, change.oldRoot), change.oldAbs);
|
|
676
852
|
}
|
|
853
|
+
const show = (abs, root) => {
|
|
854
|
+
const name = nameOf(abs, root);
|
|
855
|
+
return isOutsidePath(name) || (owners.get(changePathKey(name))?.size || 0) <= 1 ? name : slashPath(abs);
|
|
856
|
+
};
|
|
857
|
+
const rows = changes
|
|
858
|
+
.map(({ abs, root, oldAbs, oldRoot, change }) => {
|
|
859
|
+
const path = show(abs, root);
|
|
860
|
+
const oldName = oldAbs ? show(oldAbs, oldRoot) : path;
|
|
861
|
+
let patch = '';
|
|
862
|
+
if (change.text) patch = nameMeasuredPatch(change.text, oldName, path);
|
|
863
|
+
else if (change.recordPatch && change.recordName === path) patch = change.recordPatch;
|
|
864
|
+
let patchOmitted = change.patchOmitted === true;
|
|
865
|
+
if (Buffer.byteLength(patch, 'utf8') > MERGED_PATCH_MAX_BYTES) {
|
|
866
|
+
patch = '';
|
|
867
|
+
patchOmitted = true;
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
path,
|
|
871
|
+
id: slashPath(abs),
|
|
872
|
+
outside: !(root && insideDirectory(root, abs)),
|
|
873
|
+
...(oldName !== path ? { oldPath: oldName } : {}),
|
|
874
|
+
status: change.status,
|
|
875
|
+
additions: change.additions,
|
|
876
|
+
deletions: change.deletions,
|
|
877
|
+
...(change.binary ? { binary: true } : {}),
|
|
878
|
+
...(change.reason ? { reason: change.reason } : {}),
|
|
879
|
+
...(change.baseline ? { baseline: change.baseline } : {}),
|
|
880
|
+
...(change.unmeasured ? { unmeasured: true } : {}),
|
|
881
|
+
...(patchOmitted ? { patchOmitted: true } : {}),
|
|
882
|
+
patch,
|
|
883
|
+
};
|
|
884
|
+
})
|
|
885
|
+
.sort((left, right) => {
|
|
886
|
+
if (left.path === right.path) return 0;
|
|
887
|
+
return left.path < right.path ? -1 : 1;
|
|
888
|
+
});
|
|
677
889
|
// The merged response stays bounded: past the budget a file is listed
|
|
678
890
|
// without its diff.
|
|
679
891
|
let budget = MERGED_PATCH_MAX_BYTES - Buffer.byteLength(basePatch, 'utf8');
|
|
680
892
|
let truncated = false;
|
|
681
893
|
const files = [];
|
|
682
894
|
const patches = [];
|
|
683
|
-
for (const { patch, ...file } of
|
|
684
|
-
const size =
|
|
895
|
+
for (const { patch, ...file } of rows) {
|
|
896
|
+
const size = Buffer.byteLength(patch, 'utf8');
|
|
685
897
|
if (patch && size <= budget) {
|
|
686
898
|
budget -= size;
|
|
687
899
|
patches.push(patch);
|
|
688
900
|
} else if (patch) {
|
|
689
901
|
truncated = true;
|
|
690
|
-
|
|
691
|
-
continue;
|
|
902
|
+
file.patchOmitted = true;
|
|
692
903
|
}
|
|
693
904
|
files.push(file);
|
|
694
905
|
}
|
|
906
|
+
// A baseline first seen after an older recorded edit (`late`) was not the
|
|
907
|
+
// session's first state: even with no row it cannot vouch for the set.
|
|
908
|
+
// Baselines the disk refused (`unsaved`) are known only to this process.
|
|
909
|
+
const partial =
|
|
910
|
+
baselines?.incomplete === true ||
|
|
911
|
+
baselines?.unsaved === true ||
|
|
912
|
+
unvouched.size > 0 ||
|
|
913
|
+
legacy.late.size > 0 ||
|
|
914
|
+
missing.length > 0 ||
|
|
915
|
+
changes.some((change) => change.change.partial === true);
|
|
916
|
+
const vouched = (gitBacked || source === 'empty' || Boolean(baselines)) && !partial;
|
|
917
|
+
const all = [...baseFiles, ...files];
|
|
918
|
+
// An unavailable Git baseline with nothing else to list stays unavailable.
|
|
919
|
+
if (all.length === 0 && !vouched && kept.supported === false) return { ...kept, reason: unavailableReason };
|
|
695
920
|
return {
|
|
696
921
|
...kept,
|
|
697
922
|
supported: true,
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
923
|
+
// Emptiness is claimed only when it can be vouched for.
|
|
924
|
+
authoritative: all.length > 0 || vouched,
|
|
925
|
+
...(vouched ? { complete: true } : {}),
|
|
926
|
+
...(partial ? { partial: true } : {}),
|
|
927
|
+
...(baselines?.unsaved === true ? { baselinesUnsaved: true } : {}),
|
|
928
|
+
files: all,
|
|
701
929
|
patch: [basePatch, ...patches].filter(Boolean).join(basePatch.endsWith('\n') || !basePatch ? '' : '\n'),
|
|
702
|
-
patchTruncated: kept.patchTruncated === true || truncated,
|
|
703
|
-
snapshotKind:
|
|
930
|
+
patchTruncated: (gitBacked && kept.patchTruncated === true) || truncated,
|
|
931
|
+
snapshotKind: kept.snapshotKind || 'session',
|
|
704
932
|
};
|
|
705
933
|
}
|
|
706
934
|
|