@adhdev/daemon-core 0.9.82-rc.262 → 0.9.82-rc.264
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 +29 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +589 -66
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +585 -66
- 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 +123 -4
- package/src/git/git-status.ts +163 -1
- package/src/git/git-types.ts +30 -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
package/src/git/git-types.ts
CHANGED
|
@@ -68,10 +68,40 @@ export interface GitRepoStatus extends GitRepoIdentity {
|
|
|
68
68
|
lastCheckedAt: number;
|
|
69
69
|
/** Submodule statuses when auto-discover is enabled */
|
|
70
70
|
submodules?: GitSubmoduleStatus[];
|
|
71
|
+
/**
|
|
72
|
+
* Set when the running daemon's build commit is a STRICT ancestor of this
|
|
73
|
+
* repo's HEAD (or a submodule HEAD) — i.e. the live daemon predates committed
|
|
74
|
+
* code in this workspace and is awaiting a deploy/restart to catch up.
|
|
75
|
+
* Omitted entirely when no staleness is provable (unknown build, commit not
|
|
76
|
+
* present in repo, or build commit == HEAD) to avoid over-warning.
|
|
77
|
+
*/
|
|
78
|
+
daemonBuildBehind?: DaemonBuildBehind;
|
|
71
79
|
error?: string;
|
|
72
80
|
reason?: GitFailureReason;
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
export interface DaemonBuildBehind {
|
|
84
|
+
/** Full build commit baked into the running daemon. */
|
|
85
|
+
buildCommit: string;
|
|
86
|
+
/** Short build commit. */
|
|
87
|
+
buildCommitShort: string;
|
|
88
|
+
/** HEAD commit the build commit is behind (repo or submodule). */
|
|
89
|
+
head: string;
|
|
90
|
+
/** Where the comparison matched: 'root' or the submodule path. */
|
|
91
|
+
scope: string;
|
|
92
|
+
/**
|
|
93
|
+
* Whether any package changed between buildCommit..HEAD affects the daemon
|
|
94
|
+
* runtime (daemon-core, standalone, session-host, terminal-mux, ghostty,
|
|
95
|
+
* mcp-server). When false, only web/render packages changed — the daemon does
|
|
96
|
+
* NOT need a rebuild/restart; only the web deploy is pending. Conservative:
|
|
97
|
+
* when the changed-package set can't be determined it defaults to true.
|
|
98
|
+
*/
|
|
99
|
+
isDaemonAffecting: boolean;
|
|
100
|
+
/** Distinct package names changed between buildCommit..HEAD (best-effort). */
|
|
101
|
+
affectedPackages?: string[];
|
|
102
|
+
warning: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
export type GitFileChangeStatus =
|
|
76
106
|
| 'added'
|
|
77
107
|
| 'modified'
|
package/src/index.ts
CHANGED
|
@@ -217,9 +217,10 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
|
|
|
217
217
|
export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
|
|
218
218
|
|
|
219
219
|
// ── Mesh Work Queue (GUPP) ──
|
|
220
|
-
export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
|
|
220
|
+
export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
|
|
221
221
|
export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
|
|
222
|
-
export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary } from './mesh/mesh-active-work.js';
|
|
222
|
+
export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
|
|
223
|
+
export type { StaleDirectPruneClassification } from './mesh/mesh-active-work.js';
|
|
223
224
|
export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
|
|
224
225
|
export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
|
|
225
226
|
export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
|
|
@@ -306,6 +307,8 @@ export type {
|
|
|
306
307
|
export { DaemonStatusReporter } from './status/reporter.js';
|
|
307
308
|
export { buildSessionEntries, findCdpManager, hasCdpManager, isCdpConnected } from './status/builders.js';
|
|
308
309
|
export { buildStatusSnapshot, buildMachineInfo } from './status/snapshot.js';
|
|
310
|
+
export { getDaemonBuildInfo } from './build-info.js';
|
|
311
|
+
export type { DaemonBuildInfo } from './build-info.js';
|
|
309
312
|
export { normalizeManagedStatus, isManagedStatusWorking, isManagedStatusWaiting, normalizeActiveChatData } from './status/normalize.js';
|
|
310
313
|
export type { ManagedStatus } from './status/normalize.js';
|
|
311
314
|
export type { StatusSnapshotOptions, StatusSnapshot } from './status/snapshot.js';
|
|
@@ -408,6 +408,37 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
|
|
|
408
408
|
return { activeWork: records, staleDirectWork, staleDirectWorkNote, terminalDirectWork, summary };
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
+
/**
|
|
412
|
+
* staleReason strings (produced by sessionStatusFromNodes above) that indicate the original
|
|
413
|
+
* node/session is GONE from the live mesh — i.e. the staleDirect record is an orphaned ledger
|
|
414
|
+
* artifact, not active or recoverable work. These are the only reasons safe to prune from the
|
|
415
|
+
* active staleDirect surface. The "no provider acknowledgement" reason is deliberately excluded:
|
|
416
|
+
* those entries have a still-live node/session (staleDispatchUnacknowledged) and represent
|
|
417
|
+
* recoverable dispatch failures, never orphans.
|
|
418
|
+
*/
|
|
419
|
+
export const PRUNABLE_ORPHAN_STALE_REASONS: ReadonlySet<string> = new Set([
|
|
420
|
+
'direct task node is no longer in the live mesh',
|
|
421
|
+
'direct task session is not present in live session records',
|
|
422
|
+
'direct task has no node id',
|
|
423
|
+
]);
|
|
424
|
+
|
|
425
|
+
export type StaleDirectPruneClassification = 'prunable_orphan' | 'prunable_terminal' | 'preserve_unacknowledged' | 'preserve_active';
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Classify a direct-work record for the staleDirect prune path. Pure function — the prune tool
|
|
429
|
+
* uses this so the safety rules (never touch active work or recoverable unacknowledged dispatches)
|
|
430
|
+
* live next to the staleReason producers and are independently testable.
|
|
431
|
+
*/
|
|
432
|
+
export function classifyStaleDirectForPrune(
|
|
433
|
+
record: Pick<MeshActiveWorkRecord, 'staleReason' | 'staleDispatchUnacknowledged' | 'terminal'>,
|
|
434
|
+
opts: { includeTerminal?: boolean } = {},
|
|
435
|
+
): StaleDirectPruneClassification {
|
|
436
|
+
if (record.staleDispatchUnacknowledged === true) return 'preserve_unacknowledged';
|
|
437
|
+
if (record.terminal === true) return opts.includeTerminal ? 'prunable_terminal' : 'preserve_active';
|
|
438
|
+
if (record.staleReason && PRUNABLE_ORPHAN_STALE_REASONS.has(record.staleReason)) return 'prunable_orphan';
|
|
439
|
+
return 'preserve_active';
|
|
440
|
+
}
|
|
441
|
+
|
|
411
442
|
export function buildCompactStaleDirectWorkSummary(
|
|
412
443
|
staleDirectWork: MeshActiveWorkRecord[],
|
|
413
444
|
opts: { sampleLimit?: number; detailHint?: string; note?: string } = {},
|
|
@@ -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
|
});
|