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,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
|
+
}
|