canopycms 0.0.65 → 0.0.66-int.82
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/README.md +1 -3
- package/dist/ai/generate.d.ts +13 -0
- package/dist/ai/generate.js +9 -3
- package/dist/ai/to-plain-text.js +4 -0
- package/dist/ai/types.d.ts +20 -1
- package/dist/api/admin-branch-health.js +13 -12
- package/dist/api/branch.js +2 -2
- package/dist/branch-health.js +1 -1
- package/dist/branch-metadata-file.d.ts +58 -0
- package/dist/branch-metadata-file.js +65 -0
- package/dist/branch-metadata.d.ts +2 -26
- package/dist/branch-metadata.js +6 -33
- package/dist/branch-registry.js +4 -2
- package/dist/build/generate-ai-content.js +103 -0
- package/dist/cli/cli.js +432 -317
- package/dist/cli/generate-ai-content.js +264 -160
- package/dist/content-id-index.js +22 -0
- package/dist/content-listing.js +3 -0
- package/dist/content-reader.d.ts +25 -1
- package/dist/content-reader.js +15 -0
- package/dist/content-store.d.ts +50 -1
- package/dist/content-store.js +102 -4
- package/dist/context.d.ts +36 -4
- package/dist/context.js +15 -4
- package/dist/editor/admin/SystemHealthPanel.js +2 -2
- package/dist/entry-schema.d.ts +22 -0
- package/dist/git-manager.d.ts +9 -8
- package/dist/git-manager.js +35 -9
- package/dist/github-service.d.ts +1 -1
- package/dist/github-service.js +1 -1
- package/dist/paths/branch-name.d.ts +1 -1
- package/dist/paths/branch-name.js +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/meta-loader.js +1 -1
- package/dist/services.d.ts +22 -0
- package/dist/types.d.ts +1 -1
- package/dist/url-exclusivity-fixtures.d.ts +76 -0
- package/dist/url-exclusivity-fixtures.js +119 -0
- package/dist/url-path-resolver.js +8 -4
- package/dist/utils/content-write-lock.d.ts +2 -1
- package/dist/utils/content-write-lock.js +2 -1
- package/dist/utils/error.d.ts +17 -0
- package/dist/utils/error.js +17 -0
- package/dist/utils/git.d.ts +1 -1
- package/dist/utils/git.js +1 -1
- package/dist/utils/occ-json-write.js +1 -1
- package/dist/worker/cms-worker.d.ts +22 -288
- package/dist/worker/cms-worker.js +118 -1995
- package/dist/worker/git-sync.d.ts +166 -0
- package/dist/worker/git-sync.js +554 -0
- package/dist/worker/history-rewrite.d.ts +129 -0
- package/dist/worker/history-rewrite.js +216 -0
- package/dist/worker/rebase.d.ts +171 -0
- package/dist/worker/rebase.js +859 -0
- package/dist/worker/task-runner.d.ts +93 -0
- package/dist/worker/task-runner.js +529 -0
- package/dist/worker/worker-context.d.ts +133 -0
- package/dist/worker/worker-context.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { WorkerContext } from './worker-context.js';
|
|
2
|
+
/**
|
|
3
|
+
* [SYNC-H1] The history-rewrite kernel: what happens when the rebase loop
|
|
4
|
+
* rewrites history that was ALREADY published to `remote.git` (and from there
|
|
5
|
+
* to GitHub).
|
|
6
|
+
*
|
|
7
|
+
* Extracted from cms-worker.ts because it is the one thing all three of the
|
|
8
|
+
* worker's big clusters touch, and for opposite-looking reasons:
|
|
9
|
+
*
|
|
10
|
+
* - rebase.ts ARMS a rewrite (mark -> publish -> queue) and completes an
|
|
11
|
+
* interrupted one via `reconcilePendingRewrite`;
|
|
12
|
+
* - task-runner.ts CONSUMES the marker when `pushBranchToGitHub` leases its
|
|
13
|
+
* push, and CLEARS it once GitHub confirms;
|
|
14
|
+
* - git-sync.ts READS it in `reconcileTrackedBranches`, to tell this worker's
|
|
15
|
+
* own in-flight rewrite apart from a genuine cross-deployment collision.
|
|
16
|
+
*
|
|
17
|
+
* Leaving these on the class would have made every cluster's module import
|
|
18
|
+
* every other one. They share no state beyond four resolved paths, so they
|
|
19
|
+
* belong here.
|
|
20
|
+
*
|
|
21
|
+
* THE INVARIANT, since it is spread across those three callers: every force
|
|
22
|
+
* push leases on a SPECIFIC commit this worker knows its own rebase replaced
|
|
23
|
+
* -- the marker, or the pre-rebase tip -- never on "whatever remote.git holds
|
|
24
|
+
* right now". A lease on the current tip is satisfied by a reviewer's direct
|
|
25
|
+
* push to the PR branch and would delete it, silently, from remote.git and
|
|
26
|
+
* then from GitHub.
|
|
27
|
+
*/
|
|
28
|
+
export type HistoryRewriteContext = Pick<WorkerContext, 'remoteGitPath' | 'contentBranchesPath' | 'taskDir' | 'taskTimeoutMs'>;
|
|
29
|
+
/**
|
|
30
|
+
* What `remote.git` currently holds for `branchRef`, or null when this
|
|
31
|
+
* branch was never published there (never submitted) or the ref is
|
|
32
|
+
* unreadable.
|
|
33
|
+
*
|
|
34
|
+
* Uses the same explicit `--git-dir` shape as verifyBaseBranchExists()
|
|
35
|
+
* in cms-worker.ts: reading a bare repo that way does not depend on
|
|
36
|
+
* `safe.bareRepository` being permissive, which is why prod code takes
|
|
37
|
+
* this route rather than the config override the test-only `openBareRepo`
|
|
38
|
+
* helper uses.
|
|
39
|
+
*/
|
|
40
|
+
export declare function readPublishedSha(ctx: Pick<HistoryRewriteContext, 'remoteGitPath'>, branchRef: string): Promise<string | null>;
|
|
41
|
+
/**
|
|
42
|
+
* Record that this worker rewrote `expectedSha` out of a branch's already
|
|
43
|
+
* published history (see BranchMetadata.historyRewrittenFrom).
|
|
44
|
+
*
|
|
45
|
+
* Set-once: if a marker is already present it is LEFT ALONE. Across two
|
|
46
|
+
* rebases before any GitHub push lands, GitHub still holds the commit the
|
|
47
|
+
* FIRST rebase replaced, so advancing the marker would aim the lease at a
|
|
48
|
+
* commit GitHub never had and permanently wedge the branch.
|
|
49
|
+
*
|
|
50
|
+
* Re-reads metadata rather than trusting the caller's loop-top snapshot:
|
|
51
|
+
* the task loop runs concurrently with syncGit() (see scheduleLoop) and may
|
|
52
|
+
* have cleared the marker while this branch was rebasing.
|
|
53
|
+
*/
|
|
54
|
+
export declare function markHistoryRewritten(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchDir: string, expectedSha: string): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Clear the marker once GitHub is confirmed to hold the rewritten history.
|
|
57
|
+
*
|
|
58
|
+
* Best-effort only in the sense that a failure here destroys nothing: the
|
|
59
|
+
* lease still refuses anything unexpected, and the plain-push fallback only
|
|
60
|
+
* ever fast-forwards. It is NOT harmless. A marker that outlives its
|
|
61
|
+
* episode can wedge the NEXT one -- if the base advances before the stale
|
|
62
|
+
* marker is revisited, the queued push leases a commit GitHub has already
|
|
63
|
+
* moved off, falls back to a plain push of a rebased (non-ancestor)
|
|
64
|
+
* history, and fails permanently with a "something else moved it on
|
|
65
|
+
* GitHub" diagnosis that is false: we did.
|
|
66
|
+
*
|
|
67
|
+
* Tracked, with the concurrent-clear race that can drop a marker
|
|
68
|
+
* mid-arming, in
|
|
69
|
+
* .claude/future-tasks/worker-history-rewrite-marker-races.md.
|
|
70
|
+
*/
|
|
71
|
+
export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchDir: string): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Publish a branch clone's rebased history into `remote.git`, replacing
|
|
74
|
+
* EXACTLY `expectedSha` and nothing else.
|
|
75
|
+
*
|
|
76
|
+
* The lease is the entire safety argument. `--force-with-lease=<ref>:<sha>`
|
|
77
|
+
* refuses unless `remote.git` still stands at `<sha>`, so this can only
|
|
78
|
+
* ever undo the commit our own rebase rewrote away. Callers must never pass
|
|
79
|
+
* "whatever remote.git currently holds" -- see the arming guard in
|
|
80
|
+
* rebase.ts's carryForwardRewrittenHistory for the interleaving where that
|
|
81
|
+
* would silently delete a reviewer's direct push.
|
|
82
|
+
*
|
|
83
|
+
* Returns whether the push landed. A refused lease means a concurrent
|
|
84
|
+
* Lambda push moved the ref; that is logged and retried by the self-heal
|
|
85
|
+
* pass on a later cycle, never thrown.
|
|
86
|
+
*/
|
|
87
|
+
export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
|
|
88
|
+
/**
|
|
89
|
+
* Queue the GitHub hop for a branch whose rewritten history now sits in
|
|
90
|
+
* `remote.git`, so an open PR's head follows the rebase within a cycle
|
|
91
|
+
* instead of waiting for the editor's next submit.
|
|
92
|
+
*
|
|
93
|
+
* Deliberately NOT skipped when a marker is already set: inferring "a task
|
|
94
|
+
* must already be queued" from the marker starves this hop whenever a task
|
|
95
|
+
* was lost, failed permanently, or was never written. Duplicate push tasks
|
|
96
|
+
* are bounded by base-branch advances and are idempotent (a repeat push is
|
|
97
|
+
* a no-op once GitHub holds the tip).
|
|
98
|
+
*/
|
|
99
|
+
export declare function enqueueGitHubPush(ctx: Pick<HistoryRewriteContext, 'taskDir'>, branchRef: string): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Complete a rewrite this worker started but did not finish: get the
|
|
102
|
+
* rebased history into `remote.git` and queued for GitHub.
|
|
103
|
+
*
|
|
104
|
+
* Runs from the rebase loop for any branch carrying a marker, whether or
|
|
105
|
+
* not it is behind base this cycle, so an interrupted publish converges
|
|
106
|
+
* without waiting for the next base-branch advance.
|
|
107
|
+
*
|
|
108
|
+
* Always leases on the MARKER, never on remote.git's current tip -- the
|
|
109
|
+
* marker is the one commit we know our own rebase replaced.
|
|
110
|
+
*/
|
|
111
|
+
export declare function reconcilePendingRewrite(ctx: HistoryRewriteContext, options: {
|
|
112
|
+
branchPath: string;
|
|
113
|
+
branchDir: string;
|
|
114
|
+
branchRef: string;
|
|
115
|
+
headSha: string;
|
|
116
|
+
marker: string;
|
|
117
|
+
}): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* Whether this branch carries a pending history rewrite -- the rebase loop
|
|
120
|
+
* rewrote already-published history and the GitHub push has not landed yet
|
|
121
|
+
* (see BranchMetadata.historyRewrittenFrom).
|
|
122
|
+
*
|
|
123
|
+
* `branchName` is a git ref name; branch workspaces are directories named
|
|
124
|
+
* with the sanitized form, hence the conversion. Best-effort: a settings
|
|
125
|
+
* branch (no workspace at all), a missing directory or an unreadable
|
|
126
|
+
* branch.json all mean "no known rewrite", which is the conservative
|
|
127
|
+
* answer -- it keeps the branch in the louder `diverged` bucket.
|
|
128
|
+
*/
|
|
129
|
+
export declare function hasPendingHistoryRewrite(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchName: string): Promise<boolean>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { simpleGit } from 'simple-git';
|
|
3
|
+
import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
4
|
+
import { gitChildEnv } from '../git-manager.js';
|
|
5
|
+
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
6
|
+
import { getErrorMessage, redactCredentials } from '../utils/error.js';
|
|
7
|
+
import { isStaleLeaseRejection } from '../utils/git.js';
|
|
8
|
+
import { enqueueTask, listTasks } from './task-queue.js';
|
|
9
|
+
import { workerLog, workerLogWarn } from './log.js';
|
|
10
|
+
/**
|
|
11
|
+
* What `remote.git` currently holds for `branchRef`, or null when this
|
|
12
|
+
* branch was never published there (never submitted) or the ref is
|
|
13
|
+
* unreadable.
|
|
14
|
+
*
|
|
15
|
+
* Uses the same explicit `--git-dir` shape as verifyBaseBranchExists()
|
|
16
|
+
* in cms-worker.ts: reading a bare repo that way does not depend on
|
|
17
|
+
* `safe.bareRepository` being permissive, which is why prod code takes
|
|
18
|
+
* this route rather than the config override the test-only `openBareRepo`
|
|
19
|
+
* helper uses.
|
|
20
|
+
*/
|
|
21
|
+
export async function readPublishedSha(ctx, branchRef) {
|
|
22
|
+
try {
|
|
23
|
+
const out = await simpleGit().raw([
|
|
24
|
+
'--git-dir',
|
|
25
|
+
ctx.remoteGitPath,
|
|
26
|
+
'rev-parse',
|
|
27
|
+
'--verify',
|
|
28
|
+
`refs/heads/${branchRef}`,
|
|
29
|
+
]);
|
|
30
|
+
const sha = out.trim();
|
|
31
|
+
return sha.length > 0 ? sha : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Absent from remote.git: this branch was never submitted, so none of
|
|
35
|
+
// its history was ever published. Not an error.
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Record that this worker rewrote `expectedSha` out of a branch's already
|
|
41
|
+
* published history (see BranchMetadata.historyRewrittenFrom).
|
|
42
|
+
*
|
|
43
|
+
* Set-once: if a marker is already present it is LEFT ALONE. Across two
|
|
44
|
+
* rebases before any GitHub push lands, GitHub still holds the commit the
|
|
45
|
+
* FIRST rebase replaced, so advancing the marker would aim the lease at a
|
|
46
|
+
* commit GitHub never had and permanently wedge the branch.
|
|
47
|
+
*
|
|
48
|
+
* Re-reads metadata rather than trusting the caller's loop-top snapshot:
|
|
49
|
+
* the task loop runs concurrently with syncGit() (see scheduleLoop) and may
|
|
50
|
+
* have cleared the marker while this branch was rebasing.
|
|
51
|
+
*/
|
|
52
|
+
export async function markHistoryRewritten(ctx, branchPath, branchDir, expectedSha) {
|
|
53
|
+
const current = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
54
|
+
if (typeof current?.branch.historyRewrittenFrom === 'string')
|
|
55
|
+
return;
|
|
56
|
+
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
57
|
+
await meta.save({ branch: { name: branchDir, historyRewrittenFrom: expectedSha } });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Clear the marker once GitHub is confirmed to hold the rewritten history.
|
|
61
|
+
*
|
|
62
|
+
* Best-effort only in the sense that a failure here destroys nothing: the
|
|
63
|
+
* lease still refuses anything unexpected, and the plain-push fallback only
|
|
64
|
+
* ever fast-forwards. It is NOT harmless. A marker that outlives its
|
|
65
|
+
* episode can wedge the NEXT one -- if the base advances before the stale
|
|
66
|
+
* marker is revisited, the queued push leases a commit GitHub has already
|
|
67
|
+
* moved off, falls back to a plain push of a rebased (non-ancestor)
|
|
68
|
+
* history, and fails permanently with a "something else moved it on
|
|
69
|
+
* GitHub" diagnosis that is false: we did.
|
|
70
|
+
*
|
|
71
|
+
* Tracked, with the concurrent-clear race that can drop a marker
|
|
72
|
+
* mid-arming, in
|
|
73
|
+
* .claude/future-tasks/worker-history-rewrite-marker-races.md.
|
|
74
|
+
*/
|
|
75
|
+
export async function clearHistoryRewrittenMarker(ctx, branchPath, branchDir) {
|
|
76
|
+
try {
|
|
77
|
+
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
78
|
+
// Explicit undefined clears the key -- save()'s merge only overwrites
|
|
79
|
+
// keys present in the update (same pattern as rebaseFailure).
|
|
80
|
+
await meta.save({ branch: { name: branchDir, historyRewrittenFrom: undefined } });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
workerLogWarn(` Failed to clear history-rewrite marker for ${branchDir}: ${getErrorMessage(err)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Publish a branch clone's rebased history into `remote.git`, replacing
|
|
88
|
+
* EXACTLY `expectedSha` and nothing else.
|
|
89
|
+
*
|
|
90
|
+
* The lease is the entire safety argument. `--force-with-lease=<ref>:<sha>`
|
|
91
|
+
* refuses unless `remote.git` still stands at `<sha>`, so this can only
|
|
92
|
+
* ever undo the commit our own rebase rewrote away. Callers must never pass
|
|
93
|
+
* "whatever remote.git currently holds" -- see the arming guard in
|
|
94
|
+
* rebase.ts's carryForwardRewrittenHistory for the interleaving where that
|
|
95
|
+
* would silently delete a reviewer's direct push.
|
|
96
|
+
*
|
|
97
|
+
* Returns whether the push landed. A refused lease means a concurrent
|
|
98
|
+
* Lambda push moved the ref; that is logged and retried by the self-heal
|
|
99
|
+
* pass on a later cycle, never thrown.
|
|
100
|
+
*/
|
|
101
|
+
export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expectedSha) {
|
|
102
|
+
// A dedicated instance rather than the caller's: `.env()` replaces the
|
|
103
|
+
// whole child environment, and the rebase loop's instance must keep its
|
|
104
|
+
// ambient one. gitChildEnv (not gitNetworkChildEnv) because this push
|
|
105
|
+
// targets the local bare repo -- and the locale pin is what keeps
|
|
106
|
+
// isStaleLeaseRejection below from silently becoming a no-op.
|
|
107
|
+
const pushGit = simpleGit({ baseDir: branchPath, timeout: { block: ctx.taskTimeoutMs } });
|
|
108
|
+
pushGit.env(gitChildEnv({}));
|
|
109
|
+
try {
|
|
110
|
+
await pushGit.raw([
|
|
111
|
+
'push',
|
|
112
|
+
`--force-with-lease=${branchRef}:${expectedSha}`,
|
|
113
|
+
// Real flags must precede --end-of-options; everything after it is
|
|
114
|
+
// positional (see GitManager.push() for the same guard).
|
|
115
|
+
'--end-of-options',
|
|
116
|
+
'origin',
|
|
117
|
+
`${branchRef}:${branchRef}`,
|
|
118
|
+
]);
|
|
119
|
+
workerLog(` Published rebased ${branchRef} into remote.git`);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const message = redactCredentials(getErrorMessage(err));
|
|
124
|
+
workerLogWarn(isStaleLeaseRejection(message)
|
|
125
|
+
? ` Did not publish rebased ${branchRef} into remote.git: it moved since this cycle read it (concurrent submit?) -- retrying next cycle`
|
|
126
|
+
: ` Failed to publish rebased ${branchRef} into remote.git: ${message}`);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Queue the GitHub hop for a branch whose rewritten history now sits in
|
|
132
|
+
* `remote.git`, so an open PR's head follows the rebase within a cycle
|
|
133
|
+
* instead of waiting for the editor's next submit.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately NOT skipped when a marker is already set: inferring "a task
|
|
136
|
+
* must already be queued" from the marker starves this hop whenever a task
|
|
137
|
+
* was lost, failed permanently, or was never written. Duplicate push tasks
|
|
138
|
+
* are bounded by base-branch advances and are idempotent (a repeat push is
|
|
139
|
+
* a no-op once GitHub holds the tip).
|
|
140
|
+
*/
|
|
141
|
+
export async function enqueueGitHubPush(ctx, branchRef) {
|
|
142
|
+
try {
|
|
143
|
+
// Dedupe against tasks actually in flight rather than against the
|
|
144
|
+
// marker: a branch whose GitHub push keeps failing would otherwise
|
|
145
|
+
// gain one task per sync cycle forever.
|
|
146
|
+
for (const status of ['pending', 'processing']) {
|
|
147
|
+
const inFlight = await listTasks(ctx.taskDir, status);
|
|
148
|
+
if (inFlight.some((t) => t.action === 'push-branch' && t.payload.branch === branchRef)) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
await enqueueTask(ctx.taskDir, { action: 'push-branch', payload: { branch: branchRef } });
|
|
153
|
+
workerLog(` Queued GitHub push for ${branchRef}`);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
workerLogWarn(` Failed to queue GitHub push for ${branchRef}: ${getErrorMessage(err)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Complete a rewrite this worker started but did not finish: get the
|
|
161
|
+
* rebased history into `remote.git` and queued for GitHub.
|
|
162
|
+
*
|
|
163
|
+
* Runs from the rebase loop for any branch carrying a marker, whether or
|
|
164
|
+
* not it is behind base this cycle, so an interrupted publish converges
|
|
165
|
+
* without waiting for the next base-branch advance.
|
|
166
|
+
*
|
|
167
|
+
* Always leases on the MARKER, never on remote.git's current tip -- the
|
|
168
|
+
* marker is the one commit we know our own rebase replaced.
|
|
169
|
+
*/
|
|
170
|
+
export async function reconcilePendingRewrite(ctx, options) {
|
|
171
|
+
const { branchPath, branchDir, branchRef, headSha, marker } = options;
|
|
172
|
+
const publishedSha = await readPublishedSha(ctx, branchRef);
|
|
173
|
+
if (publishedSha === null) {
|
|
174
|
+
workerLogWarn(` ${branchDir}: a rewritten history is recorded but ${branchRef} is gone from remote.git -- nothing to publish`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (publishedSha === headSha) {
|
|
178
|
+
// remote.git already carries the rewrite; only the GitHub hop is left
|
|
179
|
+
// (a crash between the push and the queue, or a task that was lost).
|
|
180
|
+
await enqueueGitHubPush(ctx, branchRef);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (publishedSha === marker) {
|
|
184
|
+
// The publish into remote.git never landed -- a crash right after the
|
|
185
|
+
// marker was written, or a lease refused by a concurrent submit.
|
|
186
|
+
if (await forcePublishToLocalRemote(ctx, branchPath, branchRef, marker)) {
|
|
187
|
+
await enqueueGitHubPush(ctx, branchRef);
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Neither this branch's rebased tip nor the commit its rewrite replaced:
|
|
192
|
+
// something else moved remote.git. Never force over that.
|
|
193
|
+
workerLogWarn(` ${branchDir}: remote.git is at ${publishedSha} for ${branchRef}, which is neither the ` +
|
|
194
|
+
`rebased tip nor the commit the rewrite replaced -- left untouched`);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Whether this branch carries a pending history rewrite -- the rebase loop
|
|
198
|
+
* rewrote already-published history and the GitHub push has not landed yet
|
|
199
|
+
* (see BranchMetadata.historyRewrittenFrom).
|
|
200
|
+
*
|
|
201
|
+
* `branchName` is a git ref name; branch workspaces are directories named
|
|
202
|
+
* with the sanitized form, hence the conversion. Best-effort: a settings
|
|
203
|
+
* branch (no workspace at all), a missing directory or an unreadable
|
|
204
|
+
* branch.json all mean "no known rewrite", which is the conservative
|
|
205
|
+
* answer -- it keeps the branch in the louder `diverged` bucket.
|
|
206
|
+
*/
|
|
207
|
+
export async function hasPendingHistoryRewrite(ctx, branchName) {
|
|
208
|
+
try {
|
|
209
|
+
const branchPath = path.join(ctx.contentBranchesPath, sanitizeBranchName(branchName));
|
|
210
|
+
const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
211
|
+
return typeof metaFile?.branch.historyRewrittenFrom === 'string';
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { type BranchMetadataFile } from '../branch-metadata.js';
|
|
2
|
+
import { type ContentId } from '../paths/types.js';
|
|
3
|
+
import type { WorkerContext } from './worker-context.js';
|
|
4
|
+
/**
|
|
5
|
+
* The rebase loop: the largest and most safety-critical of the four call trees
|
|
6
|
+
* that used to share cms-worker.ts, and the deepest leaf of the git-sync
|
|
7
|
+
* cluster. `syncGit` (git-sync.ts) calls `runRebaseCycle` once per cycle.
|
|
8
|
+
*
|
|
9
|
+
* Every branch workspace that is behind the base branch is rebased onto it,
|
|
10
|
+
* keeping the BRANCH's version of any conflicting file. Three things make this
|
|
11
|
+
* the part of the worker most likely to misbehave under real EFS and spot
|
|
12
|
+
* conditions, and all three are documented at the point they happen rather
|
|
13
|
+
* than here:
|
|
14
|
+
*
|
|
15
|
+
* - it holds the [SYNC-C1] cross-host content-write lock for the whole rebase,
|
|
16
|
+
* so an editor's save cannot be destroyed mid-replay;
|
|
17
|
+
* - it recovers a rebase INTERRUPTED by a crash, OOM or spot interruption,
|
|
18
|
+
* which is lossy in a specific, logged way;
|
|
19
|
+
* - it carries a rewritten history forward into remote.git and GitHub under a
|
|
20
|
+
* lease keyed to the exact commit it replaced ([SYNC-H1], history-rewrite.ts).
|
|
21
|
+
*
|
|
22
|
+
* `pollMergeState` lives here rather than in git-sync.ts because the rebase
|
|
23
|
+
* loop is its only caller: submitted/approved branches are skipped for rebasing
|
|
24
|
+
* but still need their PR's resolution polled, and this loop is the only thing
|
|
25
|
+
* that walks every branch workspace.
|
|
26
|
+
*/
|
|
27
|
+
export type RebaseContext = Pick<WorkerContext, 'githubOwner' | 'githubRepo' | 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'octokit' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting'>;
|
|
28
|
+
/**
|
|
29
|
+
* Per-cycle outcome of `rebaseActiveBranches()` (PR-W1). Folded by `syncGit()`
|
|
30
|
+
* into the worker's self-reported status (`WorkerStatusReport.lastGitSync`,
|
|
31
|
+
* see worker-status.ts) alongside a `durationMs` measured around the whole
|
|
32
|
+
* sync cycle.
|
|
33
|
+
*/
|
|
34
|
+
export interface RebaseSummary {
|
|
35
|
+
/**
|
|
36
|
+
* Branches that were behind and completed a rebase onto the base branch
|
|
37
|
+
* (successfully, whether or not conflicts were resolved via --theirs).
|
|
38
|
+
* Branches that were already up to date are NOT listed here.
|
|
39
|
+
*/
|
|
40
|
+
rebased: string[];
|
|
41
|
+
/** Branches skipped this cycle because their working tree had uncommitted changes. */
|
|
42
|
+
skippedDirty: string[];
|
|
43
|
+
/**
|
|
44
|
+
* [SYNC-C1] Branches skipped this cycle for a content-write-lock reason,
|
|
45
|
+
* either of which is a RETRY rather than a failure:
|
|
46
|
+
*
|
|
47
|
+
* - a content write already held the branch's cross-host content-write lock
|
|
48
|
+
* (utils/content-write-lock.ts) -- the worker yields on contention, since
|
|
49
|
+
* the editor on the other side is a person waiting on a save; or
|
|
50
|
+
* - the lock was LOST mid-rebase (compromised), so the worker stopped before
|
|
51
|
+
* the next destructive git step. Only when the rebase had not completed:
|
|
52
|
+
* a completed rebase must still run its completion path, or it strands the
|
|
53
|
+
* [SYNC-H1] marker on a branch nothing will revisit.
|
|
54
|
+
*/
|
|
55
|
+
skippedLocked: string[];
|
|
56
|
+
/** Branches whose rebase attempt failed (fetch error, unexpected rebase error, or MAX_REBASE_ROUNDS exceeded). */
|
|
57
|
+
failed: {
|
|
58
|
+
branch: string;
|
|
59
|
+
error: string;
|
|
60
|
+
}[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Poll GitHub for a submitted/approved branch's PR resolution.
|
|
64
|
+
*
|
|
65
|
+
* submitted/approved branches sit outside the rebase loop and get no
|
|
66
|
+
* other signal that their PR resolved on GitHub -- nothing pushes a
|
|
67
|
+
* merge/close webhook back into the branch workspace. merged ->
|
|
68
|
+
* auto-archive via buildMergedBranchUpdate (shared with the manual
|
|
69
|
+
* markAsMerged API so both paths produce identical archived-branch
|
|
70
|
+
* metadata). closed-without-merge -> record pullRequestState only; an
|
|
71
|
+
* admin decides the workflow transition from there. Best-effort: any
|
|
72
|
+
* failure here is logged and swallowed, retried next sync cycle.
|
|
73
|
+
*/
|
|
74
|
+
export declare function pollMergeState(ctx: RebaseContext, branchDir: string, branchPath: string, metaFile: BranchMetadataFile | null): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Persist a per-branch rebase failure to branch.json (PR-W2), bounded to
|
|
77
|
+
* roughly one save per failing branch per hour: a branch stuck failing
|
|
78
|
+
* every cycle must not turn into unbounded save-per-cycle x N-failing-
|
|
79
|
+
* branches write amplification -- save() eager-regenerates the branch
|
|
80
|
+
* registry (branch-metadata.ts's invalidateRegistry(), O(branch count) EFS
|
|
81
|
+
* reads), the same concern the `alreadyClean` no-op guard above exists
|
|
82
|
+
* for.
|
|
83
|
+
*
|
|
84
|
+
* Best-effort and non-fatal like every other metadata write in this
|
|
85
|
+
* loop's error paths: a corrupt branch.json, a lock-contention error, or
|
|
86
|
+
* any other save failure here must never abort the per-branch iteration.
|
|
87
|
+
* This matters doubly at the two call sites -- one is inside the outer
|
|
88
|
+
* per-branch catch, with no further catch of its own around this call --
|
|
89
|
+
* so the whole method is wrapped, not just the load.
|
|
90
|
+
*/
|
|
91
|
+
export declare function recordRebaseFailure(ctx: RebaseContext, branchPath: string, branchDir: string, message: string): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Map the file paths a rebase resolved --theirs onto the ContentIds the editor
|
|
94
|
+
* shows as conflicted, deduplicated.
|
|
95
|
+
*
|
|
96
|
+
* IDs rather than paths because they are immutable: a later slug rename must
|
|
97
|
+
* not orphan a conflict marker. Paths that carry no recoverable ID are dropped
|
|
98
|
+
* rather than guessed at.
|
|
99
|
+
*
|
|
100
|
+
* Pure -- the one piece of this loop that can be tested without a git repo.
|
|
101
|
+
*/
|
|
102
|
+
export declare function conflictFilesToContentIds(conflictedFiles: readonly string[], contentRoot: string): ContentId[];
|
|
103
|
+
/**
|
|
104
|
+
* What one branch contributed to the cycle summary -- the return type that
|
|
105
|
+
* replaced `continue` when the per-branch body moved into
|
|
106
|
+
* {@link rebaseOneBranch}.
|
|
107
|
+
*
|
|
108
|
+
* The original loop expressed this by pushing to one of four arrays in scope
|
|
109
|
+
* and then `continue`-ing; there were nine such exits and each pushed to a
|
|
110
|
+
* different array (or to none), which is exactly the sort of thing that is
|
|
111
|
+
* easy to get wrong when the body is 640 lines long. Making it a return value
|
|
112
|
+
* means the compiler checks that every path produces one.
|
|
113
|
+
*/
|
|
114
|
+
export type BranchRebaseOutcome =
|
|
115
|
+
/** Nothing to do: a skipped status, or already up to date. Not reported. */
|
|
116
|
+
{
|
|
117
|
+
kind: 'none';
|
|
118
|
+
}
|
|
119
|
+
/** Was behind and completed a rebase, with or without conflict resolution. */
|
|
120
|
+
| {
|
|
121
|
+
kind: 'rebased';
|
|
122
|
+
}
|
|
123
|
+
/** Working tree had uncommitted changes. Retried next cycle. */
|
|
124
|
+
| {
|
|
125
|
+
kind: 'skippedDirty';
|
|
126
|
+
}
|
|
127
|
+
/** [SYNC-C1] Content-write lock busy or lost mid-rebase. Retried next cycle. */
|
|
128
|
+
| {
|
|
129
|
+
kind: 'skippedLocked';
|
|
130
|
+
} | {
|
|
131
|
+
kind: 'failed';
|
|
132
|
+
error: string;
|
|
133
|
+
/**
|
|
134
|
+
* True when the rebase itself COMPLETED and the branch was already
|
|
135
|
+
* counted as rebased before the failure. Only reachable from the
|
|
136
|
+
* [SYNC-H1] carry-forward block, which is the sole code that runs after
|
|
137
|
+
* that point and can still throw (via markHistoryRewritten's metadata
|
|
138
|
+
* save).
|
|
139
|
+
*
|
|
140
|
+
* Not a tidying-up detail: the original loop pushed to `rebased` and
|
|
141
|
+
* THEN to `failed` in that interleaving, so the branch appeared in both
|
|
142
|
+
* buckets -- and it genuinely is both, because its history moved and
|
|
143
|
+
* publishing the move failed. Collapsing it to `failed` alone would drop
|
|
144
|
+
* a real rebase from worker-status.json.
|
|
145
|
+
*
|
|
146
|
+
* KNOWN COVERAGE GAP, stated rather than hidden: only the `false`
|
|
147
|
+
* direction is exercised (the fetch-error path in cms-worker.test.ts).
|
|
148
|
+
* Reaching the `true` direction needs `markHistoryRewritten`'s metadata
|
|
149
|
+
* save to fail AFTER a completed rebase, and there is no seam for that --
|
|
150
|
+
* the save sits between two other saves with no hook between them.
|
|
151
|
+
* Adding a production hook purely to reach it would cost more than the
|
|
152
|
+
* gap. The placement was instead verified by hand against the pre-split
|
|
153
|
+
* method: `didRebase = true` sits exactly where `rebased.push` sat, after
|
|
154
|
+
* the conflictStatus save and before the carry-forward, so a failing
|
|
155
|
+
* conflictStatus save still yields `failed` alone.
|
|
156
|
+
*/
|
|
157
|
+
rebased?: boolean;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Rebase every branch workspace that is behind the base branch, one cycle.
|
|
161
|
+
*
|
|
162
|
+
* The per-branch work is {@link rebaseOneBranch}; what stays here is the walk
|
|
163
|
+
* over `content-branches/`, the structural pre-filter (entries that are not
|
|
164
|
+
* branch workspaces at all, plus the base branch's own clone), and folding
|
|
165
|
+
* each branch's outcome into the summary.
|
|
166
|
+
*
|
|
167
|
+
* Deliberately sequential rather than concurrent: each branch's rebase holds
|
|
168
|
+
* that branch's cross-host content-write lock and runs a series of git
|
|
169
|
+
* subprocesses against a shared filesystem.
|
|
170
|
+
*/
|
|
171
|
+
export declare function runRebaseCycle(ctx: RebaseContext): Promise<RebaseSummary>;
|