@ricsam/r5d-worker 0.0.120 → 0.0.122
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/dist/cjs/main.cjs +124 -44
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-checkout-garbage.cjs +190 -0
- package/dist/cjs/project-worktrees.cjs +9 -2
- package/dist/cjs/recovery-store.cjs +13 -0
- package/dist/cjs/workspace-git-sync.cjs +3 -1
- package/dist/mjs/main.mjs +124 -44
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-checkout-garbage.mjs +150 -0
- package/dist/mjs/project-worktrees.mjs +9 -2
- package/dist/mjs/recovery-store.mjs +13 -0
- package/dist/mjs/workspace-git-sync.mjs +3 -1
- package/dist/types/main.d.ts +9 -4
- package/dist/types/project-checkout-garbage.d.ts +69 -0
- package/dist/types/project-worktrees.d.ts +6 -0
- package/dist/types/recovery-store.d.ts +8 -0
- package/package.json +1 -1
package/dist/cjs/main.cjs
CHANGED
|
@@ -74,6 +74,7 @@ var import_bun_sqlite = require("bun:sqlite");
|
|
|
74
74
|
var import_cli_update = require("./cli-update.cjs");
|
|
75
75
|
var import_git_process_environment = require("./git-process-environment.cjs");
|
|
76
76
|
var import_process_tree = require("./process-tree.cjs");
|
|
77
|
+
var import_project_checkout_garbage = require("./project-checkout-garbage.cjs");
|
|
77
78
|
var import_pty_output_coalescer = require("./pty-output-coalescer.cjs");
|
|
78
79
|
var import_port_forward_client = require("./port-forward-client.cjs");
|
|
79
80
|
var import_registry_auth = require("./registry-auth.cjs");
|
|
@@ -274,6 +275,15 @@ let workerAdmissionGeneration = 0;
|
|
|
274
275
|
const workspaceMutationGate = new import_workspace_mutation_gate.WorkspaceMutationGate();
|
|
275
276
|
let workspaceSyncQueue = Promise.resolve();
|
|
276
277
|
let startupProjectSnapshotRecoveryCompleted = false;
|
|
278
|
+
const checkoutGarbageCollectors = /* @__PURE__ */ new Map();
|
|
279
|
+
function checkoutGarbageCollectorFor(projectsRoot) {
|
|
280
|
+
let collector = checkoutGarbageCollectors.get(projectsRoot);
|
|
281
|
+
if (!collector) {
|
|
282
|
+
collector = new import_project_checkout_garbage.ProjectCheckoutGarbageCollector({ projectsRoot });
|
|
283
|
+
checkoutGarbageCollectors.set(projectsRoot, collector);
|
|
284
|
+
}
|
|
285
|
+
return collector;
|
|
286
|
+
}
|
|
277
287
|
const workspaceSyncSingleFlight = {
|
|
278
288
|
runExclusive(operation) {
|
|
279
289
|
const queued = workspaceMutationGate.runSync(operation);
|
|
@@ -940,24 +950,43 @@ function tryGit(args, options = {}) {
|
|
|
940
950
|
return false;
|
|
941
951
|
}
|
|
942
952
|
}
|
|
953
|
+
class GitTransportTimeoutError extends Error {
|
|
954
|
+
}
|
|
943
955
|
async function runGitAsync(args, options = {}) {
|
|
944
956
|
const command = workerGitCommand.commandArgs(args);
|
|
957
|
+
const bounded = options.timeoutMs !== void 0;
|
|
945
958
|
const subprocess = Bun.spawn(command, {
|
|
946
959
|
cwd: options.cwd,
|
|
947
960
|
stdin: "ignore",
|
|
948
961
|
stdout: "pipe",
|
|
949
962
|
stderr: "pipe",
|
|
950
|
-
env: (0, import_git_process_environment.workerGitProcessEnvironment)()
|
|
963
|
+
env: (0, import_git_process_environment.workerGitProcessEnvironment)(),
|
|
964
|
+
...bounded ? { detached: true } : {}
|
|
951
965
|
});
|
|
952
|
-
const
|
|
966
|
+
const completion = Promise.all([
|
|
953
967
|
new Response(subprocess.stdout).text(),
|
|
954
968
|
new Response(subprocess.stderr).text(),
|
|
955
969
|
subprocess.exited
|
|
956
|
-
]);
|
|
957
|
-
|
|
958
|
-
|
|
970
|
+
]).then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode }));
|
|
971
|
+
let timer;
|
|
972
|
+
const timedOut = bounded ? new Promise((_resolve, reject) => {
|
|
973
|
+
timer = setTimeout(() => reject(new GitTransportTimeoutError(`git ${args.join(" ")} timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
|
|
974
|
+
}) : null;
|
|
975
|
+
try {
|
|
976
|
+
const { stdout, stderr, exitCode } = timedOut ? await Promise.race([completion, timedOut]) : await completion;
|
|
977
|
+
if (exitCode !== 0) {
|
|
978
|
+
throw new Error(`git ${args.join(" ")} failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`);
|
|
979
|
+
}
|
|
980
|
+
return stdout.trim();
|
|
981
|
+
} catch (error) {
|
|
982
|
+
if (error instanceof GitTransportTimeoutError) {
|
|
983
|
+
void (0, import_process_tree.terminateProcessTree)(subprocess, { graceMs: 0 }).catch(() => void 0);
|
|
984
|
+
void completion.catch(() => void 0);
|
|
985
|
+
}
|
|
986
|
+
throw error;
|
|
987
|
+
} finally {
|
|
988
|
+
if (timer) clearTimeout(timer);
|
|
959
989
|
}
|
|
960
|
-
return stdout.trim();
|
|
961
990
|
}
|
|
962
991
|
async function tryGitAsync(args, options = {}) {
|
|
963
992
|
try {
|
|
@@ -1818,14 +1847,13 @@ const workerGitSecurityTestHarness = {
|
|
|
1818
1847
|
executeEditFileOperation,
|
|
1819
1848
|
terminateCredentialBearingChildren,
|
|
1820
1849
|
terminateCredentialBearingChildrenWithRetention,
|
|
1821
|
-
async commitCredentialGeneration(prepared, credential,
|
|
1822
|
-
return await commitCredentialGeneration(
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
);
|
|
1850
|
+
async commitCredentialGeneration(prepared, credential, options) {
|
|
1851
|
+
return await commitCredentialGeneration(prepared, credential, {
|
|
1852
|
+
secretMaterialChanged: options.secretMaterialChanged,
|
|
1853
|
+
terminatePreviousGeneration: async () => await terminateCredentialBearingChildren(options.children),
|
|
1854
|
+
beforeMutation: options.beforeMutation,
|
|
1855
|
+
previousGenerationFenced: options.previousGenerationFenced ?? false
|
|
1856
|
+
});
|
|
1829
1857
|
},
|
|
1830
1858
|
credentialGenerationReceiptPath,
|
|
1831
1859
|
credentialAuthorityLockPath,
|
|
@@ -1863,14 +1891,14 @@ function installGitHubCredentialGeneration(credential) {
|
|
|
1863
1891
|
`);
|
|
1864
1892
|
}
|
|
1865
1893
|
}
|
|
1866
|
-
async function commitCredentialGeneration(prepared, credential,
|
|
1894
|
+
async function commitCredentialGeneration(prepared, credential, options) {
|
|
1867
1895
|
const changed = prepared.changed;
|
|
1868
1896
|
try {
|
|
1869
|
-
if (changed && !previousGenerationFenced) {
|
|
1897
|
+
if (changed && options.secretMaterialChanged && !options.previousGenerationFenced) {
|
|
1870
1898
|
githubCredential = null;
|
|
1871
|
-
await terminatePreviousGeneration();
|
|
1899
|
+
await options.terminatePreviousGeneration();
|
|
1872
1900
|
}
|
|
1873
|
-
prepared.commit(beforeMutation);
|
|
1901
|
+
prepared.commit(options.beforeMutation);
|
|
1874
1902
|
} catch (error) {
|
|
1875
1903
|
prepared.discard();
|
|
1876
1904
|
throw error instanceof import_registry_auth.RegistryAuthConfigurationError ? error : new import_registry_auth.RegistryAuthConfigurationError(error);
|
|
@@ -3686,6 +3714,12 @@ async function startWorker(options, projectRuntime = {
|
|
|
3686
3714
|
projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
|
|
3687
3715
|
const projectWorkspaceStateStore = initializedWorkspaceState.store;
|
|
3688
3716
|
let projectWorkspaceState = initializedWorkspaceState.store.read();
|
|
3717
|
+
const checkoutGarbageCollector = checkoutGarbageCollectorFor(projectsRoot);
|
|
3718
|
+
const staleStagedCheckouts = checkoutGarbageCollector.sweep();
|
|
3719
|
+
if (staleStagedCheckouts > 0) {
|
|
3720
|
+
process.stderr.write(`[r5d-worker] collecting ${staleStagedCheckouts} deleted checkout(s) left from an earlier run
|
|
3721
|
+
`);
|
|
3722
|
+
}
|
|
3689
3723
|
const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
|
|
3690
3724
|
const pendingCheckouts = /* @__PURE__ */ new Map();
|
|
3691
3725
|
const lastObservedProjectHeads = /* @__PURE__ */ new Map();
|
|
@@ -3800,6 +3834,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
3800
3834
|
`
|
|
3801
3835
|
);
|
|
3802
3836
|
}
|
|
3837
|
+
if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
|
|
3803
3838
|
}
|
|
3804
3839
|
const planSourcePath = import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/"));
|
|
3805
3840
|
import_node_fs.default.rmSync(planSourcePath, { recursive: true, force: true });
|
|
@@ -3883,6 +3918,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
3883
3918
|
`
|
|
3884
3919
|
);
|
|
3885
3920
|
}
|
|
3921
|
+
if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
|
|
3886
3922
|
import_node_fs.default.rmSync(import_node_path.default.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
|
|
3887
3923
|
readyProjectIds.delete(project.projectId);
|
|
3888
3924
|
reconciledProjectConfigFingerprints.delete(project.projectId);
|
|
@@ -4319,33 +4355,56 @@ async function startWorker(options, projectRuntime = {
|
|
|
4319
4355
|
return reconciledProjectIds;
|
|
4320
4356
|
};
|
|
4321
4357
|
const ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
|
|
4358
|
+
const ORIGIN_DIVERGENCE_FETCH_TIMEOUT_MS = 3e4;
|
|
4359
|
+
const ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS = 9e4;
|
|
4322
4360
|
const lastOriginDivergenceFetchMs = /* @__PURE__ */ new Map();
|
|
4323
|
-
const collectAheadOfOriginBranches = async (refetchOriginProjectIds) => {
|
|
4361
|
+
const collectAheadOfOriginBranches = async (refetchOriginProjectIds, onProgress) => {
|
|
4324
4362
|
const aheadBranches = [];
|
|
4325
4363
|
for (const projectId of [...lastOriginDivergenceFetchMs.keys()]) {
|
|
4326
4364
|
if (!projectConfigById.has(projectId)) lastOriginDivergenceFetchMs.delete(projectId);
|
|
4327
4365
|
}
|
|
4328
|
-
|
|
4366
|
+
const passStartedAtMs = Date.now();
|
|
4367
|
+
let budgetSpentReported = false;
|
|
4368
|
+
const projects = [...projectConfigById.values()];
|
|
4369
|
+
for (const [projectIndex, project] of projects.entries()) {
|
|
4329
4370
|
if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
|
|
4371
|
+
onProgress?.({
|
|
4372
|
+
detail: `Checking origin divergence for ${project.projectPath}`,
|
|
4373
|
+
completedItems: projectIndex,
|
|
4374
|
+
totalItems: projects.length
|
|
4375
|
+
});
|
|
4330
4376
|
const connection = projectConnection(project);
|
|
4331
4377
|
const primaryPath = configuredProjectBranchPath(projectsRoot, project, primaryProjectBranch(project));
|
|
4332
4378
|
const lastFetchedAtMs = lastOriginDivergenceFetchMs.get(project.projectId);
|
|
4333
4379
|
if (refetchOriginProjectIds.has(project.projectId) || lastFetchedAtMs === void 0 || Date.now() - lastFetchedAtMs >= ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS) {
|
|
4334
|
-
if (
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
],
|
|
4343
|
-
{ cwd: primaryPath }
|
|
4344
|
-
)) {
|
|
4345
|
-
lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
|
|
4380
|
+
if (Date.now() - passStartedAtMs >= ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS) {
|
|
4381
|
+
if (!budgetSpentReported) {
|
|
4382
|
+
budgetSpentReported = true;
|
|
4383
|
+
process.stderr.write(
|
|
4384
|
+
`[r5d-worker] origin divergence refresh budget of ${ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS}ms is spent; remaining origins keep their previous comparison until the next refresh
|
|
4385
|
+
`
|
|
4386
|
+
);
|
|
4387
|
+
}
|
|
4346
4388
|
} else {
|
|
4347
|
-
|
|
4348
|
-
|
|
4389
|
+
try {
|
|
4390
|
+
await runGitAsync(
|
|
4391
|
+
[
|
|
4392
|
+
...(0, import_git_process_environment.gitTransportSecurityArgs)(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
|
|
4393
|
+
"fetch",
|
|
4394
|
+
"--no-recurse-submodules",
|
|
4395
|
+
"--prune",
|
|
4396
|
+
"origin",
|
|
4397
|
+
"+refs/heads/*:refs/remotes/origin/*"
|
|
4398
|
+
],
|
|
4399
|
+
{ cwd: primaryPath, timeoutMs: ORIGIN_DIVERGENCE_FETCH_TIMEOUT_MS }
|
|
4400
|
+
);
|
|
4401
|
+
lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
|
|
4402
|
+
} catch (error) {
|
|
4403
|
+
process.stderr.write(
|
|
4404
|
+
`[r5d-worker] could not refresh origin divergence for ${project.projectPath}: ${error instanceof Error ? error.message : String(error)}
|
|
4405
|
+
`
|
|
4406
|
+
);
|
|
4407
|
+
}
|
|
4349
4408
|
}
|
|
4350
4409
|
}
|
|
4351
4410
|
for (const branch of project.branches) {
|
|
@@ -5378,13 +5437,11 @@ async function startWorker(options, projectRuntime = {
|
|
|
5378
5437
|
} catch (error) {
|
|
5379
5438
|
throw error instanceof import_registry_auth.RegistryAuthConfigurationError ? error : new import_registry_auth.RegistryAuthConfigurationError(error);
|
|
5380
5439
|
}
|
|
5381
|
-
await commitCredentialGeneration(
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
credentialPublicationPreauthorized
|
|
5387
|
-
);
|
|
5440
|
+
await commitCredentialGeneration(preparedAuthGeneration, message.githubCredential, {
|
|
5441
|
+
secretMaterialChanged: credentialTransitionPhase !== "current",
|
|
5442
|
+
terminatePreviousGeneration: terminateActiveCredentialBearingChildren,
|
|
5443
|
+
previousGenerationFenced: credentialPublicationPreauthorized
|
|
5444
|
+
});
|
|
5388
5445
|
if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
|
|
5389
5446
|
throw new Error("Workspace configuration was superseded by a newer server generation");
|
|
5390
5447
|
}
|
|
@@ -5519,7 +5576,15 @@ async function startWorker(options, projectRuntime = {
|
|
|
5519
5576
|
}
|
|
5520
5577
|
};
|
|
5521
5578
|
const hasDurableBranchDeletion = projectWorkspaceState.tombstones.some((tombstone) => tombstone.kind === "branch");
|
|
5522
|
-
|
|
5579
|
+
sendLifecycleProgress({
|
|
5580
|
+
phase: "configuring",
|
|
5581
|
+
operationId: configurationOperationId,
|
|
5582
|
+
detail: `Synchronizing the workspace after configuring ${message.projects.length} project(s)`,
|
|
5583
|
+
completedItems: message.projects.length,
|
|
5584
|
+
totalItems: message.projects.length
|
|
5585
|
+
});
|
|
5586
|
+
const connectSyncStartedAtMs = Date.now();
|
|
5587
|
+
const syncResult = await performWorkspaceSync({
|
|
5523
5588
|
attemptId: crypto.randomUUID(),
|
|
5524
5589
|
trigger: { type: "connect" },
|
|
5525
5590
|
confirmedLargeDiff: hasDurableBranchDeletion,
|
|
@@ -5527,6 +5592,11 @@ async function startWorker(options, projectRuntime = {
|
|
|
5527
5592
|
resetToCanonical: workspaceConfigurationResetToCanonicalIsAllowed(message.resetToCanonical, incidentDeferral.incidentId),
|
|
5528
5593
|
assertStillAdmitted: assertConfigurationSyncStillAdmitted
|
|
5529
5594
|
});
|
|
5595
|
+
const connectSyncMs = Date.now() - connectSyncStartedAtMs;
|
|
5596
|
+
const result = syncResult.telemetry ? syncResult : {
|
|
5597
|
+
...syncResult,
|
|
5598
|
+
telemetry: { totalMs: connectSyncMs, queueMs: 0, prepareMs: lastWorkspaceSyncMirrorObservationMs, synchronizeMs: connectSyncMs }
|
|
5599
|
+
};
|
|
5530
5600
|
assertConfigurationSyncStillAdmitted();
|
|
5531
5601
|
if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
|
|
5532
5602
|
throw new Error("Workspace configuration was superseded by a newer server generation");
|
|
@@ -5559,7 +5629,14 @@ async function startWorker(options, projectRuntime = {
|
|
|
5559
5629
|
const pending = [...pendingCheckouts.values()].sort(
|
|
5560
5630
|
(left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
|
|
5561
5631
|
);
|
|
5562
|
-
return {
|
|
5632
|
+
return {
|
|
5633
|
+
result,
|
|
5634
|
+
pending,
|
|
5635
|
+
aheadOfOriginBranches: await collectAheadOfOriginBranches(
|
|
5636
|
+
reconciledProjectIds,
|
|
5637
|
+
(progress) => sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress })
|
|
5638
|
+
)
|
|
5639
|
+
};
|
|
5563
5640
|
});
|
|
5564
5641
|
} catch (error) {
|
|
5565
5642
|
if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
|
|
@@ -5776,6 +5853,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
5776
5853
|
workspaceIncidentConfigDeferralV1: true,
|
|
5777
5854
|
workspaceConfigResetToCanonicalV1: true,
|
|
5778
5855
|
projectBranchDeletionFastAckV1: true,
|
|
5856
|
+
projectBranchDeletionStagedRemovalV1: true,
|
|
5779
5857
|
projectMirrorLeaseV1: true,
|
|
5780
5858
|
projectMirrorRefsTokensV1: true,
|
|
5781
5859
|
projectBranchWorkingTreeModeV1: true
|
|
@@ -5877,7 +5955,9 @@ async function startWorker(options, projectRuntime = {
|
|
|
5877
5955
|
if (admission !== "new") {
|
|
5878
5956
|
const response = recoveryStore.response(operationRequestId);
|
|
5879
5957
|
if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
|
|
5880
|
-
|
|
5958
|
+
if (!(admission === "unknown" && message.type === "delete_project_branch" && recoveryStore.readmitUnknownBranchDeletion(operationRequestId))) {
|
|
5959
|
+
return;
|
|
5960
|
+
}
|
|
5881
5961
|
}
|
|
5882
5962
|
}
|
|
5883
5963
|
const messageAdmissionGeneration = workerAdmissionGeneration;
|
package/dist/cjs/package.json
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var project_checkout_garbage_exports = {};
|
|
30
|
+
__export(project_checkout_garbage_exports, {
|
|
31
|
+
PROJECT_DELETED_CHECKOUTS_DIRECTORY: () => PROJECT_DELETED_CHECKOUTS_DIRECTORY,
|
|
32
|
+
ProjectCheckoutGarbageCollector: () => ProjectCheckoutGarbageCollector,
|
|
33
|
+
discoverStagedProjectCheckouts: () => discoverStagedProjectCheckouts,
|
|
34
|
+
isStagedProjectCheckoutPath: () => isStagedProjectCheckoutPath,
|
|
35
|
+
ownedStagedPath: () => ownedStagedPath,
|
|
36
|
+
removeStagedProjectCheckout: () => removeStagedProjectCheckout,
|
|
37
|
+
stageProjectCheckoutForDeletion: () => stageProjectCheckoutForDeletion
|
|
38
|
+
});
|
|
39
|
+
module.exports = __toCommonJS(project_checkout_garbage_exports);
|
|
40
|
+
var import_node_crypto = require("node:crypto");
|
|
41
|
+
var import_node_fs = __toESM(require("node:fs"), 1);
|
|
42
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
43
|
+
const PROJECT_DELETED_CHECKOUTS_DIRECTORY = ".r5d-deleted";
|
|
44
|
+
function isRealDirectory(candidate) {
|
|
45
|
+
try {
|
|
46
|
+
return import_node_fs.default.lstatSync(candidate).isDirectory();
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function stageProjectCheckoutForDeletion(projectRoot, branchName, checkoutPath) {
|
|
52
|
+
const garbageRoot = import_node_path.default.join(projectRoot, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
|
|
53
|
+
if (!isRealDirectory(checkoutPath)) throw new Error(`Cannot stage ${checkoutPath} for deletion: not a directory`);
|
|
54
|
+
import_node_fs.default.mkdirSync(garbageRoot, { recursive: true });
|
|
55
|
+
if (!isRealDirectory(garbageRoot)) throw new Error(`Cannot stage ${checkoutPath} for deletion: ${garbageRoot} is not a directory`);
|
|
56
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
57
|
+
const target = import_node_path.default.join(garbageRoot, `${branchName.replace(/\//g, "__")}-${timestamp}-${(0, import_node_crypto.randomUUID)().slice(0, 8)}`);
|
|
58
|
+
import_node_fs.default.renameSync(checkoutPath, target);
|
|
59
|
+
return target;
|
|
60
|
+
}
|
|
61
|
+
function isStagedProjectCheckoutPath(projectsRoot, candidate) {
|
|
62
|
+
const relative = import_node_path.default.relative(import_node_path.default.resolve(projectsRoot), import_node_path.default.resolve(candidate));
|
|
63
|
+
if (!relative || relative.startsWith("..") || import_node_path.default.isAbsolute(relative)) return false;
|
|
64
|
+
const segments = relative.split(import_node_path.default.sep);
|
|
65
|
+
return segments.length === 4 && segments[2] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && segments[3] !== "" && !segments[3].startsWith(".");
|
|
66
|
+
}
|
|
67
|
+
function ownedStagedPath(projectsRoot, candidate) {
|
|
68
|
+
const root = import_node_path.default.resolve(projectsRoot);
|
|
69
|
+
const resolved = import_node_path.default.resolve(candidate);
|
|
70
|
+
if (!isStagedProjectCheckoutPath(root, resolved)) return null;
|
|
71
|
+
let current = root;
|
|
72
|
+
for (const segment of import_node_path.default.relative(root, import_node_path.default.dirname(resolved)).split(import_node_path.default.sep)) {
|
|
73
|
+
current = import_node_path.default.join(current, segment);
|
|
74
|
+
if (!isRealDirectory(current)) return null;
|
|
75
|
+
}
|
|
76
|
+
let stat;
|
|
77
|
+
try {
|
|
78
|
+
stat = import_node_fs.default.lstatSync(resolved);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (stat.isSymbolicLink()) return { kind: "link" };
|
|
83
|
+
return stat.isDirectory() ? { kind: "directory" } : null;
|
|
84
|
+
}
|
|
85
|
+
function realDirectoryEntries(directory) {
|
|
86
|
+
if (!isRealDirectory(directory)) return [];
|
|
87
|
+
try {
|
|
88
|
+
return import_node_fs.default.readdirSync(directory, { withFileTypes: true }).map((entry) => entry.name).sort();
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function discoverStagedProjectCheckouts(projectsRoot) {
|
|
94
|
+
const root = import_node_path.default.resolve(projectsRoot);
|
|
95
|
+
const staged = [];
|
|
96
|
+
for (const namespace of realDirectoryEntries(root)) {
|
|
97
|
+
if (namespace.startsWith(".")) continue;
|
|
98
|
+
for (const project of realDirectoryEntries(import_node_path.default.join(root, namespace))) {
|
|
99
|
+
if (project.startsWith(".")) continue;
|
|
100
|
+
const garbageRoot = import_node_path.default.join(root, namespace, project, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
|
|
101
|
+
for (const entry of realDirectoryEntries(garbageRoot)) {
|
|
102
|
+
const candidate = import_node_path.default.join(garbageRoot, entry);
|
|
103
|
+
if (ownedStagedPath(root, candidate)) staged.push(candidate);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return staged;
|
|
108
|
+
}
|
|
109
|
+
async function removeStagedProjectCheckout(stagedPath) {
|
|
110
|
+
if (import_node_fs.default.lstatSync(stagedPath).isSymbolicLink()) {
|
|
111
|
+
import_node_fs.default.unlinkSync(stagedPath);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (process.platform === "win32") {
|
|
115
|
+
await import_node_fs.default.promises.rm(stagedPath, { recursive: true, force: true });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const command = ["rm", "-rf", "--", stagedPath];
|
|
119
|
+
if (Bun.which("nice")) command.unshift("nice", "-n", "19");
|
|
120
|
+
if (process.platform === "linux" && Bun.which("ionice")) command.unshift("ionice", "-c", "3");
|
|
121
|
+
const child = Bun.spawn(command, { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
|
|
122
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
123
|
+
if (exitCode !== 0) throw new Error(`rm exited ${exitCode}: ${stderr.trim()}`);
|
|
124
|
+
if (import_node_fs.default.existsSync(stagedPath)) throw new Error("staged checkout still exists after removal");
|
|
125
|
+
}
|
|
126
|
+
class ProjectCheckoutGarbageCollector {
|
|
127
|
+
projectsRoot;
|
|
128
|
+
remove;
|
|
129
|
+
log;
|
|
130
|
+
queued = /* @__PURE__ */ new Set();
|
|
131
|
+
tail = Promise.resolve();
|
|
132
|
+
collectedCount = 0;
|
|
133
|
+
constructor(options) {
|
|
134
|
+
this.projectsRoot = import_node_path.default.resolve(options.projectsRoot);
|
|
135
|
+
this.remove = options.remove ?? removeStagedProjectCheckout;
|
|
136
|
+
this.log = options.log ?? ((message) => process.stderr.write(`${message}
|
|
137
|
+
`));
|
|
138
|
+
}
|
|
139
|
+
/** Number of removals that have completed successfully. */
|
|
140
|
+
get collected() {
|
|
141
|
+
return this.collectedCount;
|
|
142
|
+
}
|
|
143
|
+
/** Resolves once everything queued so far has been attempted. */
|
|
144
|
+
get idle() {
|
|
145
|
+
return this.tail;
|
|
146
|
+
}
|
|
147
|
+
enqueue(stagedPath) {
|
|
148
|
+
const resolved = import_node_path.default.resolve(stagedPath);
|
|
149
|
+
if (!ownedStagedPath(this.projectsRoot, resolved)) {
|
|
150
|
+
throw new Error(`Refusing to collect ${stagedPath}: not a staged checkout under ${this.projectsRoot}`);
|
|
151
|
+
}
|
|
152
|
+
if (this.queued.has(resolved)) return;
|
|
153
|
+
this.queued.add(resolved);
|
|
154
|
+
this.tail = this.tail.then(async () => {
|
|
155
|
+
try {
|
|
156
|
+
const owned = ownedStagedPath(this.projectsRoot, resolved);
|
|
157
|
+
if (owned) {
|
|
158
|
+
const startedAt = performance.now();
|
|
159
|
+
await this.remove(resolved);
|
|
160
|
+
this.collectedCount += 1;
|
|
161
|
+
this.log(`[r5d-worker] collected deleted checkout ${resolved} in ${Math.round(performance.now() - startedAt)}ms`);
|
|
162
|
+
} else if (import_node_fs.default.existsSync(resolved) || isRealDirectory(import_node_path.default.dirname(resolved))) {
|
|
163
|
+
this.log(`[r5d-worker] skipped deleted checkout ${resolved}: no longer an owned staged checkout`);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
this.log(
|
|
167
|
+
`[r5d-worker] deleted checkout ${resolved} could not be collected yet: ${error instanceof Error ? error.message : String(error)}`
|
|
168
|
+
);
|
|
169
|
+
} finally {
|
|
170
|
+
this.queued.delete(resolved);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** Enqueue every staged checkout left behind by an earlier process. */
|
|
175
|
+
sweep() {
|
|
176
|
+
const staged = discoverStagedProjectCheckouts(this.projectsRoot);
|
|
177
|
+
for (const stagedPath of staged) this.enqueue(stagedPath);
|
|
178
|
+
return staged.length;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
182
|
+
0 && (module.exports = {
|
|
183
|
+
PROJECT_DELETED_CHECKOUTS_DIRECTORY,
|
|
184
|
+
ProjectCheckoutGarbageCollector,
|
|
185
|
+
discoverStagedProjectCheckouts,
|
|
186
|
+
isStagedProjectCheckoutPath,
|
|
187
|
+
ownedStagedPath,
|
|
188
|
+
removeStagedProjectCheckout,
|
|
189
|
+
stageProjectCheckoutForDeletion
|
|
190
|
+
});
|
|
@@ -61,6 +61,7 @@ var import_node_os = __toESM(require("node:os"), 1);
|
|
|
61
61
|
var import_node_path = __toESM(require("node:path"), 1);
|
|
62
62
|
var import_git_process_environment = require("./git-process-environment.cjs");
|
|
63
63
|
var import_managed_paths = require("./managed-paths.cjs");
|
|
64
|
+
var import_project_checkout_garbage = require("./project-checkout-garbage.cjs");
|
|
64
65
|
var import_project_mirror_refs_token = require("./project-mirror-refs-token.cjs");
|
|
65
66
|
var import_working_tree_mirror = require("./working-tree-mirror.cjs");
|
|
66
67
|
const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
|
|
@@ -1340,6 +1341,7 @@ function deleteLinkedProjectBranch(input) {
|
|
|
1340
1341
|
const primaryCommonDir = commonGitDirectory(primaryPath);
|
|
1341
1342
|
if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
|
|
1342
1343
|
let movedAsidePath;
|
|
1344
|
+
let stagedForCollectionPath;
|
|
1343
1345
|
if (import_node_fs.default.existsSync(checkoutPath)) {
|
|
1344
1346
|
const checkoutCommonDir = import_node_fs.default.lstatSync(checkoutPath).isDirectory() ? commonGitDirectory(checkoutPath) : null;
|
|
1345
1347
|
if (checkoutCommonDir !== primaryCommonDir) {
|
|
@@ -1349,7 +1351,8 @@ function deleteLinkedProjectBranch(input) {
|
|
|
1349
1351
|
if (projectWorktreeOperationInProgress(checkoutPath)) {
|
|
1350
1352
|
throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
|
|
1351
1353
|
}
|
|
1352
|
-
|
|
1354
|
+
stagedForCollectionPath = (0, import_project_checkout_garbage.stageProjectCheckoutForDeletion)(input.projectRoot, input.branchName, checkoutPath);
|
|
1355
|
+
git(primaryPath, ["worktree", "prune"], `prune linked worktree ${input.branchName}`);
|
|
1353
1356
|
}
|
|
1354
1357
|
} else {
|
|
1355
1358
|
tryGit(primaryPath, ["worktree", "prune"]);
|
|
@@ -1358,7 +1361,11 @@ function deleteLinkedProjectBranch(input) {
|
|
|
1358
1361
|
if (tryGit(primaryPath, ["show-ref", "--verify", "--quiet", branchRef])) {
|
|
1359
1362
|
git(primaryPath, ["branch", "-D", input.branchName], `delete project branch ${input.branchName}`);
|
|
1360
1363
|
}
|
|
1361
|
-
return {
|
|
1364
|
+
return {
|
|
1365
|
+
branchName: input.branchName,
|
|
1366
|
+
...movedAsidePath ? { movedAsidePath } : {},
|
|
1367
|
+
...stagedForCollectionPath ? { stagedForCollectionPath } : {}
|
|
1368
|
+
};
|
|
1362
1369
|
}
|
|
1363
1370
|
function removeProjectWorktrees(input) {
|
|
1364
1371
|
const projectRoot = import_node_path.default.resolve(input.projectRoot);
|
|
@@ -98,6 +98,19 @@ class WorkerRecoveryStore {
|
|
|
98
98
|
isUnknown(requestId) {
|
|
99
99
|
return this.row(requestId)?.state === "unknown";
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Re-admit an operation whose crash outcome is unknown so it can run again
|
|
103
|
+
* and record a result. Only a branch deletion qualifies: it is idempotent
|
|
104
|
+
* for its exact incarnation (durable tombstone plus incarnation preflight),
|
|
105
|
+
* so a rerun converges on the same outcome. Every other unknown operation
|
|
106
|
+
* keeps the no-replay invariant.
|
|
107
|
+
*/
|
|
108
|
+
readmitUnknownBranchDeletion(requestId) {
|
|
109
|
+
const row = this.row(requestId);
|
|
110
|
+
if (!row || row.state !== "unknown" || row.request_type !== "delete_project_branch") return false;
|
|
111
|
+
this.db.run("UPDATE operations SET state='accepted', response=NULL WHERE request_id=?", [row.request_id]);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
101
114
|
unknown(requestId) {
|
|
102
115
|
const row = this.row(requestId);
|
|
103
116
|
if (row) this.db.run("UPDATE operations SET state='unknown' WHERE request_id=?", [row.request_id]);
|
|
@@ -2367,7 +2367,9 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
|
|
|
2367
2367
|
}
|
|
2368
2368
|
restoreDeferredWorkspaceMountsFromHead(
|
|
2369
2369
|
workspacePath,
|
|
2370
|
-
input.mounts.filter(
|
|
2370
|
+
input.mounts.filter(
|
|
2371
|
+
(mount) => deferredMountIds.has(mount.id) || mount.preserveLocalOnHydrationBasisChange === true && cycleSkippedMountIds.has(mount.id)
|
|
2372
|
+
)
|
|
2371
2373
|
);
|
|
2372
2374
|
await stageAndCommitWorkspace();
|
|
2373
2375
|
await fsyncWorkspaceCheckoutTreeYielding(workspacePath);
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { Database } from "bun:sqlite";
|
|
|
25
25
|
import { installCliUpdate, readInstalledCliVersion } from "./cli-update.mjs";
|
|
26
26
|
import { gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
|
|
27
27
|
import { terminateProcessTree } from "./process-tree.mjs";
|
|
28
|
+
import { ProjectCheckoutGarbageCollector } from "./project-checkout-garbage.mjs";
|
|
28
29
|
import { createPtyOutputCoalescer } from "./pty-output-coalescer.mjs";
|
|
29
30
|
import { openWorkerPortForwardRelay } from "./port-forward-client.mjs";
|
|
30
31
|
import {
|
|
@@ -293,6 +294,15 @@ let workerAdmissionGeneration = 0;
|
|
|
293
294
|
const workspaceMutationGate = new WorkspaceMutationGate();
|
|
294
295
|
let workspaceSyncQueue = Promise.resolve();
|
|
295
296
|
let startupProjectSnapshotRecoveryCompleted = false;
|
|
297
|
+
const checkoutGarbageCollectors = /* @__PURE__ */ new Map();
|
|
298
|
+
function checkoutGarbageCollectorFor(projectsRoot) {
|
|
299
|
+
let collector = checkoutGarbageCollectors.get(projectsRoot);
|
|
300
|
+
if (!collector) {
|
|
301
|
+
collector = new ProjectCheckoutGarbageCollector({ projectsRoot });
|
|
302
|
+
checkoutGarbageCollectors.set(projectsRoot, collector);
|
|
303
|
+
}
|
|
304
|
+
return collector;
|
|
305
|
+
}
|
|
296
306
|
const workspaceSyncSingleFlight = {
|
|
297
307
|
runExclusive(operation) {
|
|
298
308
|
const queued = workspaceMutationGate.runSync(operation);
|
|
@@ -959,24 +969,43 @@ function tryGit(args, options = {}) {
|
|
|
959
969
|
return false;
|
|
960
970
|
}
|
|
961
971
|
}
|
|
972
|
+
class GitTransportTimeoutError extends Error {
|
|
973
|
+
}
|
|
962
974
|
async function runGitAsync(args, options = {}) {
|
|
963
975
|
const command = workerGitCommand.commandArgs(args);
|
|
976
|
+
const bounded = options.timeoutMs !== void 0;
|
|
964
977
|
const subprocess = Bun.spawn(command, {
|
|
965
978
|
cwd: options.cwd,
|
|
966
979
|
stdin: "ignore",
|
|
967
980
|
stdout: "pipe",
|
|
968
981
|
stderr: "pipe",
|
|
969
|
-
env: workerGitProcessEnvironment()
|
|
982
|
+
env: workerGitProcessEnvironment(),
|
|
983
|
+
...bounded ? { detached: true } : {}
|
|
970
984
|
});
|
|
971
|
-
const
|
|
985
|
+
const completion = Promise.all([
|
|
972
986
|
new Response(subprocess.stdout).text(),
|
|
973
987
|
new Response(subprocess.stderr).text(),
|
|
974
988
|
subprocess.exited
|
|
975
|
-
]);
|
|
976
|
-
|
|
977
|
-
|
|
989
|
+
]).then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode }));
|
|
990
|
+
let timer;
|
|
991
|
+
const timedOut = bounded ? new Promise((_resolve, reject) => {
|
|
992
|
+
timer = setTimeout(() => reject(new GitTransportTimeoutError(`git ${args.join(" ")} timed out after ${options.timeoutMs}ms`)), options.timeoutMs);
|
|
993
|
+
}) : null;
|
|
994
|
+
try {
|
|
995
|
+
const { stdout, stderr, exitCode } = timedOut ? await Promise.race([completion, timedOut]) : await completion;
|
|
996
|
+
if (exitCode !== 0) {
|
|
997
|
+
throw new Error(`git ${args.join(" ")} failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`);
|
|
998
|
+
}
|
|
999
|
+
return stdout.trim();
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (error instanceof GitTransportTimeoutError) {
|
|
1002
|
+
void terminateProcessTree(subprocess, { graceMs: 0 }).catch(() => void 0);
|
|
1003
|
+
void completion.catch(() => void 0);
|
|
1004
|
+
}
|
|
1005
|
+
throw error;
|
|
1006
|
+
} finally {
|
|
1007
|
+
if (timer) clearTimeout(timer);
|
|
978
1008
|
}
|
|
979
|
-
return stdout.trim();
|
|
980
1009
|
}
|
|
981
1010
|
async function tryGitAsync(args, options = {}) {
|
|
982
1011
|
try {
|
|
@@ -1837,14 +1866,13 @@ const workerGitSecurityTestHarness = {
|
|
|
1837
1866
|
executeEditFileOperation,
|
|
1838
1867
|
terminateCredentialBearingChildren,
|
|
1839
1868
|
terminateCredentialBearingChildrenWithRetention,
|
|
1840
|
-
async commitCredentialGeneration(prepared, credential,
|
|
1841
|
-
return await commitCredentialGeneration(
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
);
|
|
1869
|
+
async commitCredentialGeneration(prepared, credential, options) {
|
|
1870
|
+
return await commitCredentialGeneration(prepared, credential, {
|
|
1871
|
+
secretMaterialChanged: options.secretMaterialChanged,
|
|
1872
|
+
terminatePreviousGeneration: async () => await terminateCredentialBearingChildren(options.children),
|
|
1873
|
+
beforeMutation: options.beforeMutation,
|
|
1874
|
+
previousGenerationFenced: options.previousGenerationFenced ?? false
|
|
1875
|
+
});
|
|
1848
1876
|
},
|
|
1849
1877
|
credentialGenerationReceiptPath,
|
|
1850
1878
|
credentialAuthorityLockPath,
|
|
@@ -1882,14 +1910,14 @@ function installGitHubCredentialGeneration(credential) {
|
|
|
1882
1910
|
`);
|
|
1883
1911
|
}
|
|
1884
1912
|
}
|
|
1885
|
-
async function commitCredentialGeneration(prepared, credential,
|
|
1913
|
+
async function commitCredentialGeneration(prepared, credential, options) {
|
|
1886
1914
|
const changed = prepared.changed;
|
|
1887
1915
|
try {
|
|
1888
|
-
if (changed && !previousGenerationFenced) {
|
|
1916
|
+
if (changed && options.secretMaterialChanged && !options.previousGenerationFenced) {
|
|
1889
1917
|
githubCredential = null;
|
|
1890
|
-
await terminatePreviousGeneration();
|
|
1918
|
+
await options.terminatePreviousGeneration();
|
|
1891
1919
|
}
|
|
1892
|
-
prepared.commit(beforeMutation);
|
|
1920
|
+
prepared.commit(options.beforeMutation);
|
|
1893
1921
|
} catch (error) {
|
|
1894
1922
|
prepared.discard();
|
|
1895
1923
|
throw error instanceof RegistryAuthConfigurationError ? error : new RegistryAuthConfigurationError(error);
|
|
@@ -3705,6 +3733,12 @@ async function startWorker(options, projectRuntime = {
|
|
|
3705
3733
|
projectRuntime.initializedWorkspaceState = initializedWorkspaceState;
|
|
3706
3734
|
const projectWorkspaceStateStore = initializedWorkspaceState.store;
|
|
3707
3735
|
let projectWorkspaceState = initializedWorkspaceState.store.read();
|
|
3736
|
+
const checkoutGarbageCollector = checkoutGarbageCollectorFor(projectsRoot);
|
|
3737
|
+
const staleStagedCheckouts = checkoutGarbageCollector.sweep();
|
|
3738
|
+
if (staleStagedCheckouts > 0) {
|
|
3739
|
+
process.stderr.write(`[r5d-worker] collecting ${staleStagedCheckouts} deleted checkout(s) left from an earlier run
|
|
3740
|
+
`);
|
|
3741
|
+
}
|
|
3708
3742
|
const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
|
|
3709
3743
|
const pendingCheckouts = /* @__PURE__ */ new Map();
|
|
3710
3744
|
const lastObservedProjectHeads = /* @__PURE__ */ new Map();
|
|
@@ -3819,6 +3853,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
3819
3853
|
`
|
|
3820
3854
|
);
|
|
3821
3855
|
}
|
|
3856
|
+
if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
|
|
3822
3857
|
}
|
|
3823
3858
|
const planSourcePath = path.join(planRoot, project.projectId, ...branchName.split("/"));
|
|
3824
3859
|
fs.rmSync(planSourcePath, { recursive: true, force: true });
|
|
@@ -3902,6 +3937,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
3902
3937
|
`
|
|
3903
3938
|
);
|
|
3904
3939
|
}
|
|
3940
|
+
if (deleted.stagedForCollectionPath) checkoutGarbageCollector.enqueue(deleted.stagedForCollectionPath);
|
|
3905
3941
|
fs.rmSync(path.join(planRoot, project.projectId, ...branchName.split("/")), { recursive: true, force: true });
|
|
3906
3942
|
readyProjectIds.delete(project.projectId);
|
|
3907
3943
|
reconciledProjectConfigFingerprints.delete(project.projectId);
|
|
@@ -4338,33 +4374,56 @@ async function startWorker(options, projectRuntime = {
|
|
|
4338
4374
|
return reconciledProjectIds;
|
|
4339
4375
|
};
|
|
4340
4376
|
const ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
|
|
4377
|
+
const ORIGIN_DIVERGENCE_FETCH_TIMEOUT_MS = 3e4;
|
|
4378
|
+
const ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS = 9e4;
|
|
4341
4379
|
const lastOriginDivergenceFetchMs = /* @__PURE__ */ new Map();
|
|
4342
|
-
const collectAheadOfOriginBranches = async (refetchOriginProjectIds) => {
|
|
4380
|
+
const collectAheadOfOriginBranches = async (refetchOriginProjectIds, onProgress) => {
|
|
4343
4381
|
const aheadBranches = [];
|
|
4344
4382
|
for (const projectId of [...lastOriginDivergenceFetchMs.keys()]) {
|
|
4345
4383
|
if (!projectConfigById.has(projectId)) lastOriginDivergenceFetchMs.delete(projectId);
|
|
4346
4384
|
}
|
|
4347
|
-
|
|
4385
|
+
const passStartedAtMs = Date.now();
|
|
4386
|
+
let budgetSpentReported = false;
|
|
4387
|
+
const projects = [...projectConfigById.values()];
|
|
4388
|
+
for (const [projectIndex, project] of projects.entries()) {
|
|
4348
4389
|
if (project.executionDisabled || !readyProjectIds.has(project.projectId) || project.branches.length === 0) continue;
|
|
4390
|
+
onProgress?.({
|
|
4391
|
+
detail: `Checking origin divergence for ${project.projectPath}`,
|
|
4392
|
+
completedItems: projectIndex,
|
|
4393
|
+
totalItems: projects.length
|
|
4394
|
+
});
|
|
4349
4395
|
const connection = projectConnection(project);
|
|
4350
4396
|
const primaryPath = configuredProjectBranchPath(projectsRoot, project, primaryProjectBranch(project));
|
|
4351
4397
|
const lastFetchedAtMs = lastOriginDivergenceFetchMs.get(project.projectId);
|
|
4352
4398
|
if (refetchOriginProjectIds.has(project.projectId) || lastFetchedAtMs === void 0 || Date.now() - lastFetchedAtMs >= ORIGIN_DIVERGENCE_REFRESH_INTERVAL_MS) {
|
|
4353
|
-
if (
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
],
|
|
4362
|
-
{ cwd: primaryPath }
|
|
4363
|
-
)) {
|
|
4364
|
-
lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
|
|
4399
|
+
if (Date.now() - passStartedAtMs >= ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS) {
|
|
4400
|
+
if (!budgetSpentReported) {
|
|
4401
|
+
budgetSpentReported = true;
|
|
4402
|
+
process.stderr.write(
|
|
4403
|
+
`[r5d-worker] origin divergence refresh budget of ${ORIGIN_DIVERGENCE_REFRESH_BUDGET_MS}ms is spent; remaining origins keep their previous comparison until the next refresh
|
|
4404
|
+
`
|
|
4405
|
+
);
|
|
4406
|
+
}
|
|
4365
4407
|
} else {
|
|
4366
|
-
|
|
4367
|
-
|
|
4408
|
+
try {
|
|
4409
|
+
await runGitAsync(
|
|
4410
|
+
[
|
|
4411
|
+
...gitTransportSecurityArgs(connection.originUrl, connection.credentialHelper, connection.originCredentialUsername),
|
|
4412
|
+
"fetch",
|
|
4413
|
+
"--no-recurse-submodules",
|
|
4414
|
+
"--prune",
|
|
4415
|
+
"origin",
|
|
4416
|
+
"+refs/heads/*:refs/remotes/origin/*"
|
|
4417
|
+
],
|
|
4418
|
+
{ cwd: primaryPath, timeoutMs: ORIGIN_DIVERGENCE_FETCH_TIMEOUT_MS }
|
|
4419
|
+
);
|
|
4420
|
+
lastOriginDivergenceFetchMs.set(project.projectId, Date.now());
|
|
4421
|
+
} catch (error) {
|
|
4422
|
+
process.stderr.write(
|
|
4423
|
+
`[r5d-worker] could not refresh origin divergence for ${project.projectPath}: ${error instanceof Error ? error.message : String(error)}
|
|
4424
|
+
`
|
|
4425
|
+
);
|
|
4426
|
+
}
|
|
4368
4427
|
}
|
|
4369
4428
|
}
|
|
4370
4429
|
for (const branch of project.branches) {
|
|
@@ -5397,13 +5456,11 @@ async function startWorker(options, projectRuntime = {
|
|
|
5397
5456
|
} catch (error) {
|
|
5398
5457
|
throw error instanceof RegistryAuthConfigurationError ? error : new RegistryAuthConfigurationError(error);
|
|
5399
5458
|
}
|
|
5400
|
-
await commitCredentialGeneration(
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
credentialPublicationPreauthorized
|
|
5406
|
-
);
|
|
5459
|
+
await commitCredentialGeneration(preparedAuthGeneration, message.githubCredential, {
|
|
5460
|
+
secretMaterialChanged: credentialTransitionPhase !== "current",
|
|
5461
|
+
terminatePreviousGeneration: terminateActiveCredentialBearingChildren,
|
|
5462
|
+
previousGenerationFenced: credentialPublicationPreauthorized
|
|
5463
|
+
});
|
|
5407
5464
|
if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
|
|
5408
5465
|
throw new Error("Workspace configuration was superseded by a newer server generation");
|
|
5409
5466
|
}
|
|
@@ -5538,7 +5595,15 @@ async function startWorker(options, projectRuntime = {
|
|
|
5538
5595
|
}
|
|
5539
5596
|
};
|
|
5540
5597
|
const hasDurableBranchDeletion = projectWorkspaceState.tombstones.some((tombstone) => tombstone.kind === "branch");
|
|
5541
|
-
|
|
5598
|
+
sendLifecycleProgress({
|
|
5599
|
+
phase: "configuring",
|
|
5600
|
+
operationId: configurationOperationId,
|
|
5601
|
+
detail: `Synchronizing the workspace after configuring ${message.projects.length} project(s)`,
|
|
5602
|
+
completedItems: message.projects.length,
|
|
5603
|
+
totalItems: message.projects.length
|
|
5604
|
+
});
|
|
5605
|
+
const connectSyncStartedAtMs = Date.now();
|
|
5606
|
+
const syncResult = await performWorkspaceSync({
|
|
5542
5607
|
attemptId: crypto.randomUUID(),
|
|
5543
5608
|
trigger: { type: "connect" },
|
|
5544
5609
|
confirmedLargeDiff: hasDurableBranchDeletion,
|
|
@@ -5546,6 +5611,11 @@ async function startWorker(options, projectRuntime = {
|
|
|
5546
5611
|
resetToCanonical: workspaceConfigurationResetToCanonicalIsAllowed(message.resetToCanonical, incidentDeferral.incidentId),
|
|
5547
5612
|
assertStillAdmitted: assertConfigurationSyncStillAdmitted
|
|
5548
5613
|
});
|
|
5614
|
+
const connectSyncMs = Date.now() - connectSyncStartedAtMs;
|
|
5615
|
+
const result = syncResult.telemetry ? syncResult : {
|
|
5616
|
+
...syncResult,
|
|
5617
|
+
telemetry: { totalMs: connectSyncMs, queueMs: 0, prepareMs: lastWorkspaceSyncMirrorObservationMs, synchronizeMs: connectSyncMs }
|
|
5618
|
+
};
|
|
5549
5619
|
assertConfigurationSyncStillAdmitted();
|
|
5550
5620
|
if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
|
|
5551
5621
|
throw new Error("Workspace configuration was superseded by a newer server generation");
|
|
@@ -5578,7 +5648,14 @@ async function startWorker(options, projectRuntime = {
|
|
|
5578
5648
|
const pending = [...pendingCheckouts.values()].sort(
|
|
5579
5649
|
(left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
|
|
5580
5650
|
);
|
|
5581
|
-
return {
|
|
5651
|
+
return {
|
|
5652
|
+
result,
|
|
5653
|
+
pending,
|
|
5654
|
+
aheadOfOriginBranches: await collectAheadOfOriginBranches(
|
|
5655
|
+
reconciledProjectIds,
|
|
5656
|
+
(progress) => sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress })
|
|
5657
|
+
)
|
|
5658
|
+
};
|
|
5582
5659
|
});
|
|
5583
5660
|
} catch (error) {
|
|
5584
5661
|
if (receiptGeneration !== workspaceConfigurationReceiptGeneration) {
|
|
@@ -5795,6 +5872,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
5795
5872
|
workspaceIncidentConfigDeferralV1: true,
|
|
5796
5873
|
workspaceConfigResetToCanonicalV1: true,
|
|
5797
5874
|
projectBranchDeletionFastAckV1: true,
|
|
5875
|
+
projectBranchDeletionStagedRemovalV1: true,
|
|
5798
5876
|
projectMirrorLeaseV1: true,
|
|
5799
5877
|
projectMirrorRefsTokensV1: true,
|
|
5800
5878
|
projectBranchWorkingTreeModeV1: true
|
|
@@ -5896,7 +5974,9 @@ async function startWorker(options, projectRuntime = {
|
|
|
5896
5974
|
if (admission !== "new") {
|
|
5897
5975
|
const response = recoveryStore.response(operationRequestId);
|
|
5898
5976
|
if (response && admission !== "unknown") sendReplayWorkerMessage(ws, response);
|
|
5899
|
-
|
|
5977
|
+
if (!(admission === "unknown" && message.type === "delete_project_branch" && recoveryStore.readmitUnknownBranchDeletion(operationRequestId))) {
|
|
5978
|
+
return;
|
|
5979
|
+
}
|
|
5900
5980
|
}
|
|
5901
5981
|
}
|
|
5902
5982
|
const messageAdmissionGeneration = workerAdmissionGeneration;
|
package/dist/mjs/package.json
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const PROJECT_DELETED_CHECKOUTS_DIRECTORY = ".r5d-deleted";
|
|
5
|
+
function isRealDirectory(candidate) {
|
|
6
|
+
try {
|
|
7
|
+
return fs.lstatSync(candidate).isDirectory();
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function stageProjectCheckoutForDeletion(projectRoot, branchName, checkoutPath) {
|
|
13
|
+
const garbageRoot = path.join(projectRoot, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
|
|
14
|
+
if (!isRealDirectory(checkoutPath)) throw new Error(`Cannot stage ${checkoutPath} for deletion: not a directory`);
|
|
15
|
+
fs.mkdirSync(garbageRoot, { recursive: true });
|
|
16
|
+
if (!isRealDirectory(garbageRoot)) throw new Error(`Cannot stage ${checkoutPath} for deletion: ${garbageRoot} is not a directory`);
|
|
17
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
18
|
+
const target = path.join(garbageRoot, `${branchName.replace(/\//g, "__")}-${timestamp}-${randomUUID().slice(0, 8)}`);
|
|
19
|
+
fs.renameSync(checkoutPath, target);
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
22
|
+
function isStagedProjectCheckoutPath(projectsRoot, candidate) {
|
|
23
|
+
const relative = path.relative(path.resolve(projectsRoot), path.resolve(candidate));
|
|
24
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return false;
|
|
25
|
+
const segments = relative.split(path.sep);
|
|
26
|
+
return segments.length === 4 && segments[2] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && segments[3] !== "" && !segments[3].startsWith(".");
|
|
27
|
+
}
|
|
28
|
+
function ownedStagedPath(projectsRoot, candidate) {
|
|
29
|
+
const root = path.resolve(projectsRoot);
|
|
30
|
+
const resolved = path.resolve(candidate);
|
|
31
|
+
if (!isStagedProjectCheckoutPath(root, resolved)) return null;
|
|
32
|
+
let current = root;
|
|
33
|
+
for (const segment of path.relative(root, path.dirname(resolved)).split(path.sep)) {
|
|
34
|
+
current = path.join(current, segment);
|
|
35
|
+
if (!isRealDirectory(current)) return null;
|
|
36
|
+
}
|
|
37
|
+
let stat;
|
|
38
|
+
try {
|
|
39
|
+
stat = fs.lstatSync(resolved);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (stat.isSymbolicLink()) return { kind: "link" };
|
|
44
|
+
return stat.isDirectory() ? { kind: "directory" } : null;
|
|
45
|
+
}
|
|
46
|
+
function realDirectoryEntries(directory) {
|
|
47
|
+
if (!isRealDirectory(directory)) return [];
|
|
48
|
+
try {
|
|
49
|
+
return fs.readdirSync(directory, { withFileTypes: true }).map((entry) => entry.name).sort();
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function discoverStagedProjectCheckouts(projectsRoot) {
|
|
55
|
+
const root = path.resolve(projectsRoot);
|
|
56
|
+
const staged = [];
|
|
57
|
+
for (const namespace of realDirectoryEntries(root)) {
|
|
58
|
+
if (namespace.startsWith(".")) continue;
|
|
59
|
+
for (const project of realDirectoryEntries(path.join(root, namespace))) {
|
|
60
|
+
if (project.startsWith(".")) continue;
|
|
61
|
+
const garbageRoot = path.join(root, namespace, project, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
|
|
62
|
+
for (const entry of realDirectoryEntries(garbageRoot)) {
|
|
63
|
+
const candidate = path.join(garbageRoot, entry);
|
|
64
|
+
if (ownedStagedPath(root, candidate)) staged.push(candidate);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return staged;
|
|
69
|
+
}
|
|
70
|
+
async function removeStagedProjectCheckout(stagedPath) {
|
|
71
|
+
if (fs.lstatSync(stagedPath).isSymbolicLink()) {
|
|
72
|
+
fs.unlinkSync(stagedPath);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (process.platform === "win32") {
|
|
76
|
+
await fs.promises.rm(stagedPath, { recursive: true, force: true });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const command = ["rm", "-rf", "--", stagedPath];
|
|
80
|
+
if (Bun.which("nice")) command.unshift("nice", "-n", "19");
|
|
81
|
+
if (process.platform === "linux" && Bun.which("ionice")) command.unshift("ionice", "-c", "3");
|
|
82
|
+
const child = Bun.spawn(command, { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
|
|
83
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
84
|
+
if (exitCode !== 0) throw new Error(`rm exited ${exitCode}: ${stderr.trim()}`);
|
|
85
|
+
if (fs.existsSync(stagedPath)) throw new Error("staged checkout still exists after removal");
|
|
86
|
+
}
|
|
87
|
+
class ProjectCheckoutGarbageCollector {
|
|
88
|
+
projectsRoot;
|
|
89
|
+
remove;
|
|
90
|
+
log;
|
|
91
|
+
queued = /* @__PURE__ */ new Set();
|
|
92
|
+
tail = Promise.resolve();
|
|
93
|
+
collectedCount = 0;
|
|
94
|
+
constructor(options) {
|
|
95
|
+
this.projectsRoot = path.resolve(options.projectsRoot);
|
|
96
|
+
this.remove = options.remove ?? removeStagedProjectCheckout;
|
|
97
|
+
this.log = options.log ?? ((message) => process.stderr.write(`${message}
|
|
98
|
+
`));
|
|
99
|
+
}
|
|
100
|
+
/** Number of removals that have completed successfully. */
|
|
101
|
+
get collected() {
|
|
102
|
+
return this.collectedCount;
|
|
103
|
+
}
|
|
104
|
+
/** Resolves once everything queued so far has been attempted. */
|
|
105
|
+
get idle() {
|
|
106
|
+
return this.tail;
|
|
107
|
+
}
|
|
108
|
+
enqueue(stagedPath) {
|
|
109
|
+
const resolved = path.resolve(stagedPath);
|
|
110
|
+
if (!ownedStagedPath(this.projectsRoot, resolved)) {
|
|
111
|
+
throw new Error(`Refusing to collect ${stagedPath}: not a staged checkout under ${this.projectsRoot}`);
|
|
112
|
+
}
|
|
113
|
+
if (this.queued.has(resolved)) return;
|
|
114
|
+
this.queued.add(resolved);
|
|
115
|
+
this.tail = this.tail.then(async () => {
|
|
116
|
+
try {
|
|
117
|
+
const owned = ownedStagedPath(this.projectsRoot, resolved);
|
|
118
|
+
if (owned) {
|
|
119
|
+
const startedAt = performance.now();
|
|
120
|
+
await this.remove(resolved);
|
|
121
|
+
this.collectedCount += 1;
|
|
122
|
+
this.log(`[r5d-worker] collected deleted checkout ${resolved} in ${Math.round(performance.now() - startedAt)}ms`);
|
|
123
|
+
} else if (fs.existsSync(resolved) || isRealDirectory(path.dirname(resolved))) {
|
|
124
|
+
this.log(`[r5d-worker] skipped deleted checkout ${resolved}: no longer an owned staged checkout`);
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.log(
|
|
128
|
+
`[r5d-worker] deleted checkout ${resolved} could not be collected yet: ${error instanceof Error ? error.message : String(error)}`
|
|
129
|
+
);
|
|
130
|
+
} finally {
|
|
131
|
+
this.queued.delete(resolved);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** Enqueue every staged checkout left behind by an earlier process. */
|
|
136
|
+
sweep() {
|
|
137
|
+
const staged = discoverStagedProjectCheckouts(this.projectsRoot);
|
|
138
|
+
for (const stagedPath of staged) this.enqueue(stagedPath);
|
|
139
|
+
return staged.length;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export {
|
|
143
|
+
PROJECT_DELETED_CHECKOUTS_DIRECTORY,
|
|
144
|
+
ProjectCheckoutGarbageCollector,
|
|
145
|
+
discoverStagedProjectCheckouts,
|
|
146
|
+
isStagedProjectCheckoutPath,
|
|
147
|
+
ownedStagedPath,
|
|
148
|
+
removeStagedProjectCheckout,
|
|
149
|
+
stageProjectCheckoutForDeletion
|
|
150
|
+
};
|
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { gitCredentialUsernameConfigKey, gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
|
|
6
6
|
import { validateManagedBranchName } from "./managed-paths.mjs";
|
|
7
|
+
import { stageProjectCheckoutForDeletion } from "./project-checkout-garbage.mjs";
|
|
7
8
|
import {
|
|
8
9
|
canonicalizeLocalProjectMirrorRefs,
|
|
9
10
|
parseProjectMirrorRefListing,
|
|
@@ -1290,6 +1291,7 @@ function deleteLinkedProjectBranch(input) {
|
|
|
1290
1291
|
const primaryCommonDir = commonGitDirectory(primaryPath);
|
|
1291
1292
|
if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
|
|
1292
1293
|
let movedAsidePath;
|
|
1294
|
+
let stagedForCollectionPath;
|
|
1293
1295
|
if (fs.existsSync(checkoutPath)) {
|
|
1294
1296
|
const checkoutCommonDir = fs.lstatSync(checkoutPath).isDirectory() ? commonGitDirectory(checkoutPath) : null;
|
|
1295
1297
|
if (checkoutCommonDir !== primaryCommonDir) {
|
|
@@ -1299,7 +1301,8 @@ function deleteLinkedProjectBranch(input) {
|
|
|
1299
1301
|
if (projectWorktreeOperationInProgress(checkoutPath)) {
|
|
1300
1302
|
throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
|
|
1301
1303
|
}
|
|
1302
|
-
|
|
1304
|
+
stagedForCollectionPath = stageProjectCheckoutForDeletion(input.projectRoot, input.branchName, checkoutPath);
|
|
1305
|
+
git(primaryPath, ["worktree", "prune"], `prune linked worktree ${input.branchName}`);
|
|
1303
1306
|
}
|
|
1304
1307
|
} else {
|
|
1305
1308
|
tryGit(primaryPath, ["worktree", "prune"]);
|
|
@@ -1308,7 +1311,11 @@ function deleteLinkedProjectBranch(input) {
|
|
|
1308
1311
|
if (tryGit(primaryPath, ["show-ref", "--verify", "--quiet", branchRef])) {
|
|
1309
1312
|
git(primaryPath, ["branch", "-D", input.branchName], `delete project branch ${input.branchName}`);
|
|
1310
1313
|
}
|
|
1311
|
-
return {
|
|
1314
|
+
return {
|
|
1315
|
+
branchName: input.branchName,
|
|
1316
|
+
...movedAsidePath ? { movedAsidePath } : {},
|
|
1317
|
+
...stagedForCollectionPath ? { stagedForCollectionPath } : {}
|
|
1318
|
+
};
|
|
1312
1319
|
}
|
|
1313
1320
|
function removeProjectWorktrees(input) {
|
|
1314
1321
|
const projectRoot = path.resolve(input.projectRoot);
|
|
@@ -64,6 +64,19 @@ class WorkerRecoveryStore {
|
|
|
64
64
|
isUnknown(requestId) {
|
|
65
65
|
return this.row(requestId)?.state === "unknown";
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Re-admit an operation whose crash outcome is unknown so it can run again
|
|
69
|
+
* and record a result. Only a branch deletion qualifies: it is idempotent
|
|
70
|
+
* for its exact incarnation (durable tombstone plus incarnation preflight),
|
|
71
|
+
* so a rerun converges on the same outcome. Every other unknown operation
|
|
72
|
+
* keeps the no-replay invariant.
|
|
73
|
+
*/
|
|
74
|
+
readmitUnknownBranchDeletion(requestId) {
|
|
75
|
+
const row = this.row(requestId);
|
|
76
|
+
if (!row || row.state !== "unknown" || row.request_type !== "delete_project_branch") return false;
|
|
77
|
+
this.db.run("UPDATE operations SET state='accepted', response=NULL WHERE request_id=?", [row.request_id]);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
67
80
|
unknown(requestId) {
|
|
68
81
|
const row = this.row(requestId);
|
|
69
82
|
if (row) this.db.run("UPDATE operations SET state='unknown' WHERE request_id=?", [row.request_id]);
|
|
@@ -2330,7 +2330,9 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
|
|
|
2330
2330
|
}
|
|
2331
2331
|
restoreDeferredWorkspaceMountsFromHead(
|
|
2332
2332
|
workspacePath,
|
|
2333
|
-
input.mounts.filter(
|
|
2333
|
+
input.mounts.filter(
|
|
2334
|
+
(mount) => deferredMountIds.has(mount.id) || mount.preserveLocalOnHydrationBasisChange === true && cycleSkippedMountIds.has(mount.id)
|
|
2335
|
+
)
|
|
2334
2336
|
);
|
|
2335
2337
|
await stageAndCommitWorkspace();
|
|
2336
2338
|
await fsyncWorkspaceCheckoutTreeYielding(workspacePath);
|
package/dist/types/main.d.ts
CHANGED
|
@@ -957,10 +957,15 @@ export declare const workerGitSecurityTestHarness: {
|
|
|
957
957
|
executeEditFileOperation: typeof executeEditFileOperation;
|
|
958
958
|
terminateCredentialBearingChildren: typeof terminateCredentialBearingChildren;
|
|
959
959
|
terminateCredentialBearingChildrenWithRetention: typeof terminateCredentialBearingChildrenWithRetention;
|
|
960
|
-
commitCredentialGeneration(prepared: PreparedPrivateAuthFileGeneration, credential: WorkerGitHubCredential | null,
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
960
|
+
commitCredentialGeneration(prepared: PreparedPrivateAuthFileGeneration, credential: WorkerGitHubCredential | null, options: {
|
|
961
|
+
children: readonly {
|
|
962
|
+
label: string;
|
|
963
|
+
terminate: () => void | Promise<void>;
|
|
964
|
+
}[];
|
|
965
|
+
secretMaterialChanged: boolean;
|
|
966
|
+
beforeMutation?: (filePath: string, index: number) => void;
|
|
967
|
+
previousGenerationFenced?: boolean;
|
|
968
|
+
}): Promise<boolean>;
|
|
964
969
|
credentialGenerationReceiptPath: typeof credentialGenerationReceiptPath;
|
|
965
970
|
credentialAuthorityLockPath: typeof credentialAuthorityLockPath;
|
|
966
971
|
acquireCredentialAuthorityLock: typeof acquireCredentialAuthorityLock;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a deleted branch checkout waits for collection. Staging is the only
|
|
3
|
+
* platform write into this directory and a live checkout always lives at
|
|
4
|
+
* `<projectRoot>/<branch>`, so a crash between staging and collection leaves
|
|
5
|
+
* nothing that a later sweep could confuse with a replacement branch. The
|
|
6
|
+
* filesystem is user-managed, though, so the collector never trusts the name
|
|
7
|
+
* alone: it removes only real directories or leaf links that sit under a real
|
|
8
|
+
* garbage directory of a real project directory (see `ownedStagedPath`).
|
|
9
|
+
* Distinct from `.r5d-removed`, which preserves paths that were not this
|
|
10
|
+
* project's worktree.
|
|
11
|
+
*/
|
|
12
|
+
export declare const PROJECT_DELETED_CHECKOUTS_DIRECTORY = ".r5d-deleted";
|
|
13
|
+
/**
|
|
14
|
+
* Rename a checkout this project owns out of the active namespace. One
|
|
15
|
+
* same-filesystem rename takes milliseconds whatever the tree holds, so the
|
|
16
|
+
* branch mutation lease is released long before the bytes are gone. The
|
|
17
|
+
* garbage directory must be a real directory: a planted link would make the
|
|
18
|
+
* later collection walk through it, so staging refuses rather than continue.
|
|
19
|
+
*/
|
|
20
|
+
export declare function stageProjectCheckoutForDeletion(projectRoot: string, branchName: string, checkoutPath: string): string;
|
|
21
|
+
/** True when `candidate` is lexically a direct entry of some project's garbage directory under `projectsRoot`. */
|
|
22
|
+
export declare function isStagedProjectCheckoutPath(projectsRoot: string, candidate: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Whether a lexically staged path is safe to remove right now: every ancestor
|
|
25
|
+
* below the projects root is a real directory (a linked namespace, project,
|
|
26
|
+
* or garbage directory would let a removal reach an unrelated tree through
|
|
27
|
+
* the link), and the entry itself is a real directory or a leaf link. Checked
|
|
28
|
+
* again immediately before the removal starts, never only at enqueue time.
|
|
29
|
+
*/
|
|
30
|
+
export declare function ownedStagedPath(projectsRoot: string, candidate: string): {
|
|
31
|
+
kind: "directory" | "link";
|
|
32
|
+
} | null;
|
|
33
|
+
/** Every owned staged checkout under `<projectsRoot>/<namespace>/<project>/.r5d-deleted/`; linked ancestors are not followed. */
|
|
34
|
+
export declare function discoverStagedProjectCheckouts(projectsRoot: string): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Delete a staged tree in a child process so a large recursive removal never
|
|
37
|
+
* runs on the worker event loop, at the lowest CPU and (where supported) I/O
|
|
38
|
+
* priority so live agents on the same volume keep their share. A leaf link is
|
|
39
|
+
* unlinked in place; its target is never followed.
|
|
40
|
+
*/
|
|
41
|
+
export declare function removeStagedProjectCheckout(stagedPath: string): Promise<void>;
|
|
42
|
+
type Logger = (message: string) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Collects staged checkouts one at a time, outside every workspace lease.
|
|
45
|
+
* Entries are enqueued right after staging and by `sweep()` at worker start,
|
|
46
|
+
* so a crash at any point converges on the next start. A failed removal is
|
|
47
|
+
* logged and left for the next sweep.
|
|
48
|
+
*/
|
|
49
|
+
export declare class ProjectCheckoutGarbageCollector {
|
|
50
|
+
private readonly projectsRoot;
|
|
51
|
+
private readonly remove;
|
|
52
|
+
private readonly log;
|
|
53
|
+
private readonly queued;
|
|
54
|
+
private tail;
|
|
55
|
+
private collectedCount;
|
|
56
|
+
constructor(options: {
|
|
57
|
+
projectsRoot: string;
|
|
58
|
+
remove?: (stagedPath: string) => Promise<void>;
|
|
59
|
+
log?: Logger;
|
|
60
|
+
});
|
|
61
|
+
/** Number of removals that have completed successfully. */
|
|
62
|
+
get collected(): number;
|
|
63
|
+
/** Resolves once everything queued so far has been attempted. */
|
|
64
|
+
get idle(): Promise<void>;
|
|
65
|
+
enqueue(stagedPath: string): void;
|
|
66
|
+
/** Enqueue every staged checkout left behind by an earlier process. */
|
|
67
|
+
sweep(): number;
|
|
68
|
+
}
|
|
69
|
+
export {};
|
|
@@ -209,6 +209,12 @@ export declare function deleteLinkedProjectBranch(input: {
|
|
|
209
209
|
branchName: string;
|
|
210
210
|
/** Where a path that was not this project's linked worktree was set aside instead of being deleted. */
|
|
211
211
|
movedAsidePath?: string;
|
|
212
|
+
/**
|
|
213
|
+
* Where the linked worktree's tree now waits for garbage collection. The
|
|
214
|
+
* branch is already gone from Git; the caller hands this path to the
|
|
215
|
+
* collector so the recursive removal runs outside every workspace lease.
|
|
216
|
+
*/
|
|
217
|
+
stagedForCollectionPath?: string;
|
|
212
218
|
};
|
|
213
219
|
export declare function removeProjectWorktrees(input: {
|
|
214
220
|
projectRoot: string;
|
|
@@ -16,6 +16,14 @@ export declare class WorkerRecoveryStore {
|
|
|
16
16
|
requestId: string;
|
|
17
17
|
}): "new" | "duplicate" | "unknown" | "conflict";
|
|
18
18
|
isUnknown(requestId: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Re-admit an operation whose crash outcome is unknown so it can run again
|
|
21
|
+
* and record a result. Only a branch deletion qualifies: it is idempotent
|
|
22
|
+
* for its exact incarnation (durable tombstone plus incarnation preflight),
|
|
23
|
+
* so a rerun converges on the same outcome. Every other unknown operation
|
|
24
|
+
* keeps the no-replay invariant.
|
|
25
|
+
*/
|
|
26
|
+
readmitUnknownBranchDeletion(requestId: string): boolean;
|
|
19
27
|
unknown(requestId: string): void;
|
|
20
28
|
unknownRun(runId: string): void;
|
|
21
29
|
record(message: Message): Message;
|