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.
Files changed (60) hide show
  1. package/README.md +1 -3
  2. package/dist/ai/generate.d.ts +13 -0
  3. package/dist/ai/generate.js +9 -3
  4. package/dist/ai/to-plain-text.js +4 -0
  5. package/dist/ai/types.d.ts +20 -1
  6. package/dist/api/admin-branch-health.js +13 -12
  7. package/dist/api/branch.js +2 -2
  8. package/dist/branch-health.js +1 -1
  9. package/dist/branch-metadata-file.d.ts +58 -0
  10. package/dist/branch-metadata-file.js +65 -0
  11. package/dist/branch-metadata.d.ts +2 -26
  12. package/dist/branch-metadata.js +6 -33
  13. package/dist/branch-registry.js +4 -2
  14. package/dist/build/generate-ai-content.js +103 -0
  15. package/dist/cli/cli.js +432 -317
  16. package/dist/cli/generate-ai-content.js +264 -160
  17. package/dist/content-id-index.js +22 -0
  18. package/dist/content-listing.js +3 -0
  19. package/dist/content-reader.d.ts +25 -1
  20. package/dist/content-reader.js +15 -0
  21. package/dist/content-store.d.ts +50 -1
  22. package/dist/content-store.js +102 -4
  23. package/dist/context.d.ts +36 -4
  24. package/dist/context.js +15 -4
  25. package/dist/editor/admin/SystemHealthPanel.js +2 -2
  26. package/dist/entry-schema.d.ts +22 -0
  27. package/dist/git-manager.d.ts +9 -8
  28. package/dist/git-manager.js +35 -9
  29. package/dist/github-service.d.ts +1 -1
  30. package/dist/github-service.js +1 -1
  31. package/dist/paths/branch-name.d.ts +1 -1
  32. package/dist/paths/branch-name.js +1 -1
  33. package/dist/schema/index.d.ts +1 -1
  34. package/dist/schema/index.js +1 -1
  35. package/dist/schema/meta-loader.js +1 -1
  36. package/dist/services.d.ts +22 -0
  37. package/dist/types.d.ts +1 -1
  38. package/dist/url-exclusivity-fixtures.d.ts +76 -0
  39. package/dist/url-exclusivity-fixtures.js +119 -0
  40. package/dist/url-path-resolver.js +8 -4
  41. package/dist/utils/content-write-lock.d.ts +2 -1
  42. package/dist/utils/content-write-lock.js +2 -1
  43. package/dist/utils/error.d.ts +17 -0
  44. package/dist/utils/error.js +17 -0
  45. package/dist/utils/git.d.ts +1 -1
  46. package/dist/utils/git.js +1 -1
  47. package/dist/utils/occ-json-write.js +1 -1
  48. package/dist/worker/cms-worker.d.ts +22 -288
  49. package/dist/worker/cms-worker.js +118 -1995
  50. package/dist/worker/git-sync.d.ts +166 -0
  51. package/dist/worker/git-sync.js +554 -0
  52. package/dist/worker/history-rewrite.d.ts +129 -0
  53. package/dist/worker/history-rewrite.js +216 -0
  54. package/dist/worker/rebase.d.ts +171 -0
  55. package/dist/worker/rebase.js +859 -0
  56. package/dist/worker/task-runner.d.ts +93 -0
  57. package/dist/worker/task-runner.js +529 -0
  58. package/dist/worker/worker-context.d.ts +133 -0
  59. package/dist/worker/worker-context.js +1 -0
  60. package/package.json +1 -1
@@ -0,0 +1,93 @@
1
+ import type { Task } from './task-queue.js';
2
+ import type { WorkerContext } from './worker-context.js';
3
+ /**
4
+ * The task-queue cluster: everything reachable from `CmsWorker.processTaskQueue()`,
5
+ * the loop that drains tasks Lambda enqueued because it has no internet.
6
+ *
7
+ * One of the four disjoint call trees that used to share cms-worker.ts. It owns
8
+ * the GitHub-facing side of the worker -- pushing branches, creating and
9
+ * updating PRs, and recording the outcome on branch metadata -- plus the
10
+ * permanent-vs-transient failure classification that decides whether a failed
11
+ * task is retried or fails fast.
12
+ *
13
+ * Shares nothing with the git-sync cluster but the four resolved paths, the
14
+ * Octokit client and the history-rewrite marker (history-rewrite.ts). Note the
15
+ * two loops run CONCURRENTLY: `scheduleLoop` drives this on `taskPollInterval`
16
+ * (default 5s) and syncGit on `gitSyncInterval` (default 5min), so anything
17
+ * here that reads branch metadata written by the rebase loop must re-read it
18
+ * rather than trust a snapshot.
19
+ */
20
+ export type TaskRunnerContext = Pick<WorkerContext, 'githubOwner' | 'githubRepo' | 'baseBranch' | 'sanitizedBaseBranch' | 'taskDir' | 'remoteGitPath' | 'contentBranchesPath' | 'taskTimeoutMs' | 'maxTasksPerCycle' | 'maxRetries' | 'log' | 'octokit' | 'buildGitHubUrl' | 'branchWorkspacePath' | 'executeTask' | 'pushBranchToGitHub' | 'isRunning' | 'ensureStatusReport'>;
21
+ /**
22
+ * An error inherent to the task itself (malformed payload, unknown action):
23
+ * retrying can never succeed, so the task should fail fast instead of
24
+ * burning its retry budget.
25
+ */
26
+ export declare class PermanentTaskError extends Error {
27
+ }
28
+ /**
29
+ * Classify a task failure as permanent (fail fast) or transient (retry).
30
+ *
31
+ * Transient — worth retrying with backoff:
32
+ * - network errors / anything without an HTTP status (git failures included:
33
+ * most push/fetch failures are connectivity or contention and the retry
34
+ * budget bounds the pathological cases)
35
+ * - HTTP 408 (request timeout) and 429 (rate limited)
36
+ * - HTTP 403 that carries a rate-limit signal (see `isRateLimitSignal403`):
37
+ * GitHub returns 403, not 429, for both primary and secondary/abuse rate
38
+ * limits. The throttling plugin (see github-service.ts createCanopyOctokit)
39
+ * proactively retries short waits, but this carve-out remains the safety
40
+ * net for waits the plugin gives up on (`shouldRetryRateLimit`/
41
+ * `shouldRetrySecondaryRateLimit`) and for errors it never sees — without
42
+ * it a rate-limited push-and-create-or-update-pr task would fail
43
+ * permanently and wedge the branch (`sync-failed`, no retry).
44
+ * - HTTP 5xx (server-side, usually recovers)
45
+ *
46
+ * Permanent — retrying the identical request cannot succeed:
47
+ * - PermanentTaskError (malformed payload, unknown action)
48
+ * - other HTTP 4xx (e.g. 401/404/422): the request itself is bad
49
+ * - plain HTTP 403 with no rate-limit signal: a real permission denial
50
+ */
51
+ export declare function isPermanentTaskFailure(err: unknown): boolean;
52
+ /**
53
+ * Staleness threshold for recoverOrphanedTasks, derived from the
54
+ * configured task timeout rather than fixed: the safety argument for
55
+ * running recovery on every poll cycle is "no legitimately in-flight task
56
+ * can be this old, because executeTaskWithTimeout bounds every attempt by
57
+ * taskTimeoutMs" -- which is only true if this threshold scales with
58
+ * taskTimeoutMs. 2x leaves the same comfortable margin the defaults have
59
+ * (60s timeout vs 5min threshold); the 5-minute floor preserves the
60
+ * long-standing default for a replacement instance's boot window.
61
+ */
62
+ export declare function orphanRecoveryMaxAgeMs(ctx: Pick<TaskRunnerContext, 'taskTimeoutMs'>): number;
63
+ /**
64
+ * Process queued tasks from Lambda.
65
+ * Polls .tasks/pending/ directory and executes each task.
66
+ * Processes up to maxTasksPerCycle tasks per invocation.
67
+ * Retries transient failures with exponential backoff.
68
+ */
69
+ export declare function processTaskQueue(ctx: TaskRunnerContext): Promise<void>;
70
+ /**
71
+ * Execute a task bounded by taskTimeoutMs (DEP-H1). Two layers:
72
+ * - An AbortSignal cancels Octokit HTTP calls promptly.
73
+ * - A Promise.race rejects when the timeout fires, so work that cannot
74
+ * observe the signal (git subprocesses via simple-git) still fails the
75
+ * attempt and the worker moves on instead of stalling forever.
76
+ * pushBranchToGitHub additionally kills stalled git processes via
77
+ * simple-git's block timeout, so a hung push doesn't leak a process.
78
+ */
79
+ export declare function executeTaskWithTimeout(ctx: TaskRunnerContext, task: Task): Promise<Record<string, unknown>>;
80
+ export declare function executeTask(ctx: TaskRunnerContext, task: Task, signal: AbortSignal): Promise<Record<string, unknown>>;
81
+ /**
82
+ * Update branch metadata after successful task completion.
83
+ * Writes PR URL/number and sets syncStatus to 'synced'.
84
+ */
85
+ export declare function updateBranchMetadata(ctx: TaskRunnerContext, task: Task, result: Record<string, unknown>): Promise<void>;
86
+ /**
87
+ * Update branch metadata after permanent task failure.
88
+ * Sets syncStatus to 'sync-failed' and records `error` (already redacted
89
+ * by the caller, processTaskQueue -- see [REDACT] there) as
90
+ * syncFailureReason, so the editor can show WHY, not just that it failed.
91
+ */
92
+ export declare function updateBranchMetadataOnFailure(ctx: TaskRunnerContext, task: Task, error: string): Promise<void>;
93
+ export declare function pushBranchToGitHub(ctx: TaskRunnerContext, branch: string): Promise<void>;
@@ -0,0 +1,529 @@
1
+ import fs from 'node:fs/promises';
2
+ import { simpleGit } from 'simple-git';
3
+ import { completeTask, dequeueTask, failTask, recoverOrphanedTasks, retryTask } from './task-queue.js';
4
+ import { createOrUpdatePullRequest } from '../github-service.js';
5
+ import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
6
+ import { sanitizeBranchName } from '../paths/branch-name.js';
7
+ import { gitNetworkChildEnv } from '../git-manager.js';
8
+ import { getErrorMessage, redactCredentials } from '../utils/error.js';
9
+ import { isNonFastForwardRejection, isStaleLeaseRejection } from '../utils/git.js';
10
+ import { clearHistoryRewrittenMarker, readPublishedSha } from './history-rewrite.js';
11
+ import { writeWorkerStatus } from './worker-status.js';
12
+ import { workerLog, workerLogError } from './log.js';
13
+ /**
14
+ * An error inherent to the task itself (malformed payload, unknown action):
15
+ * retrying can never succeed, so the task should fail fast instead of
16
+ * burning its retry budget.
17
+ */
18
+ export class PermanentTaskError extends Error {
19
+ }
20
+ /**
21
+ * Classify a task failure as permanent (fail fast) or transient (retry).
22
+ *
23
+ * Transient — worth retrying with backoff:
24
+ * - network errors / anything without an HTTP status (git failures included:
25
+ * most push/fetch failures are connectivity or contention and the retry
26
+ * budget bounds the pathological cases)
27
+ * - HTTP 408 (request timeout) and 429 (rate limited)
28
+ * - HTTP 403 that carries a rate-limit signal (see `isRateLimitSignal403`):
29
+ * GitHub returns 403, not 429, for both primary and secondary/abuse rate
30
+ * limits. The throttling plugin (see github-service.ts createCanopyOctokit)
31
+ * proactively retries short waits, but this carve-out remains the safety
32
+ * net for waits the plugin gives up on (`shouldRetryRateLimit`/
33
+ * `shouldRetrySecondaryRateLimit`) and for errors it never sees — without
34
+ * it a rate-limited push-and-create-or-update-pr task would fail
35
+ * permanently and wedge the branch (`sync-failed`, no retry).
36
+ * - HTTP 5xx (server-side, usually recovers)
37
+ *
38
+ * Permanent — retrying the identical request cannot succeed:
39
+ * - PermanentTaskError (malformed payload, unknown action)
40
+ * - other HTTP 4xx (e.g. 401/404/422): the request itself is bad
41
+ * - plain HTTP 403 with no rate-limit signal: a real permission denial
42
+ */
43
+ export function isPermanentTaskFailure(err) {
44
+ if (err instanceof PermanentTaskError)
45
+ return true;
46
+ const status = getHttpStatus(err);
47
+ if (status === null)
48
+ return false;
49
+ if (status === 408 || status === 429)
50
+ return false;
51
+ if (status === 403 && isRateLimitSignal403(err))
52
+ return false;
53
+ return status >= 400 && status < 500;
54
+ }
55
+ /** Extract an HTTP status from an error, if present (Octokit RequestError shape). */
56
+ function getHttpStatus(err) {
57
+ if (err instanceof Error && 'status' in err) {
58
+ const status = err.status;
59
+ if (typeof status === 'number')
60
+ return status;
61
+ }
62
+ return null;
63
+ }
64
+ /** Narrow an unknown value to a response-headers record, if present (Octokit lowercases header names). */
65
+ function getResponseHeaders(err) {
66
+ if (typeof err !== 'object' || err === null || !('response' in err))
67
+ return null;
68
+ const response = err.response;
69
+ if (typeof response !== 'object' || response === null || !('headers' in response))
70
+ return null;
71
+ const headers = response.headers;
72
+ if (typeof headers !== 'object' || headers === null)
73
+ return null;
74
+ return headers;
75
+ }
76
+ /**
77
+ * Detect whether a 403 is a GitHub rate-limit response rather than a plain
78
+ * permission denial. GitHub signals rate limiting on 403s three ways: the
79
+ * primary limit zeroes out `x-ratelimit-remaining`, secondary/abuse limits
80
+ * often include a `retry-after` header, and both cases produce a message
81
+ * containing "rate limit" (e.g. "You have exceeded a secondary rate limit").
82
+ */
83
+ function isRateLimitSignal403(err) {
84
+ const headers = getResponseHeaders(err);
85
+ if (headers) {
86
+ if (headers['x-ratelimit-remaining'] === '0')
87
+ return true;
88
+ if (typeof headers['retry-after'] === 'string' && headers['retry-after'].length > 0)
89
+ return true;
90
+ }
91
+ if (err instanceof Error && /rate limit/i.test(err.message))
92
+ return true;
93
+ return false;
94
+ }
95
+ // Payload validation helpers — fail fast with clear errors instead of silent `as` casts
96
+ function requireString(payload, key) {
97
+ const val = payload[key];
98
+ if (typeof val !== 'string')
99
+ throw new PermanentTaskError(`Task payload missing required string field: ${key}`);
100
+ return val;
101
+ }
102
+ function requireNumber(payload, key) {
103
+ const val = payload[key];
104
+ if (typeof val !== 'number')
105
+ throw new PermanentTaskError(`Task payload missing required number field: ${key}`);
106
+ return val;
107
+ }
108
+ function optionalString(payload, key, fallback) {
109
+ const val = payload[key];
110
+ return typeof val === 'string' ? val : fallback;
111
+ }
112
+ /**
113
+ * Staleness threshold for recoverOrphanedTasks, derived from the
114
+ * configured task timeout rather than fixed: the safety argument for
115
+ * running recovery on every poll cycle is "no legitimately in-flight task
116
+ * can be this old, because executeTaskWithTimeout bounds every attempt by
117
+ * taskTimeoutMs" -- which is only true if this threshold scales with
118
+ * taskTimeoutMs. 2x leaves the same comfortable margin the defaults have
119
+ * (60s timeout vs 5min threshold); the 5-minute floor preserves the
120
+ * long-standing default for a replacement instance's boot window.
121
+ */
122
+ export function orphanRecoveryMaxAgeMs(ctx) {
123
+ return Math.max(5 * 60_000, ctx.taskTimeoutMs * 2);
124
+ }
125
+ /**
126
+ * Process queued tasks from Lambda.
127
+ * Polls .tasks/pending/ directory and executes each task.
128
+ * Processes up to maxTasksPerCycle tasks per invocation.
129
+ * Retries transient failures with exponential backoff.
130
+ */
131
+ export async function processTaskQueue(ctx) {
132
+ if (!ctx.isRunning())
133
+ return;
134
+ // Recover tasks orphaned in processing/ on EVERY cycle, not only at boot
135
+ // (start()'s call above). Before this fix, recoverOrphanedTasks() ran
136
+ // exactly once, at start() - fine when an instance was replaced rarely
137
+ // (a spot interruption), but CanopyCmsService's worker ASG now rolls on
138
+ // every `cdk deploy` (see its UpdatePolicy), making replacement routine.
139
+ // A replacement instance's user-data (yum install git/unzip/nodejs/
140
+ // efs-utils, mount EFS) takes roughly 2-4 minutes, well under
141
+ // recoverOrphanedTasks()'s 5-minute default staleness threshold - so
142
+ // THAT ONE boot-time call would see the just-orphaned file as "too
143
+ // fresh" and skip it, and nothing rescanned afterward: the task (and its
144
+ // branch's syncStatus) would be wedged forever. Re-checking every poll
145
+ // means the file's age eventually crosses the threshold and it gets
146
+ // recovered without operator intervention.
147
+ //
148
+ // Safe to run this often: executeTaskWithTimeout() guarantees every task
149
+ // THIS process dequeues is completed, failed, or retried (all three
150
+ // remove the processing/ file) within taskTimeoutMs - and the staleness
151
+ // threshold is derived from taskTimeoutMs (orphanRecoveryMaxAgeMs below)
152
+ // precisely so that guarantee holds for ANY configured timeout, not just
153
+ // the 60s default: a fixed 5-minute threshold would have this call steal
154
+ // the worker's own still-in-flight task back to pending whenever an
155
+ // adopter configured taskTimeoutMs above ~5 minutes.
156
+ const recovered = await recoverOrphanedTasks(ctx.taskDir, orphanRecoveryMaxAgeMs(ctx), ctx.log);
157
+ if (recovered > 0) {
158
+ workerLog(`Recovered ${recovered} orphaned task(s)`);
159
+ }
160
+ let processed = 0;
161
+ let task;
162
+ while (processed < ctx.maxTasksPerCycle &&
163
+ (task = await dequeueTask(ctx.taskDir, ctx.log)) !== null) {
164
+ try {
165
+ const result = await executeTaskWithTimeout(ctx, task);
166
+ await completeTask(ctx.taskDir, task.id, result, ctx.log);
167
+ await updateBranchMetadata(ctx, task, result);
168
+ }
169
+ catch (err) {
170
+ const message = getErrorMessage(err);
171
+ workerLogError(`Task ${task.id} (${task.action}) failed:`, message);
172
+ // [REDACT] task.error is persisted (pending/failed task JSON) and
173
+ // served to the browser by the admin panel's Tasks tab -- a push
174
+ // failure's message can embed the bot token via buildGitHubUrl().
175
+ // Console output above stays raw (journald/CloudWatch is trusted).
176
+ const persistedMessage = redactCredentials(message);
177
+ // DEP-L1: only transient failures (network, 429/5xx, timeouts) are
178
+ // worth retrying; permanent ones (malformed payload, other 4xx) would
179
+ // just burn the retry budget on an identical doomed request.
180
+ const permanent = isPermanentTaskFailure(err);
181
+ const retryCount = task.retryCount ?? 0;
182
+ const maxRetries = task.maxRetries ?? ctx.maxRetries;
183
+ if (!permanent && retryCount < maxRetries) {
184
+ await retryTask(ctx.taskDir, task.id, persistedMessage, ctx.log);
185
+ workerLog(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
186
+ }
187
+ else {
188
+ await failTask(ctx.taskDir, task.id, persistedMessage, ctx.log);
189
+ await updateBranchMetadataOnFailure(ctx, task, persistedMessage);
190
+ workerLogError(permanent
191
+ ? ' Permanently failed (non-retryable error)'
192
+ : ` Permanently failed after ${maxRetries} retries`);
193
+ }
194
+ }
195
+ processed++;
196
+ }
197
+ // PR-W1: only stamp/write when work actually happened this poll --
198
+ // otherwise every idle 5s poll would hit worker-status.json, an EFS
199
+ // write treadmill for no signal (liveness is already covered by the
200
+ // lock heartbeat; see api/admin.ts's classifyWorkerLiveness).
201
+ if (processed > 0) {
202
+ const report = ctx.ensureStatusReport();
203
+ report.lastTaskCycleAt = new Date().toISOString();
204
+ await writeWorkerStatus(ctx.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
205
+ }
206
+ }
207
+ /**
208
+ * Execute a task bounded by taskTimeoutMs (DEP-H1). Two layers:
209
+ * - An AbortSignal cancels Octokit HTTP calls promptly.
210
+ * - A Promise.race rejects when the timeout fires, so work that cannot
211
+ * observe the signal (git subprocesses via simple-git) still fails the
212
+ * attempt and the worker moves on instead of stalling forever.
213
+ * pushBranchToGitHub additionally kills stalled git processes via
214
+ * simple-git's block timeout, so a hung push doesn't leak a process.
215
+ */
216
+ export async function executeTaskWithTimeout(ctx, task) {
217
+ const controller = new AbortController();
218
+ const timer = setTimeout(() => controller.abort(), ctx.taskTimeoutMs);
219
+ try {
220
+ const work = ctx.executeTask(task, controller.signal);
221
+ // If the timeout wins the race, the losing promise must not surface an
222
+ // unhandled rejection when it eventually settles.
223
+ work.catch(() => { });
224
+ const timedOut = new Promise((_, reject) => {
225
+ controller.signal.addEventListener('abort', () => reject(new Error(`Task timed out after ${ctx.taskTimeoutMs}ms`)), { once: true });
226
+ });
227
+ return await Promise.race([work, timedOut]);
228
+ }
229
+ finally {
230
+ clearTimeout(timer);
231
+ }
232
+ }
233
+ export async function executeTask(ctx, task, signal) {
234
+ const { action, payload } = task;
235
+ switch (action) {
236
+ case 'push-branch': {
237
+ const branch = requireString(payload, 'branch');
238
+ await ctx.pushBranchToGitHub(branch);
239
+ return { pushed: true };
240
+ }
241
+ case 'push-and-create-pr': {
242
+ const branch = requireString(payload, 'branch');
243
+ await ctx.pushBranchToGitHub(branch);
244
+ const pr = await ctx.octokit().pulls.create({
245
+ owner: ctx.githubOwner,
246
+ repo: ctx.githubRepo,
247
+ head: branch,
248
+ base: optionalString(payload, 'baseBranch', ctx.baseBranch),
249
+ title: optionalString(payload, 'title', `Submit ${branch}`),
250
+ body: optionalString(payload, 'body', ''),
251
+ request: { signal },
252
+ });
253
+ workerLog(`Created PR #${pr.data.number} for ${branch}`);
254
+ return { prUrl: pr.data.html_url, prNumber: pr.data.number };
255
+ }
256
+ case 'push-and-update-pr': {
257
+ const branch = requireString(payload, 'branch');
258
+ const prNumber = requireNumber(payload, 'pullRequestNumber');
259
+ await ctx.pushBranchToGitHub(branch);
260
+ await ctx.octokit().pulls.update({
261
+ owner: ctx.githubOwner,
262
+ repo: ctx.githubRepo,
263
+ pull_number: prNumber,
264
+ title: optionalString(payload, 'title', `Submit ${branch}`),
265
+ body: optionalString(payload, 'body', ''),
266
+ request: { signal },
267
+ });
268
+ workerLog(`Updated PR #${prNumber} for ${branch}`);
269
+ return { prNumber };
270
+ }
271
+ case 'push-and-create-or-update-pr': {
272
+ // GIT-H1: idempotent create-or-update. Used for both content-branch
273
+ // submits and settings-branch syncs so a retry after a crash (task
274
+ // completed on GitHub but branch metadata never recorded the PR
275
+ // number) recovers the existing PR instead of hitting the 422 that
276
+ // a blind `pulls.create` would throw on a duplicate head+base.
277
+ // Delegates to the shared helper (also used by GitHubService's
278
+ // direct-API path) so the list->tiebreak->update/create logic and
279
+ // the draft->ready conversion live in one place.
280
+ const branch = requireString(payload, 'branch');
281
+ const base = optionalString(payload, 'baseBranch', ctx.baseBranch);
282
+ // Defense-in-depth: refuse head===base even if the 'submittableBranch'
283
+ // API guard and the syncSubmitPr backstop were both somehow bypassed
284
+ // (e.g. a task queued before this check shipped). PermanentTaskError
285
+ // (not a plain Error) so this fails immediately instead of burning
286
+ // the retry budget on an identical doomed request -- retrying can
287
+ // never make the branch not be the base branch.
288
+ if (sanitizeBranchName(branch) === ctx.sanitizedBaseBranch) {
289
+ throw new PermanentTaskError(`Refusing to push-and-create-or-update-pr for "${branch}": it is the base branch -- submitting the base branch is never valid`);
290
+ }
291
+ await ctx.pushBranchToGitHub(branch);
292
+ const result = await createOrUpdatePullRequest({
293
+ octokit: ctx.octokit(),
294
+ owner: ctx.githubOwner,
295
+ repo: ctx.githubRepo,
296
+ head: branch,
297
+ base,
298
+ title: optionalString(payload, 'title', `Submit ${branch}`),
299
+ body: optionalString(payload, 'body', ''),
300
+ // Content submits (api/github-sync.ts) set this; settings-branch
301
+ // syncs (services.ts) deliberately don't.
302
+ markReadyIfDraft: payload.markReadyIfDraft === true,
303
+ signal,
304
+ });
305
+ workerLog(result.created
306
+ ? `Created PR #${result.number} for ${branch}`
307
+ : `Updated existing PR #${result.number} for ${branch}`);
308
+ return { prUrl: result.url, prNumber: result.number };
309
+ }
310
+ case 'convert-to-draft': {
311
+ const draftPrNumber = requireNumber(payload, 'pullRequestNumber');
312
+ // GitHub REST API doesn't support converting to draft directly.
313
+ // Use the GraphQL API via Octokit.
314
+ const { data: pr } = await ctx.octokit().pulls.get({
315
+ owner: ctx.githubOwner,
316
+ repo: ctx.githubRepo,
317
+ pull_number: draftPrNumber,
318
+ request: { signal },
319
+ });
320
+ await ctx
321
+ .octokit()
322
+ .graphql(`mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`, { id: pr.node_id, request: { signal } });
323
+ workerLog(`Converted PR #${draftPrNumber} to draft`);
324
+ return { prNumber: draftPrNumber, draft: true };
325
+ }
326
+ case 'close-pr': {
327
+ const closePrNumber = requireNumber(payload, 'pullRequestNumber');
328
+ await ctx.octokit().pulls.update({
329
+ owner: ctx.githubOwner,
330
+ repo: ctx.githubRepo,
331
+ pull_number: closePrNumber,
332
+ state: 'closed',
333
+ request: { signal },
334
+ });
335
+ return { closed: true };
336
+ }
337
+ case 'delete-remote-branch': {
338
+ const branch = requireString(payload, 'branch');
339
+ await ctx.octokit().git.deleteRef({
340
+ owner: ctx.githubOwner,
341
+ repo: ctx.githubRepo,
342
+ ref: `heads/${branch}`,
343
+ request: { signal },
344
+ });
345
+ return { deleted: true };
346
+ }
347
+ default:
348
+ throw new PermanentTaskError(`Unknown task action: ${action}`);
349
+ }
350
+ }
351
+ /**
352
+ * Update branch metadata after successful task completion.
353
+ * Writes PR URL/number and sets syncStatus to 'synced'.
354
+ */
355
+ export async function updateBranchMetadata(ctx, task, result) {
356
+ const branch = typeof task.payload.branch === 'string' ? task.payload.branch : null;
357
+ if (!branch)
358
+ return;
359
+ const branchPath = ctx.branchWorkspacePath(branch);
360
+ try {
361
+ await fs.stat(branchPath);
362
+ }
363
+ catch {
364
+ return; // Branch directory doesn't exist
365
+ }
366
+ try {
367
+ const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
368
+ const updates = {
369
+ name: branch,
370
+ syncStatus: 'synced',
371
+ // save()'s merge only overwrites keys present in this update (see
372
+ // BranchMetadataFileManager.save()'s spread order) -- a prior
373
+ // syncFailureReason from an earlier failed attempt would otherwise
374
+ // survive forever past this successful sync. Explicitly clear it,
375
+ // same pattern as rebaseFailure's PR-W2 M2 clearing elsewhere.
376
+ syncFailureReason: undefined,
377
+ };
378
+ if (result.prUrl)
379
+ updates.pullRequestUrl = result.prUrl;
380
+ if (result.prNumber) {
381
+ updates.pullRequestNumber = result.prNumber;
382
+ // As soon as the PR exists the field should read 'open' -- without
383
+ // this it would stay absent until the next poll cycle observes it.
384
+ // Exception: the git-sync loop may have archived this branch (PR
385
+ // merged/closed) while this task was in flight; a late task
386
+ // completion must not downgrade that terminal state back to 'open'.
387
+ const current = await BranchMetadataFileManager.loadOnly(branchPath);
388
+ const terminal = current?.branch.status === 'archived' ||
389
+ current?.branch.pullRequestState === 'merged' ||
390
+ current?.branch.pullRequestState === 'closed';
391
+ if (!terminal) {
392
+ updates.pullRequestState = 'open';
393
+ }
394
+ }
395
+ await meta.save({ branch: updates });
396
+ }
397
+ catch (err) {
398
+ workerLogError(`Failed to update metadata for ${branch}:`, err instanceof Error ? err.message : err);
399
+ }
400
+ }
401
+ /**
402
+ * Update branch metadata after permanent task failure.
403
+ * Sets syncStatus to 'sync-failed' and records `error` (already redacted
404
+ * by the caller, processTaskQueue -- see [REDACT] there) as
405
+ * syncFailureReason, so the editor can show WHY, not just that it failed.
406
+ */
407
+ export async function updateBranchMetadataOnFailure(ctx, task, error) {
408
+ const branch = typeof task.payload.branch === 'string' ? task.payload.branch : null;
409
+ if (!branch)
410
+ return;
411
+ const branchPath = ctx.branchWorkspacePath(branch);
412
+ try {
413
+ await fs.stat(branchPath);
414
+ }
415
+ catch {
416
+ return;
417
+ }
418
+ try {
419
+ const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
420
+ await meta.save({
421
+ branch: { name: branch, syncStatus: 'sync-failed', syncFailureReason: error },
422
+ });
423
+ }
424
+ catch (err) {
425
+ workerLogError(`Failed to update failure metadata for ${branch}:`, err instanceof Error ? err.message : err);
426
+ }
427
+ }
428
+ export async function pushBranchToGitHub(ctx, branch) {
429
+ const git = simpleGit({
430
+ baseDir: ctx.remoteGitPath,
431
+ // DEP-H1: kill the git process if it produces no output for
432
+ // taskTimeoutMs (network stall, credential prompt) instead of letting
433
+ // it hang past the task timeout.
434
+ timeout: { block: ctx.taskTimeoutMs },
435
+ });
436
+ // Force stable (English) git output so isNonFastForwardRejection below
437
+ // can reliably match it -- git's rejection text is gettext-translated, so
438
+ // a non-English host would silently turn that classifier into a no-op.
439
+ // gitNetworkChildEnv (NOT gitChildEnv) because this call talks to GitHub:
440
+ // it keeps ambient HTTPS_PROXY/GIT_SSL_*/GIT_SSH_COMMAND, which
441
+ // gitChildEnv's local-ops allowlist deliberately drops.
442
+ git.env(gitNetworkChildEnv());
443
+ // [SYNC-H1] If the rebase loop rewrote this branch's already-published
444
+ // history, GitHub still holds the commit it replaced, so an ordinary
445
+ // push is non-fast-forward forever. Push under a lease keyed to exactly
446
+ // that commit: it moves GitHub off the commit we rewrote away, and
447
+ // refuses in every other case (including a commit someone else pushed).
448
+ const branchPath = ctx.branchWorkspacePath(branch);
449
+ const metaFile = await BranchMetadataFileManager.loadOnly(branchPath).catch(() => null);
450
+ const marker = metaFile?.branch.historyRewrittenFrom;
451
+ // What this push will actually send: remote.git's tip for the branch.
452
+ const outgoingSha = await readPublishedSha(ctx, branch);
453
+ try {
454
+ if (marker) {
455
+ await git.raw([
456
+ 'push',
457
+ `--force-with-lease=${branch}:${marker}`,
458
+ '--end-of-options',
459
+ ctx.buildGitHubUrl(),
460
+ `${branch}:${branch}`,
461
+ ]);
462
+ }
463
+ else {
464
+ // Pass URL directly to avoid persisting the token in remote.git/config
465
+ await git.push(ctx.buildGitHubUrl(), branch);
466
+ }
467
+ }
468
+ catch (err) {
469
+ const message = getErrorMessage(err);
470
+ // A refused lease means GitHub is not at the commit we rewrote, so the
471
+ // marker is stale -- which is routine, not exceptional: tasks are
472
+ // re-run after a crash (recoverOrphanedTasks), and the marker survives
473
+ // any failure to clear it. Two benign shapes reach here, and both are
474
+ // ordinary fast-forwards that a lease has no business blocking:
475
+ // GitHub already holds the rewritten history from an earlier attempt,
476
+ // or the branch has since moved past it.
477
+ //
478
+ // Retry PLAIN, and let git adjudicate. A non-forced push succeeds if
479
+ // and only if it fast-forwards, so it can never destroy anything --
480
+ // there is no ancestry check to get wrong here, and no extra network
481
+ // round trip to read GitHub's tip. Only if THAT is also rejected has
482
+ // the branch genuinely diverged.
483
+ //
484
+ // (Verified: git evaluates the lease only when it actually has an
485
+ // update to apply. An up-to-date ref with a stale lease prints
486
+ // "Everything up-to-date" and exits 0, so the already-landed case is
487
+ // usually absorbed above and never even reaches this branch.)
488
+ if (marker && isStaleLeaseRejection(message)) {
489
+ try {
490
+ await git.push(ctx.buildGitHubUrl(), branch);
491
+ }
492
+ catch (retryErr) {
493
+ const retryMessage = getErrorMessage(retryErr);
494
+ if (isNonFastForwardRejection(retryMessage)) {
495
+ throw new PermanentTaskError(`Push rejected for branch "${branch}": GitHub's tip is neither the commit this ` +
496
+ `deployment last published nor an ancestor of what it is pushing, so the branch ` +
497
+ `has genuinely diverged and nothing was overwritten. Something else moved it on ` +
498
+ `GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
499
+ }
500
+ throw retryErr;
501
+ }
502
+ // The lease was refused, so GitHub is provably not at the marker: it
503
+ // has moved past the rewritten commit and the marker is spent.
504
+ await clearHistoryRewrittenMarker(ctx, branchPath, branch);
505
+ workerLog(`Pushed ${branch} to GitHub (GitHub had already moved past the rewritten commit)`);
506
+ return;
507
+ }
508
+ // An ordinary non-fast-forward rejection: GitHub has commits this
509
+ // deployment never published. Retrying the identical push can never
510
+ // succeed (DEP-L1's git-failure-is-transient carve-out does NOT apply
511
+ // here), so fail fast instead of burning the task's retry budget.
512
+ // Deliberately does NOT advise renaming the branch: a branch reaching
513
+ // this point usually has an open PR, and renaming would orphan it.
514
+ if (isNonFastForwardRejection(message)) {
515
+ throw new PermanentTaskError(`Push rejected for branch "${branch}": GitHub's tip is not what this deployment last ` +
516
+ `published, so the branch has diverged and needs reconciling. Something else moved it ` +
517
+ `on GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
518
+ }
519
+ throw err;
520
+ }
521
+ // Clear the marker only once GitHub is confirmed to hold something other
522
+ // than the commit we rewrote. A push that sent nothing new (remote.git
523
+ // still at the marker because its own publish has not landed yet) must
524
+ // leave the marker set -- it is the sole trigger for the self-heal pass.
525
+ if (marker && outgoingSha && outgoingSha !== marker) {
526
+ await clearHistoryRewrittenMarker(ctx, branchPath, branch);
527
+ }
528
+ workerLog(`Pushed ${branch} to GitHub`);
529
+ }