@adhdev/daemon-core 0.9.82-rc.262 → 0.9.82-rc.263
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.d.ts +37 -0
- package/dist/git/git-status.d.ts +7 -0
- package/dist/git/git-types.d.ts +19 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +490 -64
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +486 -64
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +18 -0
- package/dist/mesh/mesh-fast-forward.d.ts +41 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +6 -0
- package/dist/mesh/mesh-work-queue.d.ts +6 -0
- package/package.json +1 -1
- package/src/build-info.ts +73 -0
- package/src/commands/router.ts +49 -2
- package/src/git/git-status.ts +73 -1
- package/src/git/git-types.ts +20 -0
- package/src/index.ts +5 -2
- package/src/mesh/mesh-active-work.ts +31 -0
- package/src/mesh/mesh-fast-forward.ts +418 -17
- package/src/mesh/mesh-ledger.ts +1 -0
- package/src/mesh/mesh-runtime-store.ts +19 -0
- package/src/mesh/mesh-work-queue.ts +13 -0
|
@@ -13,21 +13,54 @@ export interface MeshFastForwardNodeArgs {
|
|
|
13
13
|
submoduleIgnorePaths?: string[];
|
|
14
14
|
timeoutMs?: number;
|
|
15
15
|
trigger?: 'manual' | 'idle_auto' | string;
|
|
16
|
+
/**
|
|
17
|
+
* Operation mode. 'merge' (default) absorbs upstream commits into the local
|
|
18
|
+
* branch via git merge --ff-only (requires ahead=0, behind>0). 'push' publishes
|
|
19
|
+
* local commits to origin via a strict ff-only push (requires HEAD to be a
|
|
20
|
+
* descendant of origin/<branch>); it never force-pushes, resets, or rebases.
|
|
21
|
+
*/
|
|
22
|
+
mode?: 'merge' | 'push';
|
|
23
|
+
/**
|
|
24
|
+
* When mode='push', also fast-forward push submodule (e.g. oss) HEADs to their
|
|
25
|
+
* origin main branch. Gated by allowAutoPublishSubmoduleMainCommits — skipped
|
|
26
|
+
* unless that policy is true. Each submodule must still pass the descendant gate.
|
|
27
|
+
* Defaults false (root push only).
|
|
28
|
+
*/
|
|
29
|
+
pushSubmodules?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Mesh policy flag mirrored from RepoMeshPolicy.allowAutoPublishSubmoduleMainCommits.
|
|
32
|
+
* Submodule pushes are refused unless this is true.
|
|
33
|
+
*/
|
|
34
|
+
allowAutoPublishSubmoduleMainCommits?: boolean;
|
|
16
35
|
}
|
|
17
36
|
|
|
18
37
|
export interface MeshFastForwardPlannedStep {
|
|
19
|
-
operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only'
|
|
38
|
+
operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only'
|
|
39
|
+
| 'submodule_update' | 'verify_post_status' | 'verify_push_descendant' | 'push_ff_only' | 'push_submodules_ff_only';
|
|
20
40
|
description: string;
|
|
21
41
|
safe: true;
|
|
22
42
|
willMutateWorktree: boolean;
|
|
23
43
|
}
|
|
24
44
|
|
|
45
|
+
export interface MeshFastForwardSubmodulePushResult {
|
|
46
|
+
path: string;
|
|
47
|
+
commit?: string;
|
|
48
|
+
remote: string;
|
|
49
|
+
remoteBranch: string;
|
|
50
|
+
pushed: boolean;
|
|
51
|
+
skipped: boolean;
|
|
52
|
+
code: string;
|
|
53
|
+
refspec?: string;
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
export interface MeshFastForwardResult {
|
|
26
58
|
success: boolean;
|
|
27
59
|
code: string;
|
|
28
60
|
nodeId?: string;
|
|
29
61
|
meshId?: string;
|
|
30
62
|
workspace: string;
|
|
63
|
+
mode: 'merge' | 'push';
|
|
31
64
|
allowed: boolean;
|
|
32
65
|
dryRun: boolean;
|
|
33
66
|
willRun: boolean;
|
|
@@ -38,15 +71,20 @@ export interface MeshFastForwardResult {
|
|
|
38
71
|
current?: GitRepoStatus;
|
|
39
72
|
preStatus?: GitRepoStatus;
|
|
40
73
|
postStatus?: GitRepoStatus;
|
|
74
|
+
/** Push target derived from the tracked upstream (mode='push'). */
|
|
75
|
+
pushTarget?: { remote: string; remoteBranch: string; refspec: string };
|
|
76
|
+
/** Submodule ff-only push outcomes (mode='push' with pushSubmodules). */
|
|
77
|
+
submodulePushes?: MeshFastForwardSubmodulePushResult[];
|
|
41
78
|
finalBranchConvergenceState?: Record<string, unknown>;
|
|
42
79
|
operationError?: string;
|
|
43
80
|
ledgerError?: string;
|
|
81
|
+
nextStep?: string;
|
|
44
82
|
trigger?: string;
|
|
45
83
|
}
|
|
46
84
|
|
|
47
85
|
type MeshFastForwardBase = Pick<
|
|
48
86
|
MeshFastForwardResult,
|
|
49
|
-
'workspace' | 'dryRun' | 'updateSubmodules' | 'plannedSteps' | 'trigger'
|
|
87
|
+
'workspace' | 'mode' | 'dryRun' | 'updateSubmodules' | 'plannedSteps' | 'trigger'
|
|
50
88
|
> & Pick<Partial<MeshFastForwardResult>, 'nodeId' | 'meshId'>;
|
|
51
89
|
|
|
52
90
|
const STATUS_OPTIONS = { refreshUpstream: true, includeSubmodules: true, timeoutMs: 15_000 } as const;
|
|
@@ -59,11 +97,14 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
|
|
|
59
97
|
const trigger = normalizeOptionalString(args.trigger) || 'manual';
|
|
60
98
|
const updateSubmodules = args.updateSubmodules === true;
|
|
61
99
|
const dryRun = args.dryRun === true || args.execute !== true;
|
|
62
|
-
const
|
|
100
|
+
const mode: 'merge' | 'push' = args.mode === 'push' ? 'push' : 'merge';
|
|
101
|
+
const pushSubmodules = mode === 'push' && args.pushSubmodules === true;
|
|
102
|
+
const plannedSteps = buildPlannedSteps(mode, updateSubmodules, pushSubmodules);
|
|
63
103
|
const base: MeshFastForwardBase = {
|
|
64
104
|
...(nodeId ? { nodeId } : {}),
|
|
65
105
|
...(meshId ? { meshId } : {}),
|
|
66
106
|
workspace,
|
|
107
|
+
mode,
|
|
67
108
|
dryRun,
|
|
68
109
|
updateSubmodules,
|
|
69
110
|
plannedSteps,
|
|
@@ -80,13 +121,28 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
|
|
|
80
121
|
timeoutMs: args.timeoutMs ?? STATUS_OPTIONS.timeoutMs,
|
|
81
122
|
});
|
|
82
123
|
|
|
124
|
+
if (mode === 'push') {
|
|
125
|
+
return pushMeshNode(base, args, current, {
|
|
126
|
+
pushSubmodules,
|
|
127
|
+
allowAutoPublishSubmoduleMainCommits: args.allowAutoPublishSubmoduleMainCommits === true,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
83
131
|
const earlyBlockers = collectPreflightBlockers(current, requestedBranch);
|
|
84
132
|
if (earlyBlockers.length > 0) {
|
|
133
|
+
const blockCode = chooseBlockCode(current, earlyBlockers);
|
|
85
134
|
const result: MeshFastForwardResult = {
|
|
86
|
-
...block(base,
|
|
135
|
+
...block(base, blockCode, earlyBlockers),
|
|
87
136
|
current,
|
|
88
|
-
finalBranchConvergenceState: buildConvergenceState(current, codeToConvergenceStatus(
|
|
137
|
+
finalBranchConvergenceState: buildConvergenceState(current, codeToConvergenceStatus(blockCode)),
|
|
89
138
|
};
|
|
139
|
+
// Pure ahead (local commits not yet on origin, nothing to merge in) is not a
|
|
140
|
+
// hard failure — it's a push-needed case. Reclassify so the coordinator can
|
|
141
|
+
// route to push mode without launching an agent session.
|
|
142
|
+
if (blockCode === 'branch_ahead' && current.ahead > 0 && current.behind === 0 && otherBlockersAreOnlyAhead(earlyBlockers)) {
|
|
143
|
+
result.code = 'ahead_needs_push';
|
|
144
|
+
result.nextStep = 'Local branch is ahead of origin with nothing to merge. Re-run mesh_fast_forward_node with mode="push" (execute=true) to ff-only push the local commits to origin.';
|
|
145
|
+
}
|
|
90
146
|
await appendFastForwardLedger(result, 'blocked');
|
|
91
147
|
return result;
|
|
92
148
|
}
|
|
@@ -210,7 +266,300 @@ export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promis
|
|
|
210
266
|
return result;
|
|
211
267
|
}
|
|
212
268
|
|
|
213
|
-
|
|
269
|
+
interface PushOptions {
|
|
270
|
+
pushSubmodules: boolean;
|
|
271
|
+
allowAutoPublishSubmoduleMainCommits: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Strict ff-only push of a node's local commits to origin/<branch>. Only proceeds
|
|
276
|
+
* when HEAD is a descendant of origin/<branch> (origin/<branch> is an ancestor of
|
|
277
|
+
* HEAD). Never force-pushes, resets, rebases, cleans, or checks out. Optionally
|
|
278
|
+
* ff-only pushes submodule HEADs to their origin main when policy allows.
|
|
279
|
+
*/
|
|
280
|
+
async function pushMeshNode(
|
|
281
|
+
base: MeshFastForwardBase,
|
|
282
|
+
args: MeshFastForwardNodeArgs,
|
|
283
|
+
current: GitRepoStatus,
|
|
284
|
+
options: PushOptions,
|
|
285
|
+
): Promise<MeshFastForwardResult> {
|
|
286
|
+
const workspace = base.workspace;
|
|
287
|
+
const requestedBranch = normalizeOptionalString(args.branch);
|
|
288
|
+
const dryRun = base.dryRun;
|
|
289
|
+
|
|
290
|
+
const blockers = collectPushPreflightBlockers(current, requestedBranch);
|
|
291
|
+
if (blockers.length > 0) {
|
|
292
|
+
const code = choosePushBlockCode(current, blockers);
|
|
293
|
+
const result: MeshFastForwardResult = {
|
|
294
|
+
...block(base, code, blockers),
|
|
295
|
+
current,
|
|
296
|
+
preStatus: current,
|
|
297
|
+
finalBranchConvergenceState: buildConvergenceState(current, codeToConvergenceStatus(code)),
|
|
298
|
+
};
|
|
299
|
+
await appendFastForwardLedger(result, 'blocked');
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const target = parseUpstreamTarget(current.upstream || '');
|
|
304
|
+
if (!target) {
|
|
305
|
+
const result: MeshFastForwardResult = {
|
|
306
|
+
...block(base, 'upstream_unparseable', ['upstream_unparseable']),
|
|
307
|
+
current,
|
|
308
|
+
preStatus: current,
|
|
309
|
+
finalBranchConvergenceState: buildConvergenceState(current, 'blocked'),
|
|
310
|
+
};
|
|
311
|
+
await appendFastForwardLedger(result, 'blocked');
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
const refspec = `HEAD:refs/heads/${target.remoteBranch}`;
|
|
315
|
+
const pushTarget = { remote: target.remote, remoteBranch: target.remoteBranch, refspec };
|
|
316
|
+
|
|
317
|
+
if (current.ahead <= 0) {
|
|
318
|
+
// Nothing local to publish.
|
|
319
|
+
const result: MeshFastForwardResult = {
|
|
320
|
+
...base,
|
|
321
|
+
success: true,
|
|
322
|
+
code: 'nothing_to_push',
|
|
323
|
+
allowed: true,
|
|
324
|
+
willRun: false,
|
|
325
|
+
executed: false,
|
|
326
|
+
blockingReasons: [],
|
|
327
|
+
current,
|
|
328
|
+
preStatus: current,
|
|
329
|
+
postStatus: current,
|
|
330
|
+
pushTarget,
|
|
331
|
+
finalBranchConvergenceState: buildConvergenceState(current, 'up_to_date'),
|
|
332
|
+
};
|
|
333
|
+
await appendFastForwardLedger(result, 'noop');
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Strict ff-only gate: origin/<branch> must be an ancestor of HEAD.
|
|
338
|
+
const descendant = await verifyUpstreamIsAncestorOfHead(workspace, current.upstream || '', args.timeoutMs);
|
|
339
|
+
if (!descendant.ok) {
|
|
340
|
+
const result: MeshFastForwardResult = {
|
|
341
|
+
...block(base, 'non_fast_forward_push', ['head_is_not_descendant_of_upstream']),
|
|
342
|
+
current,
|
|
343
|
+
preStatus: current,
|
|
344
|
+
pushTarget,
|
|
345
|
+
operationError: descendant.error,
|
|
346
|
+
nextStep: 'origin/<branch> has commits not in local HEAD; a ff-only push would lose them. Converge by rebasing onto origin first, then re-run. This operation never force-pushes.',
|
|
347
|
+
finalBranchConvergenceState: buildConvergenceState(current, 'not_mergeable'),
|
|
348
|
+
};
|
|
349
|
+
await appendFastForwardLedger(result, 'blocked');
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (dryRun) {
|
|
354
|
+
const result: MeshFastForwardResult = {
|
|
355
|
+
...base,
|
|
356
|
+
success: true,
|
|
357
|
+
code: 'push_available',
|
|
358
|
+
allowed: true,
|
|
359
|
+
willRun: false,
|
|
360
|
+
executed: false,
|
|
361
|
+
blockingReasons: [],
|
|
362
|
+
current,
|
|
363
|
+
preStatus: current,
|
|
364
|
+
pushTarget,
|
|
365
|
+
...(options.pushSubmodules ? { submodulePushes: await planSubmodulePushes(current, options, args.timeoutMs) } : {}),
|
|
366
|
+
finalBranchConvergenceState: buildConvergenceState(current, 'push_available'),
|
|
367
|
+
};
|
|
368
|
+
await appendFastForwardLedger(result, 'dry_run');
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Execute the root ff-only push.
|
|
373
|
+
try {
|
|
374
|
+
await runGit(workspace, ['push', target.remote, refspec], { timeoutMs: args.timeoutMs ?? 30_000 });
|
|
375
|
+
} catch (error) {
|
|
376
|
+
const result: MeshFastForwardResult = {
|
|
377
|
+
...block(base, 'push_ff_only_failed', ['push_ff_only_failed']),
|
|
378
|
+
current,
|
|
379
|
+
preStatus: current,
|
|
380
|
+
pushTarget,
|
|
381
|
+
operationError: formatGitError(error),
|
|
382
|
+
finalBranchConvergenceState: buildConvergenceState(current, 'not_mergeable'),
|
|
383
|
+
};
|
|
384
|
+
await appendFastForwardLedger(result, 'failed');
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let submodulePushes: MeshFastForwardSubmodulePushResult[] | undefined;
|
|
389
|
+
if (options.pushSubmodules) {
|
|
390
|
+
submodulePushes = await executeSubmodulePushes(current, options, args.timeoutMs);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const postStatus = await getGitRepoStatus(workspace, {
|
|
394
|
+
...STATUS_OPTIONS,
|
|
395
|
+
submoduleIgnorePaths: args.submoduleIgnorePaths,
|
|
396
|
+
timeoutMs: args.timeoutMs ?? STATUS_OPTIONS.timeoutMs,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const submodulePushFailed = (submodulePushes || []).some((entry) => !entry.pushed && !entry.skipped);
|
|
400
|
+
const blockingReasons: string[] = [];
|
|
401
|
+
if (postStatus.ahead !== 0) blockingReasons.push('post_branch_ahead');
|
|
402
|
+
if (submodulePushFailed) blockingReasons.push('submodule_push_failed');
|
|
403
|
+
|
|
404
|
+
const success = blockingReasons.length === 0;
|
|
405
|
+
const code = success
|
|
406
|
+
? 'push_applied'
|
|
407
|
+
: submodulePushFailed && postStatus.ahead === 0
|
|
408
|
+
? 'push_applied_submodule_push_failed'
|
|
409
|
+
: 'post_push_verify_failed';
|
|
410
|
+
const result: MeshFastForwardResult = {
|
|
411
|
+
...base,
|
|
412
|
+
success,
|
|
413
|
+
code,
|
|
414
|
+
allowed: true,
|
|
415
|
+
willRun: true,
|
|
416
|
+
executed: true,
|
|
417
|
+
blockingReasons,
|
|
418
|
+
current,
|
|
419
|
+
preStatus: current,
|
|
420
|
+
postStatus,
|
|
421
|
+
pushTarget,
|
|
422
|
+
...(submodulePushes ? { submodulePushes } : {}),
|
|
423
|
+
finalBranchConvergenceState: buildConvergenceState(postStatus, success ? 'pushed' : 'post_verify_failed'),
|
|
424
|
+
};
|
|
425
|
+
await appendFastForwardLedger(result, success ? 'executed' : 'failed');
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function collectPushPreflightBlockers(status: GitRepoStatus, requestedBranch?: string): string[] {
|
|
430
|
+
const blockers: string[] = [];
|
|
431
|
+
if (!status.isGitRepo) blockers.push('not_git_repo');
|
|
432
|
+
if (!status.branch) blockers.push('detached_head_or_unknown_branch');
|
|
433
|
+
if (requestedBranch && status.branch !== requestedBranch) blockers.push('branch_mismatch');
|
|
434
|
+
if (!status.upstream) blockers.push('upstream_missing');
|
|
435
|
+
if (status.upstreamStatus !== 'fresh') blockers.push('upstream_not_fresh');
|
|
436
|
+
if (status.hasConflicts) blockers.push('conflicts_present');
|
|
437
|
+
if (status.staged > 0) blockers.push('staged_changes_present');
|
|
438
|
+
if (status.modified > 0) blockers.push('modified_changes_present');
|
|
439
|
+
if (status.untracked > 0) blockers.push('untracked_changes_present');
|
|
440
|
+
if (status.deleted > 0) blockers.push('deleted_changes_present');
|
|
441
|
+
if (status.renamed > 0) blockers.push('renamed_changes_present');
|
|
442
|
+
if (status.stashCount > 0) blockers.push('stash_entries_present');
|
|
443
|
+
// A diverged branch (ahead>0 AND behind>0) cannot ff-only push: origin has
|
|
444
|
+
// commits not in HEAD. The descendant gate also catches this, but flagging it
|
|
445
|
+
// in preflight gives a clearer code.
|
|
446
|
+
if (status.ahead > 0 && status.behind > 0) blockers.push('branch_diverged_from_upstream');
|
|
447
|
+
else if (status.behind > 0) blockers.push('branch_behind_upstream');
|
|
448
|
+
return blockers;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function choosePushBlockCode(status: GitRepoStatus, blockers: string[]): string {
|
|
452
|
+
if (blockers.includes('not_git_repo')) return 'not_git_repo';
|
|
453
|
+
if (blockers.includes('branch_mismatch')) return 'branch_mismatch';
|
|
454
|
+
if (blockers.includes('upstream_missing')) return 'upstream_missing';
|
|
455
|
+
if (blockers.includes('upstream_not_fresh')) return 'upstream_not_fresh';
|
|
456
|
+
if (blockers.includes('branch_diverged_from_upstream')) return 'branch_diverged';
|
|
457
|
+
if (blockers.includes('branch_behind_upstream')) return 'non_fast_forward_push';
|
|
458
|
+
if (blockers.some((reason) => reason.includes('changes') || reason.includes('conflicts') || reason.includes('stash'))) return 'dirty_worktree';
|
|
459
|
+
return 'preflight_blocked';
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Parse an upstream ref like "origin/main" into { remote, remoteBranch }. */
|
|
463
|
+
function parseUpstreamTarget(upstream: string): { remote: string; remoteBranch: string } | null {
|
|
464
|
+
const trimmed = upstream.trim();
|
|
465
|
+
const slash = trimmed.indexOf('/');
|
|
466
|
+
if (slash <= 0 || slash >= trimmed.length - 1) return null;
|
|
467
|
+
return { remote: trimmed.slice(0, slash), remoteBranch: trimmed.slice(slash + 1) };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function verifyUpstreamIsAncestorOfHead(workspace: string, upstream: string, timeoutMs?: number): Promise<{ ok: boolean; error?: string }> {
|
|
471
|
+
if (!upstream) return { ok: false, error: 'missing upstream' };
|
|
472
|
+
try {
|
|
473
|
+
await runGit(workspace, ['merge-base', '--is-ancestor', upstream, 'HEAD'], { timeoutMs: timeoutMs ?? 15_000 });
|
|
474
|
+
return { ok: true };
|
|
475
|
+
} catch (error) {
|
|
476
|
+
return { ok: false, error: formatGitError(error) };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Dry-run plan for submodule pushes: classify each as would-push / skipped / blocked. */
|
|
481
|
+
async function planSubmodulePushes(status: GitRepoStatus, options: PushOptions, timeoutMs?: number): Promise<MeshFastForwardSubmodulePushResult[]> {
|
|
482
|
+
return resolveSubmodulePushes(status, options, false, timeoutMs);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function executeSubmodulePushes(status: GitRepoStatus, options: PushOptions, timeoutMs?: number): Promise<MeshFastForwardSubmodulePushResult[]> {
|
|
486
|
+
return resolveSubmodulePushes(status, options, true, timeoutMs);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function resolveSubmodulePushes(
|
|
490
|
+
status: GitRepoStatus,
|
|
491
|
+
options: PushOptions,
|
|
492
|
+
execute: boolean,
|
|
493
|
+
timeoutMs?: number,
|
|
494
|
+
): Promise<MeshFastForwardSubmodulePushResult[]> {
|
|
495
|
+
const submodules = Array.isArray(status.submodules) ? status.submodules : [];
|
|
496
|
+
const results: MeshFastForwardSubmodulePushResult[] = [];
|
|
497
|
+
for (const submodule of submodules) {
|
|
498
|
+
const base: MeshFastForwardSubmodulePushResult = {
|
|
499
|
+
path: submodule.path,
|
|
500
|
+
commit: submodule.commit,
|
|
501
|
+
remote: 'origin',
|
|
502
|
+
remoteBranch: 'main',
|
|
503
|
+
pushed: false,
|
|
504
|
+
skipped: true,
|
|
505
|
+
code: 'submodule_push_skipped',
|
|
506
|
+
};
|
|
507
|
+
if (!options.allowAutoPublishSubmoduleMainCommits) {
|
|
508
|
+
results.push({ ...base, code: 'submodule_push_policy_disabled', error: 'allowAutoPublishSubmoduleMainCommits is not enabled' });
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (submodule.error || submodule.dirty) {
|
|
512
|
+
results.push({ ...base, code: 'submodule_not_clean', error: submodule.error || 'submodule worktree is dirty' });
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const repoPath = submodule.repoPath;
|
|
516
|
+
if (!repoPath || !submodule.commit) {
|
|
517
|
+
results.push({ ...base, code: 'submodule_status_incomplete' });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
// Refresh the submodule's origin/main, then require it to be an ancestor of
|
|
521
|
+
// the gitlink commit (strict ff-only).
|
|
522
|
+
try {
|
|
523
|
+
await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', 'refs/heads/main:refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 30_000 });
|
|
524
|
+
} catch (error) {
|
|
525
|
+
results.push({ ...base, code: 'submodule_fetch_failed', error: formatGitError(error) });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
let alreadyReachable = false;
|
|
529
|
+
try {
|
|
530
|
+
await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, 'refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 15_000 });
|
|
531
|
+
alreadyReachable = true;
|
|
532
|
+
} catch { /* not yet on origin/main — candidate for push */ }
|
|
533
|
+
if (alreadyReachable) {
|
|
534
|
+
results.push({ ...base, pushed: false, skipped: true, code: 'submodule_already_reachable' });
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
// Strict ff-only: origin/main must be an ancestor of the commit we publish.
|
|
538
|
+
try {
|
|
539
|
+
await runGit(repoPath, ['merge-base', '--is-ancestor', 'refs/remotes/origin/main', submodule.commit], { timeoutMs: timeoutMs ?? 15_000 });
|
|
540
|
+
} catch (error) {
|
|
541
|
+
results.push({ ...base, pushed: false, skipped: false, code: 'submodule_non_fast_forward', error: formatGitError(error) });
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const refspec = `${submodule.commit}:refs/heads/main`;
|
|
545
|
+
if (!execute) {
|
|
546
|
+
results.push({ ...base, pushed: false, skipped: false, code: 'submodule_push_available', refspec });
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
await runGit(repoPath, ['push', 'origin', refspec], { timeoutMs: timeoutMs ?? 30_000 });
|
|
551
|
+
// Verify reachability after the push.
|
|
552
|
+
await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', 'refs/heads/main:refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 30_000 });
|
|
553
|
+
await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, 'refs/remotes/origin/main'], { timeoutMs: timeoutMs ?? 15_000 });
|
|
554
|
+
results.push({ ...base, pushed: true, skipped: false, code: 'submodule_pushed', refspec });
|
|
555
|
+
} catch (error) {
|
|
556
|
+
results.push({ ...base, pushed: false, skipped: false, code: 'submodule_push_failed', refspec, error: formatGitError(error) });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return results;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function buildPlannedSteps(mode: 'merge' | 'push', updateSubmodules: boolean, pushSubmodules: boolean): MeshFastForwardPlannedStep[] {
|
|
214
563
|
const steps: MeshFastForwardPlannedStep[] = [
|
|
215
564
|
{
|
|
216
565
|
operation: 'refresh_upstream',
|
|
@@ -224,19 +573,48 @@ function buildPlannedSteps(updateSubmodules: boolean): MeshFastForwardPlannedSte
|
|
|
224
573
|
safe: true,
|
|
225
574
|
willMutateWorktree: false,
|
|
226
575
|
},
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
576
|
+
];
|
|
577
|
+
if (mode === 'push') {
|
|
578
|
+
steps.push({
|
|
579
|
+
operation: 'verify_push_descendant',
|
|
580
|
+
description: 'Require HEAD to be a descendant of origin/<branch> (origin/<branch> is an ancestor of HEAD); refuse any non-fast-forward push.',
|
|
230
581
|
safe: true,
|
|
231
582
|
willMutateWorktree: false,
|
|
232
|
-
}
|
|
233
|
-
{
|
|
234
|
-
operation: '
|
|
235
|
-
description: '
|
|
583
|
+
});
|
|
584
|
+
steps.push({
|
|
585
|
+
operation: 'push_ff_only',
|
|
586
|
+
description: 'Run git push origin HEAD:<branch> as a strict ff-only push; never --force, --force-with-lease, reset, or rebase. Does not mutate the worktree.',
|
|
236
587
|
safe: true,
|
|
237
|
-
willMutateWorktree:
|
|
238
|
-
}
|
|
239
|
-
|
|
588
|
+
willMutateWorktree: false,
|
|
589
|
+
});
|
|
590
|
+
if (pushSubmodules) {
|
|
591
|
+
steps.push({
|
|
592
|
+
operation: 'push_submodules_ff_only',
|
|
593
|
+
description: 'For each submodule, if allowAutoPublishSubmoduleMainCommits is enabled and the submodule HEAD is a descendant of its origin main, ff-only push it to submodule origin main; otherwise skip.',
|
|
594
|
+
safe: true,
|
|
595
|
+
willMutateWorktree: false,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
steps.push({
|
|
599
|
+
operation: 'verify_post_status',
|
|
600
|
+
description: 'Re-read daemon-owned git status and report final branch convergence state.',
|
|
601
|
+
safe: true,
|
|
602
|
+
willMutateWorktree: false,
|
|
603
|
+
});
|
|
604
|
+
return steps;
|
|
605
|
+
}
|
|
606
|
+
steps.push({
|
|
607
|
+
operation: 'verify_fast_forward',
|
|
608
|
+
description: 'Require ahead=0, behind>0, and HEAD to be an ancestor of the upstream ref.',
|
|
609
|
+
safe: true,
|
|
610
|
+
willMutateWorktree: false,
|
|
611
|
+
});
|
|
612
|
+
steps.push({
|
|
613
|
+
operation: 'merge_ff_only',
|
|
614
|
+
description: 'Apply git merge --ff-only against the tracked upstream; no force, reset, rebase, push, or deploy.',
|
|
615
|
+
safe: true,
|
|
616
|
+
willMutateWorktree: true,
|
|
617
|
+
});
|
|
240
618
|
if (updateSubmodules) {
|
|
241
619
|
steps.push({
|
|
242
620
|
operation: 'submodule_update',
|
|
@@ -254,6 +632,16 @@ function buildPlannedSteps(updateSubmodules: boolean): MeshFastForwardPlannedSte
|
|
|
254
632
|
return steps;
|
|
255
633
|
}
|
|
256
634
|
|
|
635
|
+
/**
|
|
636
|
+
* True when every preflight blocker is attributable purely to the branch being
|
|
637
|
+
* ahead of its upstream (local commits not yet pushed) — i.e. nothing dirty,
|
|
638
|
+
* diverged, or submodule-broken. Used to reclassify branch_ahead → ahead_needs_push.
|
|
639
|
+
*/
|
|
640
|
+
function otherBlockersAreOnlyAhead(blockers: string[]): boolean {
|
|
641
|
+
const aheadOnly = new Set(['branch_has_local_commits']);
|
|
642
|
+
return blockers.every((reason) => aheadOnly.has(reason));
|
|
643
|
+
}
|
|
644
|
+
|
|
257
645
|
function collectPreflightBlockers(status: GitRepoStatus, requestedBranch?: string): string[] {
|
|
258
646
|
const blockers: string[] = [];
|
|
259
647
|
if (!status.isGitRepo) blockers.push('not_git_repo');
|
|
@@ -314,7 +702,8 @@ function chooseBlockCode(status: GitRepoStatus, blockers: string[]): string {
|
|
|
314
702
|
}
|
|
315
703
|
|
|
316
704
|
function codeToConvergenceStatus(code: string): string {
|
|
317
|
-
if (code === 'branch_diverged' || code === 'branch_ahead' || code === 'non_fast_forward'
|
|
705
|
+
if (code === 'branch_diverged' || code === 'branch_ahead' || code === 'non_fast_forward'
|
|
706
|
+
|| code === 'non_fast_forward_push' || code === 'upstream_unparseable') return 'not_mergeable';
|
|
318
707
|
if (code === 'dirty_worktree' || code === 'submodule_not_clean') return 'blocked_review';
|
|
319
708
|
return 'blocked';
|
|
320
709
|
}
|
|
@@ -409,6 +798,7 @@ async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: '
|
|
|
409
798
|
...(result.nodeId ? { nodeId: result.nodeId } : {}),
|
|
410
799
|
payload: {
|
|
411
800
|
operation: 'mesh_fast_forward_node',
|
|
801
|
+
mode: result.mode,
|
|
412
802
|
trigger: result.trigger || 'manual',
|
|
413
803
|
outcome,
|
|
414
804
|
code: result.code,
|
|
@@ -419,6 +809,7 @@ async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: '
|
|
|
419
809
|
executed: result.executed,
|
|
420
810
|
branch: result.postStatus?.branch ?? result.current?.branch,
|
|
421
811
|
upstream: result.postStatus?.upstream ?? result.current?.upstream,
|
|
812
|
+
...(result.pushTarget ? { pushTarget: result.pushTarget } : {}),
|
|
422
813
|
before: result.current ? {
|
|
423
814
|
headCommit: result.current.headCommit,
|
|
424
815
|
ahead: result.current.ahead,
|
|
@@ -429,6 +820,16 @@ async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: '
|
|
|
429
820
|
ahead: result.postStatus.ahead,
|
|
430
821
|
behind: result.postStatus.behind,
|
|
431
822
|
} : undefined,
|
|
823
|
+
...(result.submodulePushes ? {
|
|
824
|
+
submodulePushes: result.submodulePushes.map((entry) => ({
|
|
825
|
+
path: entry.path,
|
|
826
|
+
commit: entry.commit,
|
|
827
|
+
pushed: entry.pushed,
|
|
828
|
+
skipped: entry.skipped,
|
|
829
|
+
code: entry.code,
|
|
830
|
+
...(entry.refspec ? { refspec: entry.refspec } : {}),
|
|
831
|
+
})),
|
|
832
|
+
} : {}),
|
|
432
833
|
blockingReasons: result.blockingReasons,
|
|
433
834
|
},
|
|
434
835
|
});
|
package/src/mesh/mesh-ledger.ts
CHANGED
|
@@ -676,6 +676,25 @@ export class MeshRuntimeStore {
|
|
|
676
676
|
this.db.prepare(`DELETE FROM mesh_direct_dispatches WHERE mesh_id = ?`).run(meshId);
|
|
677
677
|
}
|
|
678
678
|
|
|
679
|
+
/**
|
|
680
|
+
* Delete specific direct dispatch rows by taskId for a mesh. Used by the staleDirect prune
|
|
681
|
+
* path to remove orphaned/terminal dispatch records whose node/session is no longer in the
|
|
682
|
+
* live mesh. Returns the number of rows actually deleted. No-op for an empty taskId list.
|
|
683
|
+
*/
|
|
684
|
+
deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number {
|
|
685
|
+
const ids = (taskIds || []).map(id => typeof id === 'string' ? id.trim() : '').filter(Boolean);
|
|
686
|
+
if (!ids.length) return 0;
|
|
687
|
+
const stmt = this.db.prepare(`DELETE FROM mesh_direct_dispatches WHERE mesh_id = ? AND task_id = ?`);
|
|
688
|
+
let deleted = 0;
|
|
689
|
+
const run = this.db.transaction((rows: string[]) => {
|
|
690
|
+
for (const taskId of rows) {
|
|
691
|
+
deleted += stmt.run(meshId, taskId).changes;
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
run(ids);
|
|
695
|
+
return deleted;
|
|
696
|
+
}
|
|
697
|
+
|
|
679
698
|
markStaleDirectDispatches(meshId: string, olderThanMs: number): void {
|
|
680
699
|
const cutoff = new Date(Date.now() - olderThanMs).toISOString();
|
|
681
700
|
const now = new Date().toISOString();
|
|
@@ -750,6 +750,19 @@ export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_
|
|
|
750
750
|
} catch { /* best-effort */ }
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Delete specific direct dispatch rows by taskId. Returns the number of rows deleted.
|
|
755
|
+
* Used by the staleDirect prune path to evict orphaned/terminal dispatch records from the
|
|
756
|
+
* active staleDirect surface while leaving the append-only mesh ledger (audit history) intact.
|
|
757
|
+
*/
|
|
758
|
+
export function deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number {
|
|
759
|
+
try {
|
|
760
|
+
return MeshRuntimeStore.getInstance().deleteDirectDispatchesByTaskId(meshId, taskIds);
|
|
761
|
+
} catch {
|
|
762
|
+
return 0;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
753
766
|
export type MeshToolCallRateResult = { rateLimitExceeded: boolean; callsInWindow: number; advisory: string | null };
|
|
754
767
|
|
|
755
768
|
/**
|