@ricsam/r5d-worker 0.0.53 → 0.0.55
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/heartbeat.cjs +15 -2
- package/dist/cjs/main.cjs +186 -145
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/workspace-incident-state.cjs +39 -0
- package/dist/cjs/workspace-mutation-gate.cjs +92 -0
- package/dist/cjs/workspace-sync.cjs +73 -41
- package/dist/mjs/heartbeat.mjs +12 -1
- package/dist/mjs/main.mjs +191 -146
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/workspace-incident-state.mjs +14 -0
- package/dist/mjs/workspace-mutation-gate.mjs +68 -0
- package/dist/mjs/workspace-sync.mjs +71 -41
- package/dist/types/heartbeat.d.ts +7 -0
- package/dist/types/workspace-incident-state.d.ts +8 -0
- package/dist/types/workspace-mutation-gate.d.ts +19 -0
- package/dist/types/workspace-sync.d.ts +14 -1
- package/package.json +1 -1
|
@@ -2,7 +2,9 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { managedBranchPath } from "./managed-paths.mjs";
|
|
5
|
+
import { WorkspaceMutationGate } from "./workspace-mutation-gate.mjs";
|
|
5
6
|
const WORKSPACE_BRANCH = "main";
|
|
7
|
+
const WORKSPACE_INTENT_REF_PREFIX = "refs/r5d/workspace-intents/";
|
|
6
8
|
const MAX_WORKSPACE_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
|
|
7
9
|
const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5e3;
|
|
8
10
|
const MAX_REPORTED_WORKSPACE_PATHS = 2e3;
|
|
@@ -1072,10 +1074,27 @@ function commitMessage(input) {
|
|
|
1072
1074
|
...input.confirmedLargeDiff ? { confirmedLargeDiff: true, confirmationReason: input.confirmationReason } : {}
|
|
1073
1075
|
});
|
|
1074
1076
|
}
|
|
1075
|
-
function
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1078
|
-
|
|
1077
|
+
function assertWorkspaceQuarantineRef(quarantineRef) {
|
|
1078
|
+
const suffix = quarantineRef.slice(WORKSPACE_INTENT_REF_PREFIX.length);
|
|
1079
|
+
if (!quarantineRef.startsWith(WORKSPACE_INTENT_REF_PREFIX) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(suffix)) {
|
|
1080
|
+
throw new Error(`Workspace candidate ref must be below ${WORKSPACE_INTENT_REF_PREFIX}`);
|
|
1081
|
+
}
|
|
1082
|
+
const result = Bun.spawnSync(["git", "check-ref-format", quarantineRef], {
|
|
1083
|
+
stdout: "pipe",
|
|
1084
|
+
stderr: "pipe",
|
|
1085
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
1086
|
+
});
|
|
1087
|
+
if (result.exitCode !== 0) {
|
|
1088
|
+
throw new Error(`Invalid workspace candidate ref ${JSON.stringify(quarantineRef)}`);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
function uploadWorkspaceCandidate(input, candidateHead) {
|
|
1092
|
+
const quarantineRef = input.quarantineRef;
|
|
1093
|
+
if (!quarantineRef) {
|
|
1094
|
+
throw new Error("A server-issued workspace candidate ref is required before uploading a canonical workspace candidate");
|
|
1095
|
+
}
|
|
1096
|
+
assertWorkspaceQuarantineRef(quarantineRef);
|
|
1097
|
+
return runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:${quarantineRef}`]);
|
|
1079
1098
|
}
|
|
1080
1099
|
function resetShadowToRemote(input, opaqueWorkspaceRoots = []) {
|
|
1081
1100
|
runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch canonical workspace before reset");
|
|
@@ -1094,6 +1113,7 @@ function baseResult(input, startingHead) {
|
|
|
1094
1113
|
workerLabel: input.workerLabel,
|
|
1095
1114
|
trigger: input.trigger,
|
|
1096
1115
|
startingHead,
|
|
1116
|
+
expectedHead: startingHead,
|
|
1097
1117
|
rebaseCount: 0,
|
|
1098
1118
|
diffSizeBytes: 0,
|
|
1099
1119
|
gitStatus: "",
|
|
@@ -1197,7 +1217,7 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
1197
1217
|
if (stagedWorkingPaths.length > 0) {
|
|
1198
1218
|
runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
|
|
1199
1219
|
}
|
|
1200
|
-
|
|
1220
|
+
let candidateHead = revParse(input, "HEAD");
|
|
1201
1221
|
if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
|
|
1202
1222
|
const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
|
|
1203
1223
|
const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
|
|
@@ -1233,58 +1253,59 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
1233
1253
|
};
|
|
1234
1254
|
}
|
|
1235
1255
|
let rebaseCount = 0;
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
if (push.exitCode === 0) {
|
|
1239
|
-
const publishedHead = revParse(input, "HEAD") ?? candidateHead;
|
|
1240
|
-
if (publishedHead) assertPublishedCanonicalCheckoutTree(input, publishedHead, sampledCanonicalCheckoutTreeHash);
|
|
1241
|
-
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
1242
|
-
return {
|
|
1243
|
-
...observed,
|
|
1244
|
-
outcome: "published",
|
|
1245
|
-
candidateHead: candidateHead ?? void 0,
|
|
1246
|
-
publishedHead: publishedHead ?? void 0,
|
|
1247
|
-
rebaseCount,
|
|
1248
|
-
gitStatus: gitStatus(input)
|
|
1249
|
-
};
|
|
1250
|
-
}
|
|
1251
|
-
if (!isNonFastForward(push)) {
|
|
1252
|
-
return {
|
|
1253
|
-
...observed,
|
|
1254
|
-
outcome: "failed",
|
|
1255
|
-
candidateHead: candidateHead ?? void 0,
|
|
1256
|
-
rebaseCount,
|
|
1257
|
-
error: push.stderr || push.stdout || "Workspace push failed",
|
|
1258
|
-
gitStatus: gitStatus(input)
|
|
1259
|
-
};
|
|
1260
|
-
}
|
|
1261
|
-
runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch workspace after push race");
|
|
1256
|
+
const expectedHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
1257
|
+
if (candidateHead && expectedHead && !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", expectedHead, candidateHead])) {
|
|
1262
1258
|
const rebase = runGitResult(input, input.shadowRoot, ["rebase", `origin/${WORKSPACE_BRANCH}`]);
|
|
1263
1259
|
rebaseCount += 1;
|
|
1264
1260
|
if (rebase.exitCode !== 0) {
|
|
1265
1261
|
const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
|
|
1266
|
-
const
|
|
1267
|
-
|
|
1262
|
+
const conflictPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
|
|
1263
|
+
runGit(input, input.shadowRoot, ["rebase", "--abort"], "preserve workspace candidate after rebase conflict");
|
|
1268
1264
|
return {
|
|
1269
1265
|
...observed,
|
|
1270
|
-
outcome: "
|
|
1266
|
+
outcome: "failed",
|
|
1267
|
+
expectedHead,
|
|
1271
1268
|
candidateHead: candidateHead ?? void 0,
|
|
1272
|
-
publishedHead: publishedHead ?? void 0,
|
|
1273
1269
|
rebaseCount,
|
|
1274
|
-
|
|
1275
|
-
|
|
1270
|
+
affectedPaths: conflictPaths,
|
|
1271
|
+
error: `Workspace candidate conflicts with canonical head ${expectedHead}; local changes were preserved for reconciliation.`,
|
|
1276
1272
|
gitStatus: gitStatus(input)
|
|
1277
1273
|
};
|
|
1278
1274
|
}
|
|
1275
|
+
candidateHead = revParse(input, "HEAD");
|
|
1279
1276
|
assertPublishedCanonicalCheckoutTree(input, "HEAD", sampledCanonicalCheckoutTreeHash);
|
|
1280
1277
|
assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, "HEAD");
|
|
1281
1278
|
}
|
|
1279
|
+
if (!candidateHead) {
|
|
1280
|
+
return {
|
|
1281
|
+
...observed,
|
|
1282
|
+
outcome: "failed",
|
|
1283
|
+
rebaseCount,
|
|
1284
|
+
error: "Workspace candidate does not have a commit",
|
|
1285
|
+
gitStatus: gitStatus(input)
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
const upload = uploadWorkspaceCandidate(input, candidateHead);
|
|
1289
|
+
if (upload.exitCode !== 0) {
|
|
1290
|
+
return {
|
|
1291
|
+
...observed,
|
|
1292
|
+
outcome: "failed",
|
|
1293
|
+
candidateHead,
|
|
1294
|
+
quarantineRef: input.quarantineRef,
|
|
1295
|
+
rebaseCount,
|
|
1296
|
+
error: upload.stderr || upload.stdout || "Workspace candidate upload failed",
|
|
1297
|
+
gitStatus: gitStatus(input)
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
assertPublishedCanonicalCheckoutTree(input, candidateHead, sampledCanonicalCheckoutTreeHash);
|
|
1301
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
1282
1302
|
return {
|
|
1283
1303
|
...observed,
|
|
1284
|
-
outcome: "
|
|
1285
|
-
|
|
1304
|
+
outcome: "candidate_ready",
|
|
1305
|
+
expectedHead,
|
|
1306
|
+
candidateHead,
|
|
1307
|
+
quarantineRef: input.quarantineRef,
|
|
1286
1308
|
rebaseCount,
|
|
1287
|
-
error: "Workspace push did not converge after three attempts",
|
|
1288
1309
|
gitStatus: gitStatus(input)
|
|
1289
1310
|
};
|
|
1290
1311
|
} catch (error) {
|
|
@@ -1327,9 +1348,10 @@ function calculateWorkspaceDiffFingerprint(rawInput) {
|
|
|
1327
1348
|
return createHash("sha256").update(JSON.stringify({ stagedTree, localHead, remoteHead })).digest("hex");
|
|
1328
1349
|
}
|
|
1329
1350
|
class WorkspaceSyncSingleFlight {
|
|
1351
|
+
mutationGate = new WorkspaceMutationGate();
|
|
1330
1352
|
queue = Promise.resolve();
|
|
1331
1353
|
enqueue(task) {
|
|
1332
|
-
const queued = this.
|
|
1354
|
+
const queued = this.mutationGate.runSync(task);
|
|
1333
1355
|
this.queue = queued.then(
|
|
1334
1356
|
() => void 0,
|
|
1335
1357
|
() => void 0
|
|
@@ -1348,6 +1370,12 @@ class WorkspaceSyncSingleFlight {
|
|
|
1348
1370
|
fingerprintPrepared(prepare) {
|
|
1349
1371
|
return this.enqueue(() => calculateWorkspaceDiffFingerprint(prepare()));
|
|
1350
1372
|
}
|
|
1373
|
+
runMutation(operation) {
|
|
1374
|
+
return this.mutationGate.runMutation(operation);
|
|
1375
|
+
}
|
|
1376
|
+
acquireMutation() {
|
|
1377
|
+
return this.mutationGate.acquireMutation();
|
|
1378
|
+
}
|
|
1351
1379
|
afterCurrent() {
|
|
1352
1380
|
return this.queue;
|
|
1353
1381
|
}
|
|
@@ -1355,10 +1383,12 @@ class WorkspaceSyncSingleFlight {
|
|
|
1355
1383
|
export {
|
|
1356
1384
|
MAX_WORKSPACE_SYNC_DIFF_BYTES,
|
|
1357
1385
|
WORKSPACE_BRANCH,
|
|
1386
|
+
WORKSPACE_INTENT_REF_PREFIX,
|
|
1358
1387
|
WORKSPACE_NATIVE_PLANS_RELATIVE_PATH,
|
|
1359
1388
|
WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
|
|
1360
1389
|
WorkspaceSyncSingleFlight,
|
|
1361
1390
|
assertCanonicalCheckoutIndexMatchesWorktree,
|
|
1391
|
+
assertWorkspaceQuarantineRef,
|
|
1362
1392
|
calculateWorkspaceDiffFingerprint,
|
|
1363
1393
|
encodeWorkspaceBranch,
|
|
1364
1394
|
mirrorShadowWorkspaceToVisible,
|
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
export declare const WORKER_HEARTBEAT_INTERVAL_MS = 15000;
|
|
2
2
|
export declare const WORKER_HEARTBEAT_TIMEOUT_MS = 45000;
|
|
3
3
|
export declare function hasWorkerHeartbeatTimedOut(lastHeartbeatAt: number | null, now?: number, timeoutMs?: number): boolean;
|
|
4
|
+
export type WorkerHeartbeatBusyGrace = {
|
|
5
|
+
heartbeatAt: number;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
};
|
|
8
|
+
/** Grant one bounded grace period for one actual server-heartbeat generation. */
|
|
9
|
+
export declare function grantWorkerHeartbeatBusyGrace(lastHeartbeatAt: number | null, currentGrace: WorkerHeartbeatBusyGrace | null, now?: number, timeoutMs?: number, graceMs?: number): WorkerHeartbeatBusyGrace | null;
|
|
10
|
+
export declare function hasWorkerHeartbeatTimedOutWithBusyGrace(lastHeartbeatAt: number | null, busyGrace: WorkerHeartbeatBusyGrace | null, now?: number, timeoutMs?: number): boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type WorkspaceIncidentUpdate = {
|
|
2
|
+
incidentId: string | null;
|
|
3
|
+
status: "remediating" | "waiting_for_worker" | "resolved" | "confirmed" | "reset" | null;
|
|
4
|
+
};
|
|
5
|
+
export declare function applyWorkspaceIncidentUpdate(currentIncidentId: string | null, update: WorkspaceIncidentUpdate): string | null;
|
|
6
|
+
export declare function releasePendingWorkspaceHead(previousIncidentId: string | null, currentIncidentId: string | null, terminalIncidentId?: string | null): {
|
|
7
|
+
syncHead: string | null;
|
|
8
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type WorkspaceMutationLease = () => void;
|
|
2
|
+
/**
|
|
3
|
+
* Coordinates the visible workspace with its disposable sync shadow.
|
|
4
|
+
*
|
|
5
|
+
* Ordinary worktree activity may overlap other ordinary activity, but a sync
|
|
6
|
+
* gets exclusive access. Once a sync is queued, later mutations wait behind it
|
|
7
|
+
* so a busy shell cannot starve canonical publication indefinitely.
|
|
8
|
+
*/
|
|
9
|
+
export declare class WorkspaceMutationGate {
|
|
10
|
+
private activeMutations;
|
|
11
|
+
private syncActive;
|
|
12
|
+
private readonly waiters;
|
|
13
|
+
acquireMutation(): Promise<WorkspaceMutationLease>;
|
|
14
|
+
runMutation<T>(operation: () => Promise<T> | T): Promise<T>;
|
|
15
|
+
runSync<T>(operation: () => Promise<T> | T): Promise<T>;
|
|
16
|
+
private drain;
|
|
17
|
+
private releaseMutation;
|
|
18
|
+
private releaseSync;
|
|
19
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type CheckoutPathSegments } from "./managed-paths";
|
|
2
|
+
import { type WorkspaceMutationLease } from "./workspace-mutation-gate";
|
|
2
3
|
export declare const WORKSPACE_BRANCH = "main";
|
|
4
|
+
export declare const WORKSPACE_INTENT_REF_PREFIX = "refs/r5d/workspace-intents/";
|
|
3
5
|
export declare const MAX_WORKSPACE_SYNC_DIFF_BYTES: number;
|
|
4
6
|
export declare const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5000;
|
|
5
7
|
export type WorkspaceProjectManifestEntry = {
|
|
@@ -30,7 +32,7 @@ export type WorkspaceSyncTrigger = {
|
|
|
30
32
|
canonicalCheckoutOnly?: boolean;
|
|
31
33
|
detail?: string;
|
|
32
34
|
};
|
|
33
|
-
export type WorkspaceSyncOutcome = "no_change" | "published" | "updated" | "conflict_reset" | "large_diff_blocked" | "reset" | "failed";
|
|
35
|
+
export type WorkspaceSyncOutcome = "no_change" | "candidate_ready" | "published" | "updated" | "conflict_reset" | "large_diff_blocked" | "reset" | "failed";
|
|
34
36
|
export type WorkspaceSyncResult = {
|
|
35
37
|
type: "workspace_sync";
|
|
36
38
|
attemptId: string;
|
|
@@ -38,7 +40,9 @@ export type WorkspaceSyncResult = {
|
|
|
38
40
|
trigger: WorkspaceSyncTrigger;
|
|
39
41
|
outcome: WorkspaceSyncOutcome;
|
|
40
42
|
startingHead: string | null;
|
|
43
|
+
expectedHead?: string | null;
|
|
41
44
|
candidateHead?: string;
|
|
45
|
+
quarantineRef?: string;
|
|
42
46
|
publishedHead?: string;
|
|
43
47
|
rebaseCount: number;
|
|
44
48
|
diffSizeBytes: number;
|
|
@@ -60,6 +64,11 @@ export type WorkspaceSyncInput = {
|
|
|
60
64
|
shadowRoot: string;
|
|
61
65
|
projects: WorkspaceProjectManifestEntry[];
|
|
62
66
|
trigger: WorkspaceSyncTrigger;
|
|
67
|
+
/**
|
|
68
|
+
* Server-issued, attempt-scoped destination for candidate object uploads.
|
|
69
|
+
* Workers never publish the canonical workspace branch directly.
|
|
70
|
+
*/
|
|
71
|
+
quarantineRef?: string;
|
|
63
72
|
confirmedLargeDiff?: boolean;
|
|
64
73
|
confirmationReason?: string;
|
|
65
74
|
resetToCanonical?: boolean;
|
|
@@ -91,15 +100,19 @@ type GitlinkProjection = {
|
|
|
91
100
|
export declare function assertCanonicalCheckoutIndexMatchesWorktree(checkoutPath: string): void;
|
|
92
101
|
export declare function mirrorVisibleWorkspaceToShadow(input: WorkspaceSyncInput, excludedCheckouts?: ReadonlySet<string>): GitlinkProjection;
|
|
93
102
|
export declare function mirrorShadowWorkspaceToVisible(input: WorkspaceSyncInput, opaqueWorkspaceRoots?: readonly string[]): void;
|
|
103
|
+
export declare function assertWorkspaceQuarantineRef(quarantineRef: string): void;
|
|
94
104
|
export declare function synchronizeWorkspace(rawInput: WorkspaceSyncInput): Promise<WorkspaceSyncResult>;
|
|
95
105
|
export declare function calculateWorkspaceDiffFingerprint(rawInput: WorkspaceSyncInput): string;
|
|
96
106
|
export declare class WorkspaceSyncSingleFlight {
|
|
107
|
+
private readonly mutationGate;
|
|
97
108
|
private queue;
|
|
98
109
|
private enqueue;
|
|
99
110
|
run(input: WorkspaceSyncInput): Promise<WorkspaceSyncResult>;
|
|
100
111
|
runPrepared(prepare: () => WorkspaceSyncInput): Promise<WorkspaceSyncResult>;
|
|
101
112
|
fingerprint(input: WorkspaceSyncInput): Promise<string>;
|
|
102
113
|
fingerprintPrepared(prepare: () => WorkspaceSyncInput): Promise<string>;
|
|
114
|
+
runMutation<T>(operation: () => Promise<T> | T): Promise<T>;
|
|
115
|
+
acquireMutation(): Promise<WorkspaceMutationLease>;
|
|
103
116
|
afterCurrent(): Promise<void>;
|
|
104
117
|
}
|
|
105
118
|
export {};
|