@adhdev/daemon-core 0.9.82-rc.53 → 0.9.82-rc.55
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/index.d.ts +2 -0
- package/dist/index.js +393 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +392 -13
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-fast-forward.d.ts +39 -0
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/package.json +1 -1
- package/src/commands/router.ts +30 -0
- package/src/index.ts +2 -0
- package/src/mesh/coordinator-prompt.ts +4 -2
- package/src/mesh/mesh-fast-forward.ts +430 -0
- package/src/mesh/mesh-ledger.ts +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GitRepoStatus } from '../git/git-types.js';
|
|
2
|
+
export interface MeshFastForwardNodeArgs {
|
|
3
|
+
nodeId?: string;
|
|
4
|
+
meshId?: string;
|
|
5
|
+
workspace: string;
|
|
6
|
+
branch?: string;
|
|
7
|
+
execute?: boolean;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
updateSubmodules?: boolean;
|
|
10
|
+
submoduleIgnorePaths?: string[];
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface MeshFastForwardPlannedStep {
|
|
14
|
+
operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only' | 'submodule_update' | 'verify_post_status';
|
|
15
|
+
description: string;
|
|
16
|
+
safe: true;
|
|
17
|
+
willMutateWorktree: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface MeshFastForwardResult {
|
|
20
|
+
success: boolean;
|
|
21
|
+
code: string;
|
|
22
|
+
nodeId?: string;
|
|
23
|
+
meshId?: string;
|
|
24
|
+
workspace: string;
|
|
25
|
+
allowed: boolean;
|
|
26
|
+
dryRun: boolean;
|
|
27
|
+
willRun: boolean;
|
|
28
|
+
executed: boolean;
|
|
29
|
+
updateSubmodules: boolean;
|
|
30
|
+
blockingReasons: string[];
|
|
31
|
+
plannedSteps: MeshFastForwardPlannedStep[];
|
|
32
|
+
current?: GitRepoStatus;
|
|
33
|
+
preStatus?: GitRepoStatus;
|
|
34
|
+
postStatus?: GitRepoStatus;
|
|
35
|
+
finalBranchConvergenceState?: Record<string, unknown>;
|
|
36
|
+
operationError?: string;
|
|
37
|
+
ledgerError?: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promise<MeshFastForwardResult>;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Safety: mode 0o600, atomic append via appendFileSync
|
|
14
14
|
*/
|
|
15
15
|
import { EventEmitter } from 'events';
|
|
16
|
-
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled';
|
|
16
|
+
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward';
|
|
17
17
|
export interface MeshLedgerEntry {
|
|
18
18
|
id: string;
|
|
19
19
|
meshId: string;
|
package/package.json
CHANGED
package/src/commands/router.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { createHermesManualMeshCoordinatorSetup, resolveMeshCoordinatorSetup } f
|
|
|
40
40
|
import { buildSessionEntries } from '../status/builders.js';
|
|
41
41
|
import { handleMeshForwardEvent, drainPendingMeshCoordinatorEvents } from '../mesh/mesh-events.js';
|
|
42
42
|
import { buildMeshHostRequiredFailure, normalizeMeshDaemonRole, resolveMeshHostStatus } from '../mesh/mesh-host-ownership.js';
|
|
43
|
+
import { fastForwardMeshNode } from '../mesh/mesh-fast-forward.js';
|
|
43
44
|
import {
|
|
44
45
|
MESH_REFINE_CONFIG_LOCATIONS,
|
|
45
46
|
MESH_REFINE_CONFIG_SCHEMA,
|
|
@@ -3319,6 +3320,35 @@ export class DaemonCommandRouter {
|
|
|
3319
3320
|
};
|
|
3320
3321
|
}
|
|
3321
3322
|
|
|
3323
|
+
case 'fast_forward_mesh_node': {
|
|
3324
|
+
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
3325
|
+
const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
|
|
3326
|
+
let workspace = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
|
|
3327
|
+
let submoduleIgnorePaths = Array.isArray(args?.submoduleIgnorePaths)
|
|
3328
|
+
? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
|
|
3329
|
+
: undefined;
|
|
3330
|
+
if (!workspace && meshId && nodeId) {
|
|
3331
|
+
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh);
|
|
3332
|
+
const mesh = meshRecord?.mesh;
|
|
3333
|
+
const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
|
|
3334
|
+
workspace = typeof node?.workspace === 'string' ? node.workspace.trim() : '';
|
|
3335
|
+
if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
|
|
3336
|
+
submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
const result = await (fastForwardMeshNode({
|
|
3340
|
+
meshId: meshId || undefined,
|
|
3341
|
+
nodeId: nodeId || undefined,
|
|
3342
|
+
workspace,
|
|
3343
|
+
branch: typeof args?.branch === 'string' ? args.branch : undefined,
|
|
3344
|
+
execute: args?.execute === true,
|
|
3345
|
+
dryRun: args?.dryRun === true,
|
|
3346
|
+
updateSubmodules: args?.updateSubmodules === true,
|
|
3347
|
+
submoduleIgnorePaths,
|
|
3348
|
+
}) as Promise<unknown>);
|
|
3349
|
+
return result as CommandRouterResult;
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3322
3352
|
case 'refine_mesh_node': {
|
|
3323
3353
|
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
3324
3354
|
const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
|
package/src/index.ts
CHANGED
|
@@ -180,6 +180,8 @@ export type { MeshSyncTransport, MeshSyncResult, RemoteMeshRecord } from './mesh
|
|
|
180
180
|
// ── Mesh Task Ledger ──
|
|
181
181
|
export { appendLedgerEntry, appendRemoteLedgerEntries, readLedgerEntries, readLedgerSlice, getLedgerSummary, getLedgerDir, getSessionRecoveryContext, MAX_LEDGER_SLICE_LIMIT } from './mesh/mesh-ledger.js';
|
|
182
182
|
export type { AppendRemoteLedgerResult, MeshLedgerEntry, MeshLedgerKind, MeshLedgerSlice, MeshLedgerSummary, ReadLedgerOptions, ReadLedgerSliceOptions, SessionRecoveryContext } from './mesh/mesh-ledger.js';
|
|
183
|
+
export { fastForwardMeshNode } from './mesh/mesh-fast-forward.js';
|
|
184
|
+
export type { MeshFastForwardNodeArgs, MeshFastForwardPlannedStep, MeshFastForwardResult } from './mesh/mesh-fast-forward.js';
|
|
183
185
|
export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence } from './mesh/mesh-ledger-reconciliation.js';
|
|
184
186
|
export type { MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
|
|
185
187
|
|
|
@@ -140,6 +140,7 @@ const TOOLS_SECTION = `## Available Tools
|
|
|
140
140
|
| \`mesh_read_debug\` | Collect a daemon-side chat/parser debug bundle for a session |
|
|
141
141
|
| \`mesh_task_history\` | Read the task ledger — dispatches, completions, failures. Use to understand what has been done before deciding next steps |
|
|
142
142
|
| \`mesh_git_status\` | Check git status on a specific node |
|
|
143
|
+
| \`mesh_fast_forward_node\` | Safely dry-run or explicitly execute an obvious clean fast-forward without launching an agent session |
|
|
143
144
|
| \`mesh_checkpoint\` | Create a git checkpoint on a node |
|
|
144
145
|
| \`mesh_approve\` | Approve/reject a pending agent action |
|
|
145
146
|
| \`mesh_clone_node\` | Create a worktree node for isolated parallel branch work |
|
|
@@ -164,7 +165,7 @@ const WORKFLOW_SECTION = `## Orchestration Workflow
|
|
|
164
165
|
4. **Monitor** — Prefer event-driven completion/status notifications. Do **not** poll \`mesh_read_chat\` repeatedly. Use \`mesh_view_queue\` to see the status of all pending, assigned, completed, and failed tasks. Do not call \`mesh_read_chat\` again within a few seconds for the same generating session. Use at most one compact \`mesh_read_chat\` check after a completion/approval signal. Handle approvals via \`mesh_approve\`.
|
|
165
166
|
5. **Verify** — When a task reports completion or git work is visible, call \`mesh_git_status\` to verify changes were made.
|
|
166
167
|
6. **Checkpoint** — Call \`mesh_checkpoint\` to save the work.
|
|
167
|
-
7. **Converge branches** — Before marking any task complete, classify every touched node/branch into exactly one final state: \`merged_to_main\`, \`pushed_feature_branch_needs_merge\`, \`blocked_review\`, \`cleanup_candidate\`, or \`not_mergeable\`. Use \`mesh_status\` branchConvergenceSummary and \`mesh_refine_node\` for clean worktree branches when safe. A task that remains on a non-main branch is not fully complete unless the final report names the follow-up state and next step.
|
|
168
|
+
7. **Converge branches** — Before marking any task complete, classify every touched node/branch into exactly one final state: \`merged_to_main\`, \`pushed_feature_branch_needs_merge\`, \`blocked_review\`, \`cleanup_candidate\`, or \`not_mergeable\`. Use \`mesh_status\` branchConvergenceSummary. For obvious clean branch catch-up (ahead 0, behind > 0, upstream fresh, no dirty/stash/submodule issues), use \`mesh_fast_forward_node\` dry-run first and execute only when explicitly safe/approved; this avoids consuming an agent session. Use \`mesh_refine_node\` for clean worktree branches when safe. A task that remains on a non-main branch is not fully complete unless the final report names the follow-up state and next step.
|
|
168
169
|
8. **Clean up** — Remove worktree nodes via \`mesh_remove_node\` after their work is merged or no longer needed.
|
|
169
170
|
9. **Report** — Summarize what was done, what changed, any issues, and the branch convergence state.
|
|
170
171
|
|
|
@@ -201,6 +202,7 @@ function buildRulesSection(coordinatorCliType?: string): string {
|
|
|
201
202
|
- **Respect node capabilities.** Don't send build tasks to read-only nodes. Don't push from nodes that aren't allowed to.
|
|
202
203
|
- **Never fabricate tool results.** Always call the actual tool; never pretend you did.
|
|
203
204
|
- **Clean up worktree nodes.** After a worktree task completes and its changes are merged or checkpointed, call \`mesh_remove_node\` to free resources.
|
|
204
|
-
- **Do not strand completed branches.** A checkpointed or clean feature/worktree branch is not done by itself. Merge/refine it to the mesh default branch, or explicitly report one of \`pushed_feature_branch_needs_merge\`, \`blocked_review\`, \`cleanup_candidate\`, or \`not_mergeable\` with the next action.
|
|
205
|
+
- **Do not strand completed branches.** A checkpointed or clean feature/worktree branch is not done by itself. Merge/refine it to the mesh default branch, fast-forward obvious clean behind-only branches with \`mesh_fast_forward_node\`, or explicitly report one of \`pushed_feature_branch_needs_merge\`, \`blocked_review\`, \`cleanup_candidate\`, or \`not_mergeable\` with the next action.
|
|
206
|
+
- **Keep Refinery validation project-configurable.** \`mesh_refine_node\` must execute validation from repo mesh/refine config (for example \`.adhdev/refine.{json,yaml,yml}\`, \`.adhdev/repo-mesh-refine.*\`, or \`repo-mesh.refine.*\`). Heuristics are suggestions/scaffolding only, not the execution path.
|
|
205
207
|
- **Name worktree branches meaningfully.** Use descriptive names like \`feat/auth-refactor\` or \`fix/build-123\`.${coordinatorNote}`;
|
|
206
208
|
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import type { GitRepoStatus, GitSubmoduleStatus } from '../git/git-types.js';
|
|
2
|
+
import { getGitRepoStatus } from '../git/git-status.js';
|
|
3
|
+
import { GitCommandError, runGit } from '../git/git-executor.js';
|
|
4
|
+
|
|
5
|
+
export interface MeshFastForwardNodeArgs {
|
|
6
|
+
nodeId?: string;
|
|
7
|
+
meshId?: string;
|
|
8
|
+
workspace: string;
|
|
9
|
+
branch?: string;
|
|
10
|
+
execute?: boolean;
|
|
11
|
+
dryRun?: boolean;
|
|
12
|
+
updateSubmodules?: boolean;
|
|
13
|
+
submoduleIgnorePaths?: string[];
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MeshFastForwardPlannedStep {
|
|
18
|
+
operation: 'refresh_upstream' | 'verify_clean_worktree' | 'verify_fast_forward' | 'merge_ff_only' | 'submodule_update' | 'verify_post_status';
|
|
19
|
+
description: string;
|
|
20
|
+
safe: true;
|
|
21
|
+
willMutateWorktree: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MeshFastForwardResult {
|
|
25
|
+
success: boolean;
|
|
26
|
+
code: string;
|
|
27
|
+
nodeId?: string;
|
|
28
|
+
meshId?: string;
|
|
29
|
+
workspace: string;
|
|
30
|
+
allowed: boolean;
|
|
31
|
+
dryRun: boolean;
|
|
32
|
+
willRun: boolean;
|
|
33
|
+
executed: boolean;
|
|
34
|
+
updateSubmodules: boolean;
|
|
35
|
+
blockingReasons: string[];
|
|
36
|
+
plannedSteps: MeshFastForwardPlannedStep[];
|
|
37
|
+
current?: GitRepoStatus;
|
|
38
|
+
preStatus?: GitRepoStatus;
|
|
39
|
+
postStatus?: GitRepoStatus;
|
|
40
|
+
finalBranchConvergenceState?: Record<string, unknown>;
|
|
41
|
+
operationError?: string;
|
|
42
|
+
ledgerError?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type MeshFastForwardBase = Pick<
|
|
46
|
+
MeshFastForwardResult,
|
|
47
|
+
'workspace' | 'dryRun' | 'updateSubmodules' | 'plannedSteps'
|
|
48
|
+
> & Pick<Partial<MeshFastForwardResult>, 'nodeId' | 'meshId'>;
|
|
49
|
+
|
|
50
|
+
const STATUS_OPTIONS = { refreshUpstream: true, includeSubmodules: true, timeoutMs: 15_000 } as const;
|
|
51
|
+
|
|
52
|
+
export async function fastForwardMeshNode(args: MeshFastForwardNodeArgs): Promise<MeshFastForwardResult> {
|
|
53
|
+
const workspace = typeof args.workspace === 'string' ? args.workspace.trim() : '';
|
|
54
|
+
const nodeId = normalizeOptionalString(args.nodeId);
|
|
55
|
+
const meshId = normalizeOptionalString(args.meshId);
|
|
56
|
+
const requestedBranch = normalizeOptionalString(args.branch);
|
|
57
|
+
const updateSubmodules = args.updateSubmodules === true;
|
|
58
|
+
const dryRun = args.dryRun === true || args.execute !== true;
|
|
59
|
+
const plannedSteps = buildPlannedSteps(updateSubmodules);
|
|
60
|
+
const base: MeshFastForwardBase = {
|
|
61
|
+
...(nodeId ? { nodeId } : {}),
|
|
62
|
+
...(meshId ? { meshId } : {}),
|
|
63
|
+
workspace,
|
|
64
|
+
dryRun,
|
|
65
|
+
updateSubmodules,
|
|
66
|
+
plannedSteps,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (!workspace) {
|
|
70
|
+
return block(base, 'invalid_workspace', ['workspace_required']);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const current = await getGitRepoStatus(workspace, {
|
|
74
|
+
...STATUS_OPTIONS,
|
|
75
|
+
submoduleIgnorePaths: args.submoduleIgnorePaths,
|
|
76
|
+
timeoutMs: args.timeoutMs ?? STATUS_OPTIONS.timeoutMs,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const earlyBlockers = collectPreflightBlockers(current, requestedBranch);
|
|
80
|
+
if (earlyBlockers.length > 0) {
|
|
81
|
+
return {
|
|
82
|
+
...block(base, chooseBlockCode(current, earlyBlockers), earlyBlockers),
|
|
83
|
+
current,
|
|
84
|
+
finalBranchConvergenceState: buildConvergenceState(current, codeToConvergenceStatus(chooseBlockCode(current, earlyBlockers))),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (current.behind === 0) {
|
|
89
|
+
const result: MeshFastForwardResult = {
|
|
90
|
+
...base,
|
|
91
|
+
success: true,
|
|
92
|
+
code: 'already_up_to_date',
|
|
93
|
+
allowed: true,
|
|
94
|
+
willRun: false,
|
|
95
|
+
executed: false,
|
|
96
|
+
blockingReasons: [],
|
|
97
|
+
current,
|
|
98
|
+
preStatus: current,
|
|
99
|
+
postStatus: current,
|
|
100
|
+
finalBranchConvergenceState: buildConvergenceState(current, 'up_to_date'),
|
|
101
|
+
};
|
|
102
|
+
await appendFastForwardLedger(result, 'noop');
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ancestorCheck = await verifyHeadIsAncestorOfUpstream(workspace, current.upstream || '', args.timeoutMs);
|
|
107
|
+
if (!ancestorCheck.ok) {
|
|
108
|
+
const result: MeshFastForwardResult = {
|
|
109
|
+
...block(base, 'non_fast_forward', ['head_is_not_ancestor_of_upstream']),
|
|
110
|
+
current,
|
|
111
|
+
preStatus: current,
|
|
112
|
+
operationError: ancestorCheck.error,
|
|
113
|
+
finalBranchConvergenceState: buildConvergenceState(current, 'not_mergeable'),
|
|
114
|
+
};
|
|
115
|
+
await appendFastForwardLedger(result, 'blocked');
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (dryRun) {
|
|
120
|
+
const result: MeshFastForwardResult = {
|
|
121
|
+
...base,
|
|
122
|
+
success: true,
|
|
123
|
+
code: 'fast_forward_available',
|
|
124
|
+
allowed: true,
|
|
125
|
+
willRun: false,
|
|
126
|
+
executed: false,
|
|
127
|
+
blockingReasons: [],
|
|
128
|
+
current,
|
|
129
|
+
preStatus: current,
|
|
130
|
+
finalBranchConvergenceState: buildConvergenceState(current, 'fast_forward_available'),
|
|
131
|
+
};
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await runGit(workspace, ['merge', '--ff-only', current.upstream || ''], { timeoutMs: args.timeoutMs ?? 30_000 });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const result: MeshFastForwardResult = {
|
|
139
|
+
...block(base, 'merge_ff_only_failed', ['merge_ff_only_failed']),
|
|
140
|
+
current,
|
|
141
|
+
preStatus: current,
|
|
142
|
+
operationError: formatGitError(error),
|
|
143
|
+
finalBranchConvergenceState: buildConvergenceState(current, 'not_mergeable'),
|
|
144
|
+
};
|
|
145
|
+
await appendFastForwardLedger(result, 'failed');
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let postStatus = await getGitRepoStatus(workspace, {
|
|
150
|
+
...STATUS_OPTIONS,
|
|
151
|
+
submoduleIgnorePaths: args.submoduleIgnorePaths,
|
|
152
|
+
timeoutMs: args.timeoutMs ?? STATUS_OPTIONS.timeoutMs,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const submoduleIssues = collectSubmoduleBlockers(postStatus, 'post');
|
|
156
|
+
let submoduleFollowUpRequired = false;
|
|
157
|
+
let operationError: string | undefined;
|
|
158
|
+
if (submoduleIssues.length > 0) {
|
|
159
|
+
if (updateSubmodules) {
|
|
160
|
+
try {
|
|
161
|
+
await runGit(workspace, ['submodule', 'update', '--init', '--recursive'], { timeoutMs: args.timeoutMs ?? 60_000 });
|
|
162
|
+
postStatus = await getGitRepoStatus(workspace, {
|
|
163
|
+
...STATUS_OPTIONS,
|
|
164
|
+
submoduleIgnorePaths: args.submoduleIgnorePaths,
|
|
165
|
+
timeoutMs: args.timeoutMs ?? STATUS_OPTIONS.timeoutMs,
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
operationError = formatGitError(error);
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
submoduleFollowUpRequired = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const postBlockers = collectPostExecutionBlockers(postStatus);
|
|
176
|
+
if (operationError) postBlockers.push('submodule_update_failed');
|
|
177
|
+
if (submoduleFollowUpRequired) postBlockers.push('submodule_update_required');
|
|
178
|
+
|
|
179
|
+
const success = postBlockers.length === 0 || submoduleFollowUpRequired;
|
|
180
|
+
const code = postBlockers.length === 0
|
|
181
|
+
? 'fast_forward_applied'
|
|
182
|
+
: submoduleFollowUpRequired
|
|
183
|
+
? 'fast_forward_applied_submodule_update_required'
|
|
184
|
+
: 'post_verify_failed';
|
|
185
|
+
const result: MeshFastForwardResult = {
|
|
186
|
+
...base,
|
|
187
|
+
success,
|
|
188
|
+
code,
|
|
189
|
+
allowed: true,
|
|
190
|
+
willRun: true,
|
|
191
|
+
executed: true,
|
|
192
|
+
blockingReasons: postBlockers,
|
|
193
|
+
current,
|
|
194
|
+
preStatus: current,
|
|
195
|
+
postStatus,
|
|
196
|
+
...(operationError ? { operationError } : {}),
|
|
197
|
+
finalBranchConvergenceState: buildConvergenceState(
|
|
198
|
+
postStatus,
|
|
199
|
+
postBlockers.length === 0 ? 'fast_forwarded' : submoduleFollowUpRequired ? 'follow_up_required' : 'post_verify_failed',
|
|
200
|
+
),
|
|
201
|
+
};
|
|
202
|
+
await appendFastForwardLedger(result, success ? 'executed' : 'failed');
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildPlannedSteps(updateSubmodules: boolean): MeshFastForwardPlannedStep[] {
|
|
207
|
+
const steps: MeshFastForwardPlannedStep[] = [
|
|
208
|
+
{
|
|
209
|
+
operation: 'refresh_upstream',
|
|
210
|
+
description: 'Refresh the tracked upstream remote ref before trusting ahead/behind state.',
|
|
211
|
+
safe: true,
|
|
212
|
+
willMutateWorktree: false,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
operation: 'verify_clean_worktree',
|
|
216
|
+
description: 'Require clean staged/modified/untracked/deleted/renamed/conflict/stash/submodule state.',
|
|
217
|
+
safe: true,
|
|
218
|
+
willMutateWorktree: false,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
operation: 'verify_fast_forward',
|
|
222
|
+
description: 'Require ahead=0, behind>0, and HEAD to be an ancestor of the upstream ref.',
|
|
223
|
+
safe: true,
|
|
224
|
+
willMutateWorktree: false,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
operation: 'merge_ff_only',
|
|
228
|
+
description: 'Apply git merge --ff-only against the tracked upstream; no force, reset, rebase, push, or deploy.',
|
|
229
|
+
safe: true,
|
|
230
|
+
willMutateWorktree: true,
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
if (updateSubmodules) {
|
|
234
|
+
steps.push({
|
|
235
|
+
operation: 'submodule_update',
|
|
236
|
+
description: 'If the fast-forward changes gitlinks, run git submodule update --init --recursive and re-verify submodules.',
|
|
237
|
+
safe: true,
|
|
238
|
+
willMutateWorktree: true,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
steps.push({
|
|
242
|
+
operation: 'verify_post_status',
|
|
243
|
+
description: 'Re-read daemon-owned git status and report final branch convergence state.',
|
|
244
|
+
safe: true,
|
|
245
|
+
willMutateWorktree: false,
|
|
246
|
+
});
|
|
247
|
+
return steps;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function collectPreflightBlockers(status: GitRepoStatus, requestedBranch?: string): string[] {
|
|
251
|
+
const blockers: string[] = [];
|
|
252
|
+
if (!status.isGitRepo) blockers.push('not_git_repo');
|
|
253
|
+
if (!status.branch) blockers.push('detached_head_or_unknown_branch');
|
|
254
|
+
if (requestedBranch && status.branch !== requestedBranch) blockers.push('branch_mismatch');
|
|
255
|
+
if (!status.upstream) blockers.push('upstream_missing');
|
|
256
|
+
if (status.upstreamStatus !== 'fresh') blockers.push('upstream_not_fresh');
|
|
257
|
+
if (status.hasConflicts) blockers.push('conflicts_present');
|
|
258
|
+
if (status.staged > 0) blockers.push('staged_changes_present');
|
|
259
|
+
if (status.modified > 0) blockers.push('modified_changes_present');
|
|
260
|
+
if (status.untracked > 0) blockers.push('untracked_changes_present');
|
|
261
|
+
if (status.deleted > 0) blockers.push('deleted_changes_present');
|
|
262
|
+
if (status.renamed > 0) blockers.push('renamed_changes_present');
|
|
263
|
+
if (status.stashCount > 0) blockers.push('stash_entries_present');
|
|
264
|
+
blockers.push(...collectSubmoduleBlockers(status, 'pre'));
|
|
265
|
+
if (status.ahead > 0 && status.behind > 0) {
|
|
266
|
+
blockers.push('branch_diverged_from_upstream');
|
|
267
|
+
blockers.push('branch_has_local_commits');
|
|
268
|
+
} else if (status.ahead > 0) blockers.push('branch_has_local_commits');
|
|
269
|
+
return blockers;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function collectPostExecutionBlockers(status: GitRepoStatus): string[] {
|
|
273
|
+
const blockers: string[] = [];
|
|
274
|
+
if (!status.isGitRepo) blockers.push('post_not_git_repo');
|
|
275
|
+
if (status.hasConflicts) blockers.push('post_conflicts_present');
|
|
276
|
+
if (status.ahead !== 0) blockers.push('post_branch_ahead');
|
|
277
|
+
if (status.behind !== 0) blockers.push('post_branch_still_behind');
|
|
278
|
+
if (status.staged > 0 || status.modified > 0 || status.untracked > 0 || status.deleted > 0 || status.renamed > 0) {
|
|
279
|
+
blockers.push('post_working_tree_not_clean');
|
|
280
|
+
}
|
|
281
|
+
if (status.stashCount > 0) blockers.push('post_stash_entries_present');
|
|
282
|
+
blockers.push(...collectSubmoduleBlockers(status, 'post'));
|
|
283
|
+
return blockers;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function collectSubmoduleBlockers(status: GitRepoStatus, phase: 'pre' | 'post'): string[] {
|
|
287
|
+
const submodules = Array.isArray(status.submodules) ? status.submodules : [];
|
|
288
|
+
const blockers: string[] = [];
|
|
289
|
+
for (const submodule of submodules) {
|
|
290
|
+
if (submodule.error) blockers.push(`${phase}_submodule_status_error:${submodule.path}`);
|
|
291
|
+
if (submodule.dirty) blockers.push(`${phase}_submodule_dirty:${submodule.path}`);
|
|
292
|
+
if (submodule.outOfSync) blockers.push(`${phase}_submodule_out_of_sync:${submodule.path}`);
|
|
293
|
+
}
|
|
294
|
+
return blockers;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function chooseBlockCode(status: GitRepoStatus, blockers: string[]): string {
|
|
298
|
+
if (blockers.includes('not_git_repo')) return 'not_git_repo';
|
|
299
|
+
if (blockers.includes('branch_mismatch')) return 'branch_mismatch';
|
|
300
|
+
if (blockers.includes('upstream_missing')) return 'upstream_missing';
|
|
301
|
+
if (blockers.includes('upstream_not_fresh')) return 'upstream_not_fresh';
|
|
302
|
+
if (blockers.some((reason) => reason.includes('submodule'))) return 'submodule_not_clean';
|
|
303
|
+
if (blockers.includes('branch_diverged_from_upstream')) return 'branch_diverged';
|
|
304
|
+
if (blockers.includes('branch_has_local_commits') || status.ahead > 0) return 'branch_ahead';
|
|
305
|
+
if (blockers.some((reason) => reason.includes('changes') || reason.includes('conflicts') || reason.includes('stash'))) return 'dirty_worktree';
|
|
306
|
+
return 'preflight_blocked';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function codeToConvergenceStatus(code: string): string {
|
|
310
|
+
if (code === 'branch_diverged' || code === 'branch_ahead' || code === 'non_fast_forward') return 'not_mergeable';
|
|
311
|
+
if (code === 'dirty_worktree' || code === 'submodule_not_clean') return 'blocked_review';
|
|
312
|
+
return 'blocked';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function verifyHeadIsAncestorOfUpstream(workspace: string, upstream: string, timeoutMs?: number): Promise<{ ok: boolean; error?: string }> {
|
|
316
|
+
if (!upstream) return { ok: false, error: 'missing upstream' };
|
|
317
|
+
try {
|
|
318
|
+
await runGit(workspace, ['merge-base', '--is-ancestor', 'HEAD', upstream], { timeoutMs: timeoutMs ?? 15_000 });
|
|
319
|
+
return { ok: true };
|
|
320
|
+
} catch (error) {
|
|
321
|
+
return { ok: false, error: formatGitError(error) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function block(
|
|
326
|
+
base: MeshFastForwardBase,
|
|
327
|
+
code: string,
|
|
328
|
+
blockingReasons: string[],
|
|
329
|
+
): MeshFastForwardResult {
|
|
330
|
+
const normalizedReasons = normalizeBlockingReasons(blockingReasons);
|
|
331
|
+
return {
|
|
332
|
+
...base,
|
|
333
|
+
success: false,
|
|
334
|
+
code,
|
|
335
|
+
allowed: false,
|
|
336
|
+
willRun: false,
|
|
337
|
+
executed: false,
|
|
338
|
+
blockingReasons: normalizedReasons,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function normalizeBlockingReasons(reasons: string[]): string[] {
|
|
343
|
+
const normalized = new Set<string>();
|
|
344
|
+
for (const reason of reasons) {
|
|
345
|
+
normalized.add(reason);
|
|
346
|
+
}
|
|
347
|
+
if ([
|
|
348
|
+
'conflicts_present',
|
|
349
|
+
'staged_changes_present',
|
|
350
|
+
'modified_changes_present',
|
|
351
|
+
'untracked_changes_present',
|
|
352
|
+
'deleted_changes_present',
|
|
353
|
+
'renamed_changes_present',
|
|
354
|
+
].some((reason) => normalized.has(reason))) {
|
|
355
|
+
normalized.add('working_tree_not_clean');
|
|
356
|
+
}
|
|
357
|
+
return Array.from(normalized);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function buildConvergenceState(status: GitRepoStatus, convergenceStatus: string): Record<string, unknown> {
|
|
361
|
+
return {
|
|
362
|
+
status: convergenceStatus,
|
|
363
|
+
branch: status.branch,
|
|
364
|
+
headCommit: status.headCommit,
|
|
365
|
+
upstream: status.upstream,
|
|
366
|
+
ahead: status.ahead,
|
|
367
|
+
behind: status.behind,
|
|
368
|
+
dirty: status.staged + status.modified + status.untracked + status.deleted + status.renamed > 0 || status.hasConflicts,
|
|
369
|
+
stashCount: status.stashCount,
|
|
370
|
+
submodules: summarizeSubmodules(status.submodules),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function summarizeSubmodules(submodules: GitSubmoduleStatus[] | undefined): Array<Record<string, unknown>> {
|
|
375
|
+
return (submodules || []).map((submodule) => ({
|
|
376
|
+
path: submodule.path,
|
|
377
|
+
commit: submodule.commit,
|
|
378
|
+
dirty: submodule.dirty,
|
|
379
|
+
outOfSync: submodule.outOfSync,
|
|
380
|
+
...(submodule.error ? { error: submodule.error } : {}),
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function normalizeOptionalString(value: unknown): string | undefined {
|
|
385
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function formatGitError(error: unknown): string {
|
|
389
|
+
if (error instanceof GitCommandError) {
|
|
390
|
+
return error.stderr || error.stdout || error.message;
|
|
391
|
+
}
|
|
392
|
+
if (error instanceof Error) return error.message;
|
|
393
|
+
return String(error);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function appendFastForwardLedger(result: MeshFastForwardResult, outcome: 'noop' | 'blocked' | 'executed' | 'failed'): Promise<void> {
|
|
397
|
+
if (!result.meshId) return;
|
|
398
|
+
try {
|
|
399
|
+
const { appendLedgerEntry } = await import('./mesh-ledger.js');
|
|
400
|
+
appendLedgerEntry(result.meshId, {
|
|
401
|
+
kind: 'direct_fast_forward',
|
|
402
|
+
...(result.nodeId ? { nodeId: result.nodeId } : {}),
|
|
403
|
+
payload: {
|
|
404
|
+
operation: 'mesh_fast_forward_node',
|
|
405
|
+
outcome,
|
|
406
|
+
code: result.code,
|
|
407
|
+
workspace: result.workspace,
|
|
408
|
+
allowed: result.allowed,
|
|
409
|
+
dryRun: result.dryRun,
|
|
410
|
+
willRun: result.willRun,
|
|
411
|
+
executed: result.executed,
|
|
412
|
+
branch: result.postStatus?.branch ?? result.current?.branch,
|
|
413
|
+
upstream: result.postStatus?.upstream ?? result.current?.upstream,
|
|
414
|
+
before: result.current ? {
|
|
415
|
+
headCommit: result.current.headCommit,
|
|
416
|
+
ahead: result.current.ahead,
|
|
417
|
+
behind: result.current.behind,
|
|
418
|
+
} : undefined,
|
|
419
|
+
after: result.postStatus ? {
|
|
420
|
+
headCommit: result.postStatus.headCommit,
|
|
421
|
+
ahead: result.postStatus.ahead,
|
|
422
|
+
behind: result.postStatus.behind,
|
|
423
|
+
} : undefined,
|
|
424
|
+
blockingReasons: result.blockingReasons,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
} catch (error) {
|
|
428
|
+
result.ledgerError = error instanceof Error ? error.message : String(error);
|
|
429
|
+
}
|
|
430
|
+
}
|