@gajae-code/coding-agent 0.15.4 → 0.15.6
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 +46 -2
- package/dist/types/async/job-manager.d.ts +83 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
- package/dist/types/cli/model-presets-cli.d.ts +9 -0
- package/dist/types/cli/update-cli.d.ts +27 -5
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +99 -0
- package/dist/types/config/file-lock.d.ts +49 -2
- package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
- package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +3 -2
- package/dist/types/config/model-preset-registry.d.ts +368 -0
- package/dist/types/config/model-profile-activation.d.ts +39 -0
- package/dist/types/config/model-profiles.d.ts +5 -3
- package/dist/types/config/model-registry.d.ts +11 -5
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
- package/dist/types/extensibility/extensions/types.d.ts +4 -4
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/sdk/broker/broker.d.ts +3 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/cli/session-cli.d.ts +42 -1
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/host/model-pin.d.ts +7 -2
- package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +19 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
- package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +19 -5
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
- package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
- package/dist/types/session/sdk-run-capability.d.ts +2 -0
- package/dist/types/telemetry/control.d.ts +11 -0
- package/dist/types/telemetry/events.d.ts +20 -0
- package/dist/types/telemetry/index.d.ts +3 -0
- package/dist/types/telemetry/transport.d.ts +22 -0
- package/dist/types/tools/bash-interactive.d.ts +23 -0
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/tools/path-utils.d.ts +5 -6
- package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
- package/dist/types/web/search/providers/base.d.ts +9 -4
- package/dist/types/web/search/providers/xai.d.ts +2 -1
- package/dist/types/web/search/types.d.ts +14 -0
- package/package.json +10 -7
- package/scripts/generate-hotkeys-docs.ts +4 -0
- package/scripts/generate-sdk-operation-inventory.ts +3 -3
- package/src/async/job-manager.ts +671 -39
- package/src/cli/auth-broker-cli.ts +40 -12
- package/src/cli/auth-gateway-cli.ts +273 -97
- package/src/cli/config-cli.ts +2 -2
- package/src/cli/fast-help.ts +3 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/update-cli.ts +142 -6
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/model-presets.ts +24 -0
- package/src/commands/sdk.ts +5 -0
- package/src/config/autorouting-tier-map.ts +33 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +775 -59
- package/src/config/internal/model-preset-registry-test-state.ts +28 -0
- package/src/config/internal/model-preset-registry-test-support.ts +11 -0
- package/src/config/keybindings.ts +6 -2
- package/src/config/model-preset-registry.ts +2799 -0
- package/src/config/model-profile-activation.ts +115 -28
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +56 -14
- package/src/config/model-registry.ts +1028 -123
- package/src/config/settings-schema.ts +11 -0
- package/src/config/settings.ts +6 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/policy.ts +1 -1
- package/src/coordinator-mcp/projection-scan.ts +571 -0
- package/src/coordinator-mcp/server.ts +19 -24
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- package/src/extensibility/extensions/loader.ts +0 -1
- package/src/extensibility/extensions/types.ts +4 -2
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
- package/src/extensibility/shared-events.ts +2 -0
- package/src/gjc-runtime/empty-delete-gc.ts +301 -0
- package/src/gjc-runtime/gc-render.ts +29 -0
- package/src/gjc-runtime/gc-runtime.ts +174 -11
- package/src/gjc-runtime/launch-worktree.ts +44 -7
- package/src/gjc-runtime/ralplan-runtime.ts +14 -1
- package/src/gjc-runtime/session-state-lock.ts +1242 -141
- package/src/gjc-runtime/session-state-sidecar.ts +76 -3
- package/src/gjc-runtime/tmux-sessions.ts +145 -48
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +9 -2
- package/src/modes/acp/acp-agent.ts +11 -5
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +23 -6
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/prompt-action-autocomplete.ts +26 -47
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/utils/injected-user-submission.ts +15 -1
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/fold-receipt.md +3 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +17 -2
- package/src/sdk/broker/broker.ts +48 -21
- package/src/sdk/broker/lifecycle.ts +32 -6
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
- package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
- package/src/sdk/bus/prompt-reconciliation.ts +33 -6
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/cli/session-cli.ts +161 -5
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +3 -0
- package/src/sdk/host/host.ts +7 -0
- package/src/sdk/host/model-pin.ts +185 -25
- package/src/sdk/host/sdk-run-capability.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +1240 -107
- package/src/sdk/host/session-runtime.ts +1029 -115
- package/src/sdk/prompt-deadline-manager.ts +16 -1
- package/src/sdk/protocol/adapter-validation.ts +27 -0
- package/src/sdk/protocol/operation-inventory.generated.json +2 -2
- package/src/sdk/protocol/operation-registry.ts +4 -1
- package/src/sdk/router/session-router.ts +297 -111
- package/src/sdk/session.ts +83 -12
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +887 -95
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/internal/managed-session-storage.ts +64 -26
- package/src/session/sdk-run-capability-internal.ts +15 -0
- package/src/session/sdk-run-capability.ts +2 -0
- package/src/session/session-manager.ts +29 -10
- package/src/setup/provider-presets.json +1 -1
- package/src/slash-commands/helpers/credential-switch.ts +6 -1
- package/src/task/executor.ts +19 -3
- package/src/telemetry/control.ts +26 -0
- package/src/telemetry/events.ts +142 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/transport.ts +87 -0
- package/src/tools/bash-interactive.ts +182 -85
- package/src/tools/bash.ts +786 -67
- package/src/tools/index.ts +7 -5
- package/src/tools/path-utils.ts +77 -29
- package/src/tools/tmux-self-injection-guard.ts +359 -0
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/web/search/provider.ts +120 -11
- package/src/web/search/providers/anthropic.ts +89 -17
- package/src/web/search/providers/base.ts +9 -4
- package/src/web/search/providers/gemini.ts +87 -15
- package/src/web/search/providers/openai-compatible.ts +23 -8
- package/src/web/search/providers/xai.ts +98 -12
- package/src/web/search/types.ts +16 -0
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
} from "./event-webhook";
|
|
70
70
|
import {
|
|
71
71
|
type CoordinatorModelProfileLoader,
|
|
72
|
-
|
|
72
|
+
createCoordinatorModelProfileLoader,
|
|
73
73
|
resolveCoordinatorMpreset,
|
|
74
74
|
} from "./model-preset";
|
|
75
75
|
import {
|
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
requireCoordinatorMutation,
|
|
84
84
|
safeOpenCoordinatorArtifact,
|
|
85
85
|
} from "./policy";
|
|
86
|
+
import { listCoordinatorJsonFiles } from "./projection-scan";
|
|
86
87
|
import {
|
|
87
88
|
answerBindingMatches,
|
|
88
89
|
buildCoordinatorAskAnswerSchema,
|
|
@@ -1497,26 +1498,17 @@ function publicSdkAcknowledgement(result: RuntimePromptAcknowledgement): Record<
|
|
|
1497
1498
|
}
|
|
1498
1499
|
|
|
1499
1500
|
async function listJsonFiles(dir: string): Promise<unknown[]> {
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
1510
|
-
logger.warn("Coordinator projection read failed", { path: entry, error: String(error) });
|
|
1511
|
-
throw error;
|
|
1512
|
-
}
|
|
1513
|
-
}),
|
|
1514
|
-
);
|
|
1515
|
-
return values.filter(value => value !== null);
|
|
1516
|
-
} catch (error) {
|
|
1517
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
1518
|
-
throw error;
|
|
1501
|
+
const scan = await listCoordinatorJsonFiles(dir);
|
|
1502
|
+
if (scan.capped) throw new Error("coordinator_projection_scan_incomplete");
|
|
1503
|
+
if (scan.skippedEmpty > 0 || scan.skippedDebris > 0) {
|
|
1504
|
+
logger.warn("Coordinator projection scan skipped debris", {
|
|
1505
|
+
dir,
|
|
1506
|
+
parsed: scan.parsed,
|
|
1507
|
+
skippedDebris: scan.skippedDebris,
|
|
1508
|
+
skippedEmpty: scan.skippedEmpty,
|
|
1509
|
+
});
|
|
1519
1510
|
}
|
|
1511
|
+
return scan.values;
|
|
1520
1512
|
}
|
|
1521
1513
|
|
|
1522
1514
|
const COORDINATOR_STATUS_EVENT_LIMIT = 100;
|
|
@@ -3260,7 +3252,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
3260
3252
|
await routerReady;
|
|
3261
3253
|
}
|
|
3262
3254
|
const platform = options.platform ?? process.platform;
|
|
3263
|
-
const loadModelProfiles = services.resolveModelProfiles ??
|
|
3255
|
+
const loadModelProfiles = services.resolveModelProfiles ?? createCoordinatorModelProfileLoader(routerAgentDir);
|
|
3264
3256
|
// Every authoritative projection is scoped by the collision-resistant namespace identity.
|
|
3265
3257
|
const namespaceDir = coordinatorNamespacePath(config);
|
|
3266
3258
|
// The prior human-readable directory is migration input only; it is never authority.
|
|
@@ -8412,6 +8404,9 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
8412
8404
|
}
|
|
8413
8405
|
if (name === "gjc_coordinator_start_session") {
|
|
8414
8406
|
requireCoordinatorMutation(config, "sessions", args);
|
|
8407
|
+
const suppliedPrompt = Object.hasOwn(args, "prompt");
|
|
8408
|
+
if (suppliedPrompt && (typeof args.prompt !== "string" || args.prompt.trim().length === 0))
|
|
8409
|
+
return { ok: false, error: { code: "invalid_input", message: "Prompt must not be empty." } };
|
|
8415
8410
|
const idempotencyKey = requiredIdempotencyKey(args);
|
|
8416
8411
|
const cwd = await canonicalBrokerWorkspace(await assertCoordinatorWorkdir(config, args.cwd));
|
|
8417
8412
|
const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
|
|
@@ -8434,7 +8429,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
8434
8429
|
error: { code: "unknown_model", message: modelResolution.error },
|
|
8435
8430
|
};
|
|
8436
8431
|
}
|
|
8437
|
-
const prompt =
|
|
8432
|
+
const prompt = suppliedPrompt ? (args.prompt as string) : null;
|
|
8438
8433
|
/**
|
|
8439
8434
|
* A prepared session is deliberately not ready for input: its readiness
|
|
8440
8435
|
* is withheld until an operator-supplied thread is bound and activation
|
|
@@ -9079,10 +9074,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
9079
9074
|
}
|
|
9080
9075
|
if (name === "gjc_coordinator_send_prompt") {
|
|
9081
9076
|
requireCoordinatorMutation(config, "sessions", args);
|
|
9077
|
+
if (typeof args.prompt !== "string" || args.prompt.trim().length === 0)
|
|
9078
|
+
return { ok: false, error: { code: "invalid_input", message: "Prompt must not be empty." } };
|
|
9082
9079
|
const idempotencyKey = requiredIdempotencyKey(args);
|
|
9083
9080
|
const sessionId = safeExternalId("session", args.session_id);
|
|
9084
|
-
if (typeof args.prompt !== "string" || args.prompt.length === 0)
|
|
9085
|
-
return { ok: false, error: { code: "invalid_input", message: "prompt is required" } };
|
|
9086
9081
|
const prompt = args.prompt;
|
|
9087
9082
|
return await withToolIdempotency(
|
|
9088
9083
|
name,
|
|
@@ -100,9 +100,15 @@ export function createSessionReaper(deps: SessionReaperDeps, policy: SessionReap
|
|
|
100
100
|
function schedule(gen: number): void {
|
|
101
101
|
timer = setTimeout(() => {
|
|
102
102
|
if (gen !== generation) return; // stale timer from a prior start()
|
|
103
|
-
void sweepOnce()
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
void sweepOnce()
|
|
104
|
+
.catch(error => {
|
|
105
|
+
// A refused sweep (e.g. a fail-closed capped projection scan) must not
|
|
106
|
+
// become an unhandled rejection; log and keep the schedule alive.
|
|
107
|
+
logger.warn(`session-reaper: sweep refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
108
|
+
})
|
|
109
|
+
.finally(() => {
|
|
110
|
+
if (gen === generation) schedule(gen);
|
|
111
|
+
});
|
|
106
112
|
}, sweepIntervalMs);
|
|
107
113
|
// The reaper must never keep the coordinator process alive by itself.
|
|
108
114
|
(timer as { unref?: () => void }).unref?.();
|
|
@@ -345,7 +345,6 @@ class ConcreteExtensionAPI implements ExtensionAPI {
|
|
|
345
345
|
onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
|
|
346
346
|
onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
|
|
347
347
|
preflightSignal?: AbortSignal;
|
|
348
|
-
sdkRunToken?: string;
|
|
349
348
|
},
|
|
350
349
|
): Promise<void> {
|
|
351
350
|
return Promise.resolve(this.runtime.sendUserMessage(content, options));
|
|
@@ -536,6 +536,7 @@ export interface ExtensionContext {
|
|
|
536
536
|
onPreflightAcceptCommit?: () => void | Promise<void>;
|
|
537
537
|
onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
|
|
538
538
|
preflightSignal?: AbortSignal;
|
|
539
|
+
sdkRunCapability?: unknown;
|
|
539
540
|
},
|
|
540
541
|
): Promise<unknown>;
|
|
541
542
|
setPlanMode?(on: boolean): unknown;
|
|
@@ -754,6 +755,8 @@ export interface AgentStartEvent extends SharedAgentStartEvent {
|
|
|
754
755
|
export interface AgentFailedEvent {
|
|
755
756
|
type: "agent_failed";
|
|
756
757
|
error: AgentFailureDiagnostic;
|
|
758
|
+
/** Internal SDK queue-owner binding for exact lifecycle attribution. */
|
|
759
|
+
sdkRunToken?: string;
|
|
757
760
|
/** Attempt correlation for the failing run, when scoped (matches the
|
|
758
761
|
* agent_start/agent_end scope contract). */
|
|
759
762
|
scope?: AttemptScope;
|
|
@@ -1313,7 +1316,6 @@ export interface ExtensionAPI {
|
|
|
1313
1316
|
onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
|
|
1314
1317
|
preflightSignal?: AbortSignal;
|
|
1315
1318
|
/** Internal SDK correlation owner for an exact queued follow-up. */
|
|
1316
|
-
sdkRunToken?: string;
|
|
1317
1319
|
},
|
|
1318
1320
|
): Promise<void>;
|
|
1319
1321
|
|
|
@@ -1543,7 +1545,6 @@ export type SendUserMessageHandler = (
|
|
|
1543
1545
|
onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
|
|
1544
1546
|
preflightSignal?: AbortSignal;
|
|
1545
1547
|
/** Internal SDK correlation owner for an exact queued follow-up. */
|
|
1546
|
-
sdkRunToken?: string;
|
|
1547
1548
|
},
|
|
1548
1549
|
) => void | Promise<void>;
|
|
1549
1550
|
|
|
@@ -1705,6 +1706,7 @@ export interface ExtensionContextActions {
|
|
|
1705
1706
|
onPreflightAcceptCommit?: () => void | Promise<void>;
|
|
1706
1707
|
onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
|
|
1707
1708
|
preflightSignal?: AbortSignal;
|
|
1709
|
+
sdkRunCapability?: unknown;
|
|
1708
1710
|
},
|
|
1709
1711
|
) => Promise<unknown>;
|
|
1710
1712
|
setPlanMode?: (on: boolean) => unknown;
|
|
@@ -453,6 +453,7 @@ export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
|
|
|
453
453
|
command: cfg.command,
|
|
454
454
|
args: cfg.args,
|
|
455
455
|
cwd: cfg.cwd ? nodePath.resolve(entry.pluginRoot, cfg.cwd) : entry.pluginRoot,
|
|
456
|
+
timeout: 5_000,
|
|
456
457
|
// Third-party plugin MCP processes must not inherit host secrets;
|
|
457
458
|
// only a minimal OS allowlist (PATH/HOME/temp/locale) is provided.
|
|
458
459
|
noInheritEnv: true,
|
|
@@ -184,6 +184,8 @@ export interface AgentStartEvent {
|
|
|
184
184
|
export interface AgentEndEvent {
|
|
185
185
|
type: "agent_end";
|
|
186
186
|
messages: AgentMessage[];
|
|
187
|
+
/** Internal SDK queue-owner binding for exact lifecycle attribution. */
|
|
188
|
+
sdkRunToken?: string;
|
|
187
189
|
/** Indicates whether the loop ended normally, suspended, cancelled, or entered maintenance. */
|
|
188
190
|
stopReason?: "completed" | "paused" | "cancelled" | "maintenance";
|
|
189
191
|
/** Present for maintenance checkpoints; non-aborted checkpoints are not terminal. */
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official GC of already-quarantined empty `.gjc-delete-*` receipts.
|
|
3
|
+
* Roots are operator operands — never hardcoded host paths.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import type { BigIntStats } from "node:fs";
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { exactUnlinkDirect, type NativeExactUnlinkResult } from "@gajae-code/natives";
|
|
11
|
+
|
|
12
|
+
export const EMPTY_DELETE_PREFIX = ".gjc-delete-";
|
|
13
|
+
const EMPTY_DELETE_RECEIPT_PATTERN =
|
|
14
|
+
/^\.gjc-delete-session-state-lock-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/u;
|
|
15
|
+
const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
16
|
+
|
|
17
|
+
export interface EmptyDeleteIdentity {
|
|
18
|
+
dev: bigint;
|
|
19
|
+
ino: bigint;
|
|
20
|
+
nlink: bigint;
|
|
21
|
+
size: bigint;
|
|
22
|
+
mtimeNs: bigint;
|
|
23
|
+
parentDev: bigint;
|
|
24
|
+
parentIno: bigint;
|
|
25
|
+
sha256: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EmptyDeleteGcRecord {
|
|
29
|
+
root: string;
|
|
30
|
+
path: string;
|
|
31
|
+
action: "would_remove" | "removed" | "kept" | "skipped";
|
|
32
|
+
reason: string;
|
|
33
|
+
identity?: EmptyDeleteIdentity;
|
|
34
|
+
/** A dry-run candidate is an observation, never deletion authority. */
|
|
35
|
+
observationOnly?: boolean;
|
|
36
|
+
/** Retained-object paths a native direct unlink left behind; operator-recoverable only there. */
|
|
37
|
+
retainedPaths?: { detached?: string; successor?: string; placeholder?: string; unknown?: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EmptyDeleteGcReport {
|
|
41
|
+
dry_run: boolean;
|
|
42
|
+
roots: string[];
|
|
43
|
+
records: EmptyDeleteGcRecord[];
|
|
44
|
+
would_remove: number;
|
|
45
|
+
removed: number;
|
|
46
|
+
kept: number;
|
|
47
|
+
skipped: number;
|
|
48
|
+
errors: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface EmptyDeleteGcOptions {
|
|
52
|
+
roots: string[];
|
|
53
|
+
prune: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface EmptyDeleteGcDependencies {
|
|
57
|
+
/** Internal seam for deterministic collect-to-prune race tests. */
|
|
58
|
+
collect?: (root: string) => Promise<EmptyDeleteGcRecord[]>;
|
|
59
|
+
exactUnlinkDirect?: typeof exactUnlinkDirect;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isUnsafeName(name: string): boolean {
|
|
63
|
+
return name.includes("/") || name.includes("\0") || name === "." || name === "..";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function identityOf(stat: BigIntStats): Omit<EmptyDeleteIdentity, "parentDev" | "parentIno" | "sha256"> {
|
|
67
|
+
return {
|
|
68
|
+
dev: stat.dev,
|
|
69
|
+
ino: stat.ino,
|
|
70
|
+
nlink: stat.nlink,
|
|
71
|
+
size: stat.size,
|
|
72
|
+
mtimeNs: stat.mtimeNs,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract the operator-recoverable retained paths a native direct unlink can leave behind.
|
|
78
|
+
* Any of them means debris still exists outside the receipt namespace, so the caller
|
|
79
|
+
* records them and fails the run instead of reporting a clean prune.
|
|
80
|
+
*/
|
|
81
|
+
function retainedPathsOf(
|
|
82
|
+
result: NativeExactUnlinkResult,
|
|
83
|
+
): { detached?: string; successor?: string; placeholder?: string; unknown?: string } | undefined {
|
|
84
|
+
if (
|
|
85
|
+
result.detachedPath === undefined &&
|
|
86
|
+
result.retainedSuccessorPath === undefined &&
|
|
87
|
+
result.retainedPlaceholderPath === undefined &&
|
|
88
|
+
result.retainedUnknownPath === undefined
|
|
89
|
+
)
|
|
90
|
+
return undefined;
|
|
91
|
+
return {
|
|
92
|
+
detached: result.detachedPath,
|
|
93
|
+
successor: result.retainedSuccessorPath,
|
|
94
|
+
placeholder: result.retainedPlaceholderPath,
|
|
95
|
+
unknown: result.retainedUnknownPath,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isEmptyDeleteReceiptName(name: string): boolean {
|
|
100
|
+
return EMPTY_DELETE_RECEIPT_PATTERN.test(name);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sameRootIdentity(left: BigIntStats, right: BigIntStats): boolean {
|
|
104
|
+
return left.isDirectory() && right.isDirectory() && left.dev === right.dev && left.ino === right.ino;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rootRaceRecord(root: string): EmptyDeleteGcRecord[] {
|
|
108
|
+
return [{ root, path: root, action: "skipped", reason: "root_race" }];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function collectEmptyDeleteReceipts(
|
|
112
|
+
root: string,
|
|
113
|
+
deps: { lstat?: (file: string, options: { bigint: true }) => Promise<BigIntStats> } = {},
|
|
114
|
+
): Promise<EmptyDeleteGcRecord[]> {
|
|
115
|
+
const lstatFile = deps.lstat ?? ((file: string) => fs.lstat(file, { bigint: true }));
|
|
116
|
+
const records: EmptyDeleteGcRecord[] = [];
|
|
117
|
+
const resolvedRoot = path.resolve(root);
|
|
118
|
+
let rootStat: BigIntStats;
|
|
119
|
+
try {
|
|
120
|
+
rootStat = await lstatFile(resolvedRoot, { bigint: true });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
123
|
+
return [{ root, path: root, action: "skipped", reason: "missing_root" }];
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
if (rootStat.isSymbolicLink()) {
|
|
128
|
+
return [{ root, path: root, action: "skipped", reason: "symlink_root" }];
|
|
129
|
+
}
|
|
130
|
+
if (!rootStat.isDirectory()) {
|
|
131
|
+
return [{ root, path: root, action: "skipped", reason: "not_directory" }];
|
|
132
|
+
}
|
|
133
|
+
let names: string[];
|
|
134
|
+
try {
|
|
135
|
+
names = await fs.readdir(resolvedRoot);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
138
|
+
return rootRaceRecord(root);
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
// Enumeration is the first operation that can observe a replacement root. Do not
|
|
143
|
+
// attribute any names from that enumeration until the original root identity is
|
|
144
|
+
// proved to still own the pathname.
|
|
145
|
+
try {
|
|
146
|
+
const enumeratedRoot = await lstatFile(resolvedRoot, { bigint: true });
|
|
147
|
+
if (!sameRootIdentity(rootStat, enumeratedRoot)) return rootRaceRecord(root);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return rootRaceRecord(root);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
for (const name of names) {
|
|
153
|
+
if (isUnsafeName(name) || !isEmptyDeleteReceiptName(name)) continue;
|
|
154
|
+
const file = path.join(resolvedRoot, name);
|
|
155
|
+
let stat: BigIntStats;
|
|
156
|
+
try {
|
|
157
|
+
stat = await lstatFile(file, { bigint: true });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// ENOENT is an ordinary race with concurrent cleanup, but silently dropping the
|
|
160
|
+
// entry would shrink the report without trace; record it as a raced candidate so
|
|
161
|
+
// discovery stays fail-closed. Any other stat failure is a hard discovery error.
|
|
162
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
163
|
+
records.push({ root, path: file, action: "skipped", reason: "raced" });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (stat.isSymbolicLink()) {
|
|
169
|
+
records.push({ root, path: file, action: "kept", reason: "symlink" });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!stat.isFile()) {
|
|
173
|
+
records.push({ root, path: file, action: "kept", reason: "not_regular" });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (stat.size !== 0n) {
|
|
177
|
+
records.push({ root, path: file, action: "kept", reason: "non_empty" });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (stat.nlink !== 1n) {
|
|
181
|
+
records.push({ root, path: file, action: "kept", reason: "nlink" });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
records.push({
|
|
185
|
+
root,
|
|
186
|
+
path: file,
|
|
187
|
+
action: "would_remove",
|
|
188
|
+
reason: "empty_delete_receipt",
|
|
189
|
+
identity: {
|
|
190
|
+
...identityOf(stat),
|
|
191
|
+
parentDev: rootStat.dev,
|
|
192
|
+
parentIno: rootStat.ino,
|
|
193
|
+
sha256: EMPTY_FILE_SHA256,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// The candidate lstat calls above are pathname operations. Revalidate once more
|
|
198
|
+
// before returning so a replaced root can never leak its external candidate paths
|
|
199
|
+
// (or their identities) into a GC report.
|
|
200
|
+
try {
|
|
201
|
+
const finalRoot = await lstatFile(resolvedRoot, { bigint: true });
|
|
202
|
+
if (!sameRootIdentity(rootStat, finalRoot)) return rootRaceRecord(root);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return rootRaceRecord(root);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
return records;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function runEmptyDeleteGc(
|
|
211
|
+
options: EmptyDeleteGcOptions,
|
|
212
|
+
deps: EmptyDeleteGcDependencies = {},
|
|
213
|
+
): Promise<EmptyDeleteGcReport> {
|
|
214
|
+
const report: EmptyDeleteGcReport = {
|
|
215
|
+
dry_run: !options.prune,
|
|
216
|
+
roots: options.roots,
|
|
217
|
+
records: [],
|
|
218
|
+
would_remove: 0,
|
|
219
|
+
removed: 0,
|
|
220
|
+
kept: 0,
|
|
221
|
+
skipped: 0,
|
|
222
|
+
errors: [],
|
|
223
|
+
};
|
|
224
|
+
const collect = deps.collect ?? collectEmptyDeleteReceipts;
|
|
225
|
+
const unlink = deps.exactUnlinkDirect ?? exactUnlinkDirect;
|
|
226
|
+
for (const root of options.roots) {
|
|
227
|
+
let records: EmptyDeleteGcRecord[];
|
|
228
|
+
try {
|
|
229
|
+
records = await collect(root);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
report.errors.push(`${root}: ${error instanceof Error ? error.message : String(error)}`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
for (const record of records) {
|
|
235
|
+
if (record.action === "would_remove" && !options.prune) record.observationOnly = true;
|
|
236
|
+
if (
|
|
237
|
+
record.action === "skipped" &&
|
|
238
|
+
(record.reason === "raced" ||
|
|
239
|
+
record.reason === "root_race" ||
|
|
240
|
+
(record.path === root &&
|
|
241
|
+
(record.reason === "missing_root" ||
|
|
242
|
+
record.reason === "symlink_root" ||
|
|
243
|
+
record.reason === "not_directory")))
|
|
244
|
+
) {
|
|
245
|
+
report.errors.push(`${record.path}: ${record.reason}`);
|
|
246
|
+
}
|
|
247
|
+
if (record.action === "would_remove" && options.prune) {
|
|
248
|
+
try {
|
|
249
|
+
if (!record.identity) {
|
|
250
|
+
record.action = "kept";
|
|
251
|
+
record.reason = "identity_missing";
|
|
252
|
+
} else {
|
|
253
|
+
const result = unlink(record.path, {
|
|
254
|
+
...record.identity,
|
|
255
|
+
quarantineName: `.gjc-delete-gc-${randomUUID()}.json`,
|
|
256
|
+
});
|
|
257
|
+
const retainedPaths = retainedPathsOf(result);
|
|
258
|
+
if (result.ok) {
|
|
259
|
+
record.action = "removed";
|
|
260
|
+
} else if (result.code === "not_found") {
|
|
261
|
+
record.action = "skipped";
|
|
262
|
+
record.reason = "gone";
|
|
263
|
+
} else if (result.code === "identity_mismatch") {
|
|
264
|
+
record.action = "kept";
|
|
265
|
+
record.reason = "identity_drift";
|
|
266
|
+
} else {
|
|
267
|
+
record.action = "kept";
|
|
268
|
+
record.reason = `unlink_failed:${result.code ?? "unknown"}`;
|
|
269
|
+
report.errors.push(`${record.path}: ${record.reason}`);
|
|
270
|
+
}
|
|
271
|
+
// Objects the native layer retained (post-detach failure or a successor at
|
|
272
|
+
// the private quarantine name) are recoverable only at those paths; surface
|
|
273
|
+
// them and fail the run instead of silently stranding debris.
|
|
274
|
+
if (retainedPaths !== undefined) {
|
|
275
|
+
record.retainedPaths = retainedPaths;
|
|
276
|
+
record.reason = result.ok ? `retained:${result.code ?? "cleanup_pending"}` : record.reason;
|
|
277
|
+
report.errors.push(
|
|
278
|
+
`${record.path}: retained debris requires operator recovery: ${JSON.stringify(retainedPaths)}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
284
|
+
record.action = "skipped";
|
|
285
|
+
record.reason = "gone";
|
|
286
|
+
} else {
|
|
287
|
+
record.action = "kept";
|
|
288
|
+
record.reason = `unlink_failed:${error instanceof Error ? error.message : String(error)}`;
|
|
289
|
+
report.errors.push(`${record.path}: ${record.reason}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
report.records.push(record);
|
|
294
|
+
if (record.action === "would_remove") report.would_remove += 1;
|
|
295
|
+
else if (record.action === "removed") report.removed += 1;
|
|
296
|
+
else if (record.action === "skipped") report.skipped += 1;
|
|
297
|
+
else report.kept += 1;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return report;
|
|
301
|
+
}
|
|
@@ -88,6 +88,35 @@ export function buildGcReportText(report: GcReport): string {
|
|
|
88
88
|
lines.push("");
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
if (report.empty_delete_receipts) {
|
|
92
|
+
const empty = report.empty_delete_receipts;
|
|
93
|
+
lines.push(`Empty .gjc-delete receipts (${empty.records.length})`);
|
|
94
|
+
if (empty.records.length === 0) {
|
|
95
|
+
lines.push(" (none)");
|
|
96
|
+
} else {
|
|
97
|
+
for (const record of empty.records) {
|
|
98
|
+
const action = record.action === "would_remove" ? "would remove" : record.action;
|
|
99
|
+
lines.push(` [${action}] ${record.path} :: ${record.reason}`);
|
|
100
|
+
if (record.retainedPaths) {
|
|
101
|
+
const retained = [
|
|
102
|
+
["detached", record.retainedPaths.detached],
|
|
103
|
+
["successor", record.retainedPaths.successor],
|
|
104
|
+
["placeholder", record.retainedPaths.placeholder],
|
|
105
|
+
["unknown", record.retainedPaths.unknown],
|
|
106
|
+
].filter((entry): entry is [string, string] => entry[1] !== undefined);
|
|
107
|
+
if (retained.length > 0)
|
|
108
|
+
lines.push(` retained: ${retained.map(([kind, p]) => `${kind}=${p}`).join(" ")}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const error of empty.errors) lines.push(` [error] ${error}`);
|
|
113
|
+
lines.push(
|
|
114
|
+
` Summary: roots=${empty.roots.length} would_remove=${empty.would_remove} removed=${empty.removed} ` +
|
|
115
|
+
`kept=${empty.kept} skipped=${empty.skipped} errors=${empty.errors.length}`,
|
|
116
|
+
);
|
|
117
|
+
lines.push("");
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
if (report.warnings.length > 0) {
|
|
92
121
|
lines.push(`Warnings (${report.warnings.length})`);
|
|
93
122
|
for (const warning of report.warnings) lines.push(` [${warning.store}/${warning.scope}] ${warning.message}`);
|