canopycms 0.12.1 → 0.13.0
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/CHANGELOG.md +211 -49
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +112 -23
- package/dist/api/branch-status.d.ts +11 -2
- package/dist/api/branch-status.js +3 -2
- package/dist/api/branch-update-from-base.js +57 -6
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +27 -2
- package/dist/api/client.d.ts +2 -2
- package/dist/api/client.js +2 -2
- package/dist/api/content.js +1 -1
- package/dist/api/merge-conflicts.d.ts +10 -1
- package/dist/api/merge-conflicts.js +42 -7
- package/dist/api/types.d.ts +3 -2
- package/dist/base-update.d.ts +6 -0
- package/dist/branch-metadata-file.d.ts +15 -0
- package/dist/branch-metadata-file.js +5 -1
- package/dist/cli/cli.js +238 -108
- package/dist/cli/generate-ai-content.js +20 -6
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +7 -1
- package/dist/editor/Editor.js +39 -16
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +4 -0
- package/dist/editor/base-update-wait.js +7 -0
- package/dist/editor/components/ConflictsGroup.d.ts +13 -1
- package/dist/editor/components/ConflictsGroup.js +52 -12
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +11 -7
- package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
- package/dist/editor/components/EntryChangesDrawer.js +58 -6
- package/dist/editor/conflict-cards.d.ts +4 -1
- package/dist/editor/conflict-cards.js +14 -9
- package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
- package/dist/editor/hooks/useBaseUpdate.js +12 -2
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +80 -6
- package/dist/editor/hooks/useDraftManager.d.ts +8 -1
- package/dist/editor/hooks/useDraftManager.js +16 -1
- package/dist/editor/hooks/useEntryManager.d.ts +8 -3
- package/dist/editor/hooks/useEntryManager.js +6 -5
- package/dist/git-manager.d.ts +1 -0
- package/dist/git-manager.js +8 -3
- package/dist/merge/entry-merge.js +4 -3
- package/dist/merge/list-alignment.d.ts +9 -6
- package/dist/merge/list-alignment.js +65 -16
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/operating-mode/client-safe-strategy.js +1 -1
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +10 -2
- package/dist/services.d.ts +12 -4
- package/dist/services.js +56 -0
- package/dist/types.d.ts +35 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +27 -3
- package/dist/worker/branch-merge.js +106 -5
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +21 -0
- package/dist/worker/cms-worker.js +29 -4
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +17 -2
- package/dist/worker/field-merge.js +95 -2
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +9 -0
- package/dist/worker/github-commits.d.ts +3 -3
- package/dist/worker/github-commits.js +192 -40
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +7 -0
- package/dist/worker/github-gateway-wire.d.ts +30 -10
- package/dist/worker/github-gateway-wire.js +14 -2
- package/dist/worker/github-gateway.d.ts +17 -0
- package/dist/worker/github-gateway.js +37 -1
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +25 -5
- package/dist/worker/rebase.js +225 -81
- package/dist/worker/task-runner.js +16 -39
- package/dist/worker/worker-context.d.ts +6 -0
- package/package.json +1 -1
package/dist/worker/rebase.js
CHANGED
|
@@ -260,9 +260,14 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
260
260
|
`${branchRef}, so the rewrite is not published`);
|
|
261
261
|
return;
|
|
262
262
|
}
|
|
263
|
-
|
|
263
|
+
const publish = await forcePublishToLocalRemote(ctx, branchPath, branchRef, publishedSha, rebasedHead);
|
|
264
|
+
if (publish.kind === 'published') {
|
|
264
265
|
await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
|
|
265
266
|
}
|
|
267
|
+
else if (publish.kind === 'failed') {
|
|
268
|
+
const reason = redactCredentials(`this branch's rebased history could not be published into the deployment's repository: ${publish.message}`);
|
|
269
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, reason, branchRef);
|
|
270
|
+
}
|
|
266
271
|
}
|
|
267
272
|
else {
|
|
268
273
|
const divergence = `rebased locally, but remote.git holds ${publishedSha} for ${branchRef}, which this ` +
|
|
@@ -296,13 +301,13 @@ function worthAnotherCycle(branch) {
|
|
|
296
301
|
}
|
|
297
302
|
/**
|
|
298
303
|
* Answer the branch's request for the latest changes in `lastBaseUpdate`, which ends the editor's
|
|
299
|
-
* wait. Only from a
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
+
* wait. Only from a fetch begun at or after the request, and only once the branch holds the base
|
|
305
|
+
* that fetch found: an earlier fetch may predate the changes asked for, and a base ref that didn't
|
|
306
|
+
* move (a held or failed update) isn't the latest. While the schema gate holds the base the request
|
|
307
|
+
* stands. Anything else that settles nothing, a lock or a GitHub step on its way among them, asks
|
|
308
|
+
* for another early cycle while the request is recent. Never throws.
|
|
304
309
|
*/
|
|
305
|
-
async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome,
|
|
310
|
+
async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, source, requested) {
|
|
306
311
|
const { request } = probe;
|
|
307
312
|
if (!request) {
|
|
308
313
|
// Skipped before its request was read: a provisioning lock held elsewhere.
|
|
@@ -313,7 +318,7 @@ async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitH
|
|
|
313
318
|
}
|
|
314
319
|
return;
|
|
315
320
|
}
|
|
316
|
-
if (!
|
|
321
|
+
if (!source || ctx.ensureStatusReport().baseHold)
|
|
317
322
|
return;
|
|
318
323
|
const askAgain = () => {
|
|
319
324
|
if (worthAnotherCycle({ status: 'editing', baseUpdateRequestedAt: request.requestedAt })) {
|
|
@@ -321,17 +326,16 @@ async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitH
|
|
|
321
326
|
}
|
|
322
327
|
};
|
|
323
328
|
// The request's time is the Lambda's clock and the fetch's this host's: both keep NTP time.
|
|
324
|
-
if (
|
|
329
|
+
if (source.fetchedAt < Date.parse(request.requestedAt))
|
|
325
330
|
return askAgain();
|
|
326
331
|
const answer = answerFor(outcome, probe);
|
|
327
332
|
if (answer === undefined)
|
|
328
333
|
return askAgain();
|
|
329
|
-
|
|
330
|
-
if (answer !== 'failed' && gitHubBase !== undefined && probe.baseSha !== gitHubBase) {
|
|
334
|
+
if (answer !== 'failed' && source.baseTip !== undefined && probe.baseSha !== source.baseTip) {
|
|
331
335
|
return askAgain();
|
|
332
336
|
}
|
|
333
337
|
const lastBaseUpdate = {
|
|
334
|
-
at: new Date(
|
|
338
|
+
at: new Date(source.fetchedAt).toISOString(),
|
|
335
339
|
outcome: answer,
|
|
336
340
|
...(probe.baseSha !== undefined && { baseSha: probe.baseSha }),
|
|
337
341
|
...(answer === 'conflicts' && { keptEntries: probe.keptEntries }),
|
|
@@ -350,19 +354,32 @@ async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitH
|
|
|
350
354
|
workerLogWarn(` ${branchDir}: could not record the answer to its request for the latest changes: ${getErrorMessage(err)}`);
|
|
351
355
|
}
|
|
352
356
|
}
|
|
357
|
+
/** A run with no GitHub publishes nothing there: an empty namespace is a gateway that pushes nothing. */
|
|
358
|
+
const NO_GITHUB_POLICY = {
|
|
359
|
+
configured: [],
|
|
360
|
+
githubDefault: null,
|
|
361
|
+
namespace: '',
|
|
362
|
+
legacy: [],
|
|
363
|
+
settingsBranch: null,
|
|
364
|
+
};
|
|
365
|
+
/** The GitHub fetch's answer source: when it began, and the base it found. */
|
|
366
|
+
const sourceOf = (ctx, gitHub) => ({
|
|
367
|
+
fetchedAt: gitHub.fetchedAt,
|
|
368
|
+
baseTip: gitHub.tips.get(ctx.baseBranch),
|
|
369
|
+
});
|
|
353
370
|
/**
|
|
354
371
|
* Rebase every branch workspace that is behind the base branch, one cycle.
|
|
355
372
|
*
|
|
356
|
-
* The per-branch work is {@link
|
|
357
|
-
* over `content-branches
|
|
358
|
-
*
|
|
359
|
-
*
|
|
373
|
+
* The per-branch work is {@link syncBranchDir}; what stays here is the walk
|
|
374
|
+
* over `content-branches/` and folding each branch's outcome into the summary.
|
|
375
|
+
* `source` answers requests for the latest changes; the GitHub fetch's by default. `local` is a
|
|
376
|
+
* run with no GitHub (see {@link syncBranchDir}).
|
|
360
377
|
*
|
|
361
378
|
* Deliberately sequential rather than concurrent: each branch's rebase holds
|
|
362
379
|
* that branch's cross-host content-write lock and runs a series of git
|
|
363
380
|
* subprocesses against a shared filesystem.
|
|
364
381
|
*/
|
|
365
|
-
export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new Set()) {
|
|
382
|
+
export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new Set(), source = gitHub && sourceOf(ctx, gitHub), local = false) {
|
|
366
383
|
const rebased = [];
|
|
367
384
|
const skippedDirty = [];
|
|
368
385
|
const skippedLocked = [];
|
|
@@ -383,47 +400,9 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new
|
|
|
383
400
|
workerLog(' Rebase cycle stopped: the worker is draining');
|
|
384
401
|
break;
|
|
385
402
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
// (.canopy-meta/, transient lock dirs). Skipped SILENTLY so the no-.git
|
|
389
|
-
// skip logs below don't fire for them every cycle; everything else still
|
|
390
|
-
// logs loudly on skip.
|
|
391
|
-
if (branchDir.startsWith('.') || branchDir === 'branches.json') {
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
const branchPath = path.join(ctx.contentBranchesPath, branchDir);
|
|
395
|
-
const gitDir = path.join(branchPath, '.git');
|
|
396
|
-
try {
|
|
397
|
-
const stat = await fs.stat(gitDir);
|
|
398
|
-
if (!stat.isDirectory()) {
|
|
399
|
-
workerLog(` Skipping ${branchDir}: .git is not a directory`);
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
catch {
|
|
404
|
-
workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
|
|
405
|
-
continue;
|
|
406
|
-
}
|
|
407
|
-
// The base branch's own clone is refreshed ff-only by
|
|
408
|
-
// refreshBaseBranchWorkspace() earlier in syncGit(). Routing it through
|
|
409
|
-
// this loop could rewrite its history (the rebase below) and stamp
|
|
410
|
-
// meaningless conflict metadata on it. Compare
|
|
411
|
-
// sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
|
|
412
|
-
// is raw.
|
|
413
|
-
if (branchDir === ctx.sanitizedBaseBranch) {
|
|
414
|
-
workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
|
|
403
|
+
const outcome = await syncBranchDir(ctx, branchDir, gatewayPolicy, gitHub, source, requested.has(branchDir), {}, local);
|
|
404
|
+
if (outcome === null)
|
|
415
405
|
continue;
|
|
416
|
-
}
|
|
417
|
-
// A settings branch is an orphan: rebasing it onto the base would give it content
|
|
418
|
-
// history. The API refuses to provision one, so a directory under that name is a
|
|
419
|
-
// leftover and is left alone.
|
|
420
|
-
if (isSettingsBranchName(branchDir, ctx.ensureSettingsBranch())) {
|
|
421
|
-
workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
const probe = {};
|
|
425
|
-
const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe);
|
|
426
|
-
await answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitHub, requested.has(branchDir));
|
|
427
406
|
if (outcome.kind === 'rebased')
|
|
428
407
|
rebased.push(branchDir);
|
|
429
408
|
else if (outcome.kind === 'skippedDirty')
|
|
@@ -440,6 +419,82 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new
|
|
|
440
419
|
}
|
|
441
420
|
return { rebased, skippedDirty, skippedLocked, failed };
|
|
442
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* One entry of `content-branches/`: the structural pre-filter (entries that are not branch
|
|
424
|
+
* workspaces at all, the base branch's own clone, a settings branch), then
|
|
425
|
+
* {@link holdAndRebaseOneBranch} and the answer to its request. Null for an entry skipped before
|
|
426
|
+
* any of that. Never throws. A `local` run has no GitHub: it publishes nothing there, and
|
|
427
|
+
* `remote.git` is where a branch's work is published.
|
|
428
|
+
*/
|
|
429
|
+
async function syncBranchDir(ctx, branchDir, gatewayPolicy, gitHub, source, requested, probe, local = false) {
|
|
430
|
+
// Known structural entries under content-branches/, not branch workspaces:
|
|
431
|
+
// branches.json (registry snapshot) and dot-prefixed entries
|
|
432
|
+
// (.canopy-meta/, transient lock dirs). Skipped SILENTLY so the no-.git
|
|
433
|
+
// skip logs below don't fire for them every cycle; everything else still
|
|
434
|
+
// logs loudly on skip.
|
|
435
|
+
if (branchDir.startsWith('.') || branchDir === 'branches.json')
|
|
436
|
+
return null;
|
|
437
|
+
const branchPath = path.join(ctx.contentBranchesPath, branchDir);
|
|
438
|
+
const gitDir = path.join(branchPath, '.git');
|
|
439
|
+
try {
|
|
440
|
+
const stat = await fs.stat(gitDir);
|
|
441
|
+
if (!stat.isDirectory()) {
|
|
442
|
+
workerLog(` Skipping ${branchDir}: .git is not a directory`);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
// The base branch's own clone is refreshed ff-only by
|
|
451
|
+
// refreshBaseBranchWorkspace() earlier in syncGit(). Routing it through
|
|
452
|
+
// this loop could rewrite its history (the rebase below) and stamp
|
|
453
|
+
// meaningless conflict metadata on it. Compare
|
|
454
|
+
// sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
|
|
455
|
+
// is raw.
|
|
456
|
+
if (branchDir === ctx.sanitizedBaseBranch) {
|
|
457
|
+
workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
// A settings branch is an orphan: rebasing it onto the base would give it content
|
|
461
|
+
// history. The API refuses to provision one, so a directory under that name is a
|
|
462
|
+
// leftover and is left alone.
|
|
463
|
+
if (isSettingsBranchName(branchDir, ctx.ensureSettingsBranch())) {
|
|
464
|
+
workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, local ? NO_GITHUB_POLICY : gatewayPolicy, gitHub, probe, local);
|
|
468
|
+
await answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, source, requested);
|
|
469
|
+
return outcome;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Bring the base into one branch now and answer its request for the latest changes, for a mode that
|
|
473
|
+
* runs no worker loop (operating-mode `baseUpdateRunsInRequest`). With no GitHub, the base is
|
|
474
|
+
* `remote.git`'s, so the answer is from this run's start. A run that answers nothing writes
|
|
475
|
+
* `failed`, since nothing else ever will: `busy` says a lock held it back, which a retry in a moment
|
|
476
|
+
* gets past. Never throws but for the metadata write of that fallback.
|
|
477
|
+
*/
|
|
478
|
+
export async function syncBranchNow(ctx, branchName) {
|
|
479
|
+
const startedAt = Date.now();
|
|
480
|
+
const branchDir = sanitizeBranchName(branchName);
|
|
481
|
+
const probe = {};
|
|
482
|
+
const outcome = await syncBranchDir(ctx, branchDir, undefined, undefined, { fetchedAt: startedAt }, true, probe, true);
|
|
483
|
+
const busy = outcome?.kind === 'skippedLocked';
|
|
484
|
+
const branchPath = path.join(ctx.contentBranchesPath, branchDir);
|
|
485
|
+
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchName &&
|
|
486
|
+
existing.branch.status === 'editing' &&
|
|
487
|
+
existing.branch.baseUpdateRequestedAt !== undefined &&
|
|
488
|
+
Date.parse(existing.branch.baseUpdateRequestedAt) <= startedAt &&
|
|
489
|
+
isBaseUpdatePending(existing.branch)
|
|
490
|
+
? {
|
|
491
|
+
branch: {
|
|
492
|
+
lastBaseUpdate: { at: new Date(startedAt).toISOString(), outcome: 'failed' },
|
|
493
|
+
},
|
|
494
|
+
}
|
|
495
|
+
: null, { keepUpdatedAt: true });
|
|
496
|
+
return busy ? 'busy' : 'answered';
|
|
497
|
+
}
|
|
443
498
|
/**
|
|
444
499
|
* The ref a branch clone's HEAD names, or null for a detached or unreadable one. Read from the
|
|
445
500
|
* file rather than through git, so it runs before `assertSharedRepoConfig` vets the clone.
|
|
@@ -491,7 +546,7 @@ async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namesp
|
|
|
491
546
|
* content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
|
|
492
547
|
* Never throws, like rebaseOneBranch.
|
|
493
548
|
*/
|
|
494
|
-
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe) {
|
|
549
|
+
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe, local) {
|
|
495
550
|
let hold;
|
|
496
551
|
try {
|
|
497
552
|
hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
|
|
@@ -511,18 +566,62 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy,
|
|
|
511
566
|
if (hold.kind === 'not-provisioned')
|
|
512
567
|
return { kind: 'none' };
|
|
513
568
|
try {
|
|
514
|
-
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub, probe);
|
|
569
|
+
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub, probe, local);
|
|
515
570
|
}
|
|
516
571
|
finally {
|
|
517
572
|
await releaseProvisionedWorkspace(hold.release, branchDir);
|
|
518
573
|
}
|
|
519
574
|
}
|
|
520
|
-
/**
|
|
521
|
-
|
|
575
|
+
/**
|
|
576
|
+
* Clear a recorded failure and any recorded rewrite, the caller knowing neither stands, with
|
|
577
|
+
* `alsoClear` in the same write.
|
|
578
|
+
*/
|
|
579
|
+
async function clearRebaseFailure(ctx, branchPath, branchRef, alsoClear = {}) {
|
|
522
580
|
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === branchRef
|
|
523
|
-
? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch) } }
|
|
581
|
+
? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch), ...alsoClear } }
|
|
524
582
|
: null);
|
|
525
583
|
}
|
|
584
|
+
/**
|
|
585
|
+
* {@link recordLatestChangesWaiting} for a branch the sync skips for its saved work: marked while it
|
|
586
|
+
* lacks the base `remote.git` holds. Read from `remote.git` rather than fetched, so the skip moves no
|
|
587
|
+
* ref. Best-effort, so the skip stays a skip.
|
|
588
|
+
*/
|
|
589
|
+
async function noteLatestChangesWaiting(ctx, git, branchPath, metaFile) {
|
|
590
|
+
if (metaFile.branch.status !== 'editing')
|
|
591
|
+
return;
|
|
592
|
+
let baseTip;
|
|
593
|
+
let behind;
|
|
594
|
+
try {
|
|
595
|
+
baseTip = await readPublishedSha(ctx, ctx.baseBranch);
|
|
596
|
+
behind = baseTip !== null && !(await contains(git, 'HEAD', baseTip));
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
workerLogWarn(` Could not compare ${escapeForLog(metaFile.branch.name)} with the base: ${getErrorMessage(err)}`);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
await recordLatestChangesWaiting(ctx, branchPath, metaFile, behind ? (baseTip ?? undefined) : undefined);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Mark the branch `metaFile` names as lacking `baseSha`, the base commit the sync first left out, or
|
|
606
|
+
* clear the mark when undefined: the automatic sync never commits saved work, so a branch holding
|
|
607
|
+
* some waits for its author's "Get latest changes" (base-update.ts). Written only when the mark comes
|
|
608
|
+
* or goes, as every save regenerates the branch registry, and as bookkeeping, which leaves
|
|
609
|
+
* `updatedAt`. Best-effort: a failure leaves the hint as it was until the next cycle.
|
|
610
|
+
*/
|
|
611
|
+
async function recordLatestChangesWaiting(ctx, branchPath, metaFile, baseSha) {
|
|
612
|
+
const { name } = metaFile.branch;
|
|
613
|
+
const flips = (current) => (current === undefined) !== (baseSha === undefined);
|
|
614
|
+
if (!flips(metaFile.branch.latestChangesWaiting))
|
|
615
|
+
return;
|
|
616
|
+
try {
|
|
617
|
+
await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === name && flips(current.branch.latestChangesWaiting)
|
|
618
|
+
? { branch: { latestChangesWaiting: baseSha } }
|
|
619
|
+
: null, { keepUpdatedAt: true });
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
workerLogWarn(` Could not record the latest changes waiting for ${escapeForLog(name)}: ${getErrorMessage(err)}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
526
625
|
/** A branch github-commits.ts left for a person: logged, recorded, and in the cycle's summary. */
|
|
527
626
|
async function recordBlocked(ctx, branchDir, branchPath, branchRef, reason) {
|
|
528
627
|
// [REDACT] failed[] folds into worker-status.json, served to the browser, and these reasons name
|
|
@@ -809,7 +908,7 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
|
|
|
809
908
|
* returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
|
|
810
909
|
* rest of the cycle ([SYNC-M2]).
|
|
811
910
|
*/
|
|
812
|
-
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub, probe = {}) {
|
|
911
|
+
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub, probe = {}, local = false) {
|
|
813
912
|
// Read only by the catch below. See the `rebased` rider on
|
|
814
913
|
// BranchRebaseOutcome.
|
|
815
914
|
let didRebase = false;
|
|
@@ -832,6 +931,8 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
832
931
|
// - archived: finished, with no PR left to poll.
|
|
833
932
|
if (metaFile !== null && (branchStatus === 'submitted' || branchStatus === 'approved')) {
|
|
834
933
|
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
934
|
+
// Submit committed the saved work; a withdraw lets the next skip mark it again.
|
|
935
|
+
await recordLatestChangesWaiting(ctx, branchPath, metaFile);
|
|
835
936
|
await reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised);
|
|
836
937
|
const outcome = await bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub);
|
|
837
938
|
const pullRequest = await pollMergeState(ctx, branchDir, branchPath, metaFile);
|
|
@@ -999,6 +1100,27 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
999
1100
|
lockLost: lockCompromised,
|
|
1000
1101
|
}
|
|
1001
1102
|
: null;
|
|
1103
|
+
const skipIfDirty = async () => {
|
|
1104
|
+
if (editorDirt.length === 0)
|
|
1105
|
+
return { kind: 'none' };
|
|
1106
|
+
if (metaFile !== null)
|
|
1107
|
+
await noteLatestChangesWaiting(ctx, branchGit, branchPath, metaFile);
|
|
1108
|
+
return { kind: 'skippedDirty' };
|
|
1109
|
+
};
|
|
1110
|
+
// A merge of GitHub's commits whose result holds the base `remote.git` holds answers a request
|
|
1111
|
+
// for the latest changes as a merge of the base does, conflicts included.
|
|
1112
|
+
const tookInByGitHubMerge = async (keptEntries) => {
|
|
1113
|
+
if (editorDirt.length > 0)
|
|
1114
|
+
return await skipIfDirty();
|
|
1115
|
+
const baseTip = await readPublishedSha(ctx, ctx.baseBranch).catch(() => null);
|
|
1116
|
+
const holdsBase = baseTip !== null && (await contains(branchGit, 'HEAD', baseTip).catch(() => false));
|
|
1117
|
+
if (!holdsBase)
|
|
1118
|
+
return { kind: 'none' };
|
|
1119
|
+
probe.baseSha = baseTip;
|
|
1120
|
+
probe.keptEntries = keptEntries;
|
|
1121
|
+
await clearRebaseFailure(ctx, branchPath, branchRef, { latestChangesWaiting: undefined });
|
|
1122
|
+
return { kind: 'rebased' };
|
|
1123
|
+
};
|
|
1002
1124
|
// Before the dirty return: a fast-forward passes saves that touch other files.
|
|
1003
1125
|
let unpushed = false;
|
|
1004
1126
|
if (commitArgs !== null) {
|
|
@@ -1008,20 +1130,21 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1008
1130
|
}
|
|
1009
1131
|
if (outcome.kind === 'unpushed')
|
|
1010
1132
|
unpushed = true;
|
|
1011
|
-
else if (outcome.kind
|
|
1012
|
-
return
|
|
1013
|
-
|
|
1133
|
+
else if (outcome.kind === 'merged')
|
|
1134
|
+
return await tookInByGitHubMerge(outcome.keptEntries);
|
|
1135
|
+
else if (outcome.kind !== 'in-step')
|
|
1136
|
+
return await skipIfDirty();
|
|
1014
1137
|
// A merge of the base this worker published and never moved the clone to comes first.
|
|
1015
1138
|
const caughtUp = await catchUpWithPublished(ctx, commitArgs);
|
|
1016
1139
|
if (caughtUp?.kind === 'blocked') {
|
|
1017
1140
|
return recordBlocked(ctx, branchDir, branchPath, branchRef, caughtUp.reason);
|
|
1018
1141
|
}
|
|
1019
1142
|
if (caughtUp)
|
|
1020
|
-
return
|
|
1143
|
+
return await skipIfDirty();
|
|
1021
1144
|
}
|
|
1022
1145
|
if (editorDirt.length > 0) {
|
|
1023
1146
|
workerLog(` Skipping ${branchDir}: has uncommitted changes`);
|
|
1024
|
-
return
|
|
1147
|
+
return await skipIfDirty();
|
|
1025
1148
|
}
|
|
1026
1149
|
// `git rebase` refuses to start over ANY modified tracked file, so
|
|
1027
1150
|
// tracked canopycms state blocks. The worker never untracks it here:
|
|
@@ -1043,21 +1166,32 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1043
1166
|
await recordRebaseFailure(ctx, branchPath, branchDir, reason);
|
|
1044
1167
|
return { kind: 'failed', error: reason };
|
|
1045
1168
|
}
|
|
1046
|
-
// [SYNC-H1] Self-heal: finish
|
|
1047
|
-
// branch is not behind base
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
// unmarked branches (nearly all of them, every cycle) cost nothing.
|
|
1169
|
+
// [SYNC-H1] Self-heal: finish a publish that never left remote.git at
|
|
1170
|
+
// the marker's commit, even when this branch is not behind base -- a
|
|
1171
|
+
// crash in the arming sequence below, or a failed push. Gated on the
|
|
1172
|
+
// loop-top snapshot, so unmarked branches (nearly all of them, every
|
|
1173
|
+
// cycle) cost nothing.
|
|
1052
1174
|
if (canPublish && metaFile?.branch.historyRewrittenFrom) {
|
|
1053
|
-
await reconcilePendingRewrite(ctx, {
|
|
1175
|
+
const pending = await reconcilePendingRewrite(ctx, {
|
|
1054
1176
|
branchPath,
|
|
1055
1177
|
branchDir,
|
|
1056
1178
|
branchRef,
|
|
1057
1179
|
headSha: (await branchGit.revparse(['HEAD'])).trim(),
|
|
1058
1180
|
marker: metaFile.branch.historyRewrittenFrom,
|
|
1181
|
+
target: metaFile.branch.historyRewrittenTo,
|
|
1059
1182
|
gatewayPolicy,
|
|
1060
1183
|
});
|
|
1184
|
+
// A rebase now would replace the rewrite remote.git still waits for, along with any commit
|
|
1185
|
+
// made on top of it since, which only a submit may publish.
|
|
1186
|
+
if (pending.kind === 'failed') {
|
|
1187
|
+
// [REDACT] failed[] is served to the browser via worker-status.json.
|
|
1188
|
+
const reason = redactCredentials(pending.reason);
|
|
1189
|
+
workerLogWarn(` Skipping ${branchDir}: ${reason}`);
|
|
1190
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, reason, branchRef);
|
|
1191
|
+
return { kind: 'failed', error: reason };
|
|
1192
|
+
}
|
|
1193
|
+
if (pending.kind === 'waiting')
|
|
1194
|
+
return { kind: 'none' };
|
|
1061
1195
|
}
|
|
1062
1196
|
await fetchFromRemoteGit(branchGit, ctx.remoteGitPath, ctx.baseBranch);
|
|
1063
1197
|
// rev-list, not status.behind, which needs an upstream tracking branch
|
|
@@ -1088,6 +1222,8 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1088
1222
|
// whose cause stands stays: remote.git still holds a commit this clone
|
|
1089
1223
|
// lacks, and the next submit is refused for it.
|
|
1090
1224
|
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
1225
|
+
if (currentMeta !== null)
|
|
1226
|
+
await recordLatestChangesWaiting(ctx, branchPath, currentMeta);
|
|
1091
1227
|
if (currentMeta?.branch.rebaseFailure === undefined) {
|
|
1092
1228
|
return { kind: 'none' };
|
|
1093
1229
|
}
|
|
@@ -1110,13 +1246,17 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1110
1246
|
const head = (await branchGit.revparse(['HEAD'])).trim();
|
|
1111
1247
|
// Commits on top of what this branch published, as "Get latest changes" makes from saved work
|
|
1112
1248
|
// on a branch submitted once, or a submit whose push failed: a rebase would rewrite the
|
|
1113
|
-
// published commit under them, which reads as a push this clone never saw. A merge
|
|
1114
|
-
// them
|
|
1249
|
+
// published commit under them, which reads as a push this clone never saw. A merge keeps
|
|
1250
|
+
// them, unpublished until the submit pushes it (publish-decision.ts). A same-named ref at a
|
|
1251
|
+
// commit the base holds is no work of this branch.
|
|
1115
1252
|
const published = canPublish ? await readPublishedSha(ctx, branchRef) : null;
|
|
1253
|
+
// With no GitHub, a commit of the branch's in remote.git is published however it got there.
|
|
1116
1254
|
const aheadOfPublished = published !== null &&
|
|
1117
|
-
published !== head &&
|
|
1118
1255
|
metaFile?.branch.name === branchRef &&
|
|
1119
|
-
|
|
1256
|
+
(local ||
|
|
1257
|
+
(published !== head &&
|
|
1258
|
+
// A rewrite's tip is this branch's own publish, on GitHub or not.
|
|
1259
|
+
(isPublished(metaFile) || published === metaFile.branch.historyRewrittenTo))) &&
|
|
1120
1260
|
(await contains(branchGit, head, published)) &&
|
|
1121
1261
|
!(await contains(branchGit, fetchedBaseTip, published));
|
|
1122
1262
|
/**
|
|
@@ -1149,7 +1289,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1149
1289
|
return { kind: 'skippedLocked' };
|
|
1150
1290
|
}
|
|
1151
1291
|
probe.keptEntries = outcome.keptEntries;
|
|
1152
|
-
await clearRebaseFailure(ctx, branchPath, branchRef);
|
|
1292
|
+
await clearRebaseFailure(ctx, branchPath, branchRef, { latestChangesWaiting: undefined });
|
|
1153
1293
|
return { kind: 'rebased' };
|
|
1154
1294
|
};
|
|
1155
1295
|
if (commitArgs !== null && (cannotReplay || unpushed || aheadOfPublished)) {
|
|
@@ -1175,6 +1315,9 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1175
1315
|
const publishedSha = published;
|
|
1176
1316
|
await assertNoIncomingSubmodules(branchGit, branchPath, preRebaseHead, fetchedBaseTip);
|
|
1177
1317
|
const { completed, conflicted, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
|
|
1318
|
+
// The tip the rebase made, read before anything else awaits: past a lost lock a commit can
|
|
1319
|
+
// land on it, and only this tip may be published as the rewrite.
|
|
1320
|
+
const rebasedHead = (await branchGit.revparse(['HEAD'])).trim();
|
|
1178
1321
|
// Outside the round loop's try/catch, so a throwing test hook can
|
|
1179
1322
|
// never be misread as a rebase error.
|
|
1180
1323
|
if (completed)
|
|
@@ -1248,6 +1391,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1248
1391
|
branch: {
|
|
1249
1392
|
conflictStatus: (existing.branch.conflictFiles?.length ?? 0) > 0 ? 'conflicts-detected' : 'clean',
|
|
1250
1393
|
rebaseFailure: undefined,
|
|
1394
|
+
latestChangesWaiting: undefined,
|
|
1251
1395
|
},
|
|
1252
1396
|
}
|
|
1253
1397
|
: null);
|
|
@@ -1258,7 +1402,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
1258
1402
|
branchRef,
|
|
1259
1403
|
publishedSha,
|
|
1260
1404
|
preRebaseHead,
|
|
1261
|
-
rebasedHead
|
|
1405
|
+
rebasedHead,
|
|
1262
1406
|
gatewayPolicy,
|
|
1263
1407
|
});
|
|
1264
1408
|
return { kind: 'rebased' };
|
|
@@ -16,6 +16,7 @@ import { writeWorkerStatus } from '../task-queue/worker-status.js';
|
|
|
16
16
|
import { SYNC_PR_COMMENTS_ACTION, refreshCommentsBlock, renderPrCommentsBody } from '../pr-comments.js';
|
|
17
17
|
import { SYNC_BRANCH_ACTION } from '../base-update.js';
|
|
18
18
|
import { COMMIT_ID, REWRITTEN_PUSH_REFUSAL, SEEN_COMMIT_GONE_PUSH_REFUSAL, enqueueReviewBlockSync, } from './github-commits.js';
|
|
19
|
+
import { getHttpStatus, isRateLimitSignal403 } from './github-errors.js';
|
|
19
20
|
import { workerLog, workerLogError, workerLogWarn } from './log.js';
|
|
20
21
|
import { assertSharedRepoConfig } from './shared-repo-git.js';
|
|
21
22
|
/**
|
|
@@ -85,45 +86,6 @@ export function isPermanentTaskFailure(err) {
|
|
|
85
86
|
return false;
|
|
86
87
|
return status >= 400 && status < 500;
|
|
87
88
|
}
|
|
88
|
-
/** Extract an HTTP status from an error, if present (Octokit RequestError shape). */
|
|
89
|
-
function getHttpStatus(err) {
|
|
90
|
-
if (err instanceof Error && 'status' in err) {
|
|
91
|
-
const status = err.status;
|
|
92
|
-
if (typeof status === 'number')
|
|
93
|
-
return status;
|
|
94
|
-
}
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
/** Narrow an unknown value to a response-headers record, if present (Octokit lowercases header names). */
|
|
98
|
-
function getResponseHeaders(err) {
|
|
99
|
-
if (typeof err !== 'object' || err === null || !('response' in err))
|
|
100
|
-
return null;
|
|
101
|
-
const response = err.response;
|
|
102
|
-
if (typeof response !== 'object' || response === null || !('headers' in response))
|
|
103
|
-
return null;
|
|
104
|
-
const headers = response.headers;
|
|
105
|
-
if (typeof headers !== 'object' || headers === null)
|
|
106
|
-
return null;
|
|
107
|
-
return headers;
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Whether a 403 is a GitHub rate-limit response rather than a plain permission
|
|
111
|
-
* denial. GitHub signals rate limiting on 403s three ways: the primary limit
|
|
112
|
-
* zeroes `x-ratelimit-remaining`, secondary limits often carry `retry-after`,
|
|
113
|
-
* and both produce a message containing "rate limit".
|
|
114
|
-
*/
|
|
115
|
-
function isRateLimitSignal403(err) {
|
|
116
|
-
const headers = getResponseHeaders(err);
|
|
117
|
-
if (headers) {
|
|
118
|
-
if (headers['x-ratelimit-remaining'] === '0')
|
|
119
|
-
return true;
|
|
120
|
-
if (typeof headers['retry-after'] === 'string' && headers['retry-after'].length > 0)
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
if (err instanceof Error && /rate limit/i.test(err.message))
|
|
124
|
-
return true;
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
89
|
// Payload validation helpers — fail fast with clear errors instead of silent `as` casts
|
|
128
90
|
/**
|
|
129
91
|
* Whether `branch` carries the reserved settings-branch prefix, which routes a submit to a push
|
|
@@ -602,6 +564,7 @@ export async function updateBranchMetadata(ctx, task, result) {
|
|
|
602
564
|
return;
|
|
603
565
|
}
|
|
604
566
|
let found = false;
|
|
567
|
+
let newPullRequest = false;
|
|
605
568
|
// Bookkeeping: the save that queued this already said when the branch last changed.
|
|
606
569
|
await meta.update((existing) => {
|
|
607
570
|
if (existing?.branch.name !== branch)
|
|
@@ -626,6 +589,7 @@ export async function updateBranchMetadata(ctx, task, result) {
|
|
|
626
589
|
if (result.prUrl && !archived)
|
|
627
590
|
updates.pullRequestUrl = result.prUrl;
|
|
628
591
|
if (result.prNumber && !archived) {
|
|
592
|
+
newPullRequest = existing.branch.pullRequestNumber !== result.prNumber;
|
|
629
593
|
updates.pullRequestNumber = result.prNumber;
|
|
630
594
|
// As soon as the PR exists the field reads 'open', rather than staying
|
|
631
595
|
// absent until the next poll cycle observes it. Exception: the git-sync
|
|
@@ -641,6 +605,9 @@ export async function updateBranchMetadata(ctx, task, result) {
|
|
|
641
605
|
}, { keepUpdatedAt: true });
|
|
642
606
|
if (!found)
|
|
643
607
|
noMetadataToRecord(branch, `${task.action} outcome`);
|
|
608
|
+
// The sync its push woke may have run before the number was recorded, and skipped the branch.
|
|
609
|
+
if (newPullRequest)
|
|
610
|
+
ctx.requestBranchSync(branch);
|
|
644
611
|
}
|
|
645
612
|
catch (err) {
|
|
646
613
|
workerLogError(`Failed to update metadata for ${branch}:`, err instanceof Error ? err.message : err);
|
|
@@ -848,6 +815,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
848
815
|
// has moved past the rewritten commit and the marker is spent.
|
|
849
816
|
await clearHistoryRewrittenMarker(ctx, branchPath, branch);
|
|
850
817
|
await recordPushedToGitHub(ctx, branchPath, branch, outgoingSha);
|
|
818
|
+
wakeForConflictsStatus(ctx, branch, outcome);
|
|
851
819
|
logPushOutcome(ctx, branch, outcome, 'GitHub had already moved past the rewritten commit');
|
|
852
820
|
return;
|
|
853
821
|
}
|
|
@@ -859,8 +827,17 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
859
827
|
await clearHistoryRewrittenMarker(ctx, branchPath, branch);
|
|
860
828
|
}
|
|
861
829
|
await recordPushedToGitHub(ctx, branchPath, branch, outgoingSha);
|
|
830
|
+
wakeForConflictsStatus(ctx, branch, outcome);
|
|
862
831
|
logPushOutcome(ctx, branch, outcome);
|
|
863
832
|
}
|
|
833
|
+
/**
|
|
834
|
+
* A pull request's new head has no conflicts status until a git sync sets one
|
|
835
|
+
* (worker/conflicts-status.ts), and a required check waits on it: wake the sync for the branch.
|
|
836
|
+
*/
|
|
837
|
+
function wakeForConflictsStatus(ctx, branch, outcome) {
|
|
838
|
+
if (outcome.moved && !isSettingsBranch(branch))
|
|
839
|
+
ctx.requestBranchSync(branch);
|
|
840
|
+
}
|
|
864
841
|
/** An unmoved ref is routine (a task re-run after a crash), so it is logged at debug level. */
|
|
865
842
|
function logPushOutcome(ctx, branch, outcome, note) {
|
|
866
843
|
const suffix = note === undefined ? '' : `; ${note}`;
|
|
@@ -110,6 +110,12 @@ export interface WorkerContext {
|
|
|
110
110
|
* restarted worker warns afresh.
|
|
111
111
|
*/
|
|
112
112
|
warnedDivergences(): Map<string, string>;
|
|
113
|
+
/**
|
|
114
|
+
* The conflicts status this worker last set on each branch's pull request head, by branch name,
|
|
115
|
+
* and the tip each `not-head:` key last woke a sync for (worker/conflicts-status.ts). Held per
|
|
116
|
+
* instance, so a restarted worker sets each once again.
|
|
117
|
+
*/
|
|
118
|
+
conflictsStatusesSet(): Map<string, string>;
|
|
113
119
|
/**
|
|
114
120
|
* Run the git sync early, coalesced: once it has rested `EARLY_RUN_MIN_REST_MS` (cms-worker.ts),
|
|
115
121
|
* or its own interval if shorter, counting from when the last cycle ended, so a request during a
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
|
|
3
3
|
"name": "canopycms",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.0",
|
|
5
5
|
"description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|