@adhdev/daemon-core 0.9.82-rc.113 → 0.9.82-rc.115
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/config/mesh-config.d.ts +2 -0
- package/dist/git/git-commands.d.ts +5 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +976 -269
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +960 -260
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +13 -0
- package/dist/mesh/mesh-refine-status.d.ts +27 -0
- package/dist/mesh/preview-freshness.d.ts +18 -0
- package/dist/mesh/refine-config.d.ts +17 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +115 -0
- package/dist/repo-mesh-types.d.ts +17 -0
- package/package.json +1 -1
- package/src/commands/chat-commands.ts +28 -10
- package/src/commands/router.ts +341 -5
- package/src/config/mesh-config.ts +4 -1
- package/src/git/git-commands.ts +17 -5
- package/src/index.ts +13 -2
- package/src/mesh/mesh-active-work.ts +37 -0
- package/src/mesh/mesh-refine-status.ts +145 -0
- package/src/mesh/preview-freshness.ts +118 -0
- package/src/mesh/refine-config.ts +17 -7
- package/src/mesh/worktree-bootstrap-config.ts +234 -0
- package/src/repo-mesh-types.ts +17 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { MeshLedgerEntry } from './mesh-ledger.js';
|
|
2
|
+
import type { PendingMeshCoordinatorEvent } from './mesh-events.js';
|
|
3
|
+
|
|
4
|
+
export type MeshAsyncRefineJobStatus = 'accepted' | 'running' | 'completed' | 'failed';
|
|
5
|
+
|
|
6
|
+
export interface MeshAsyncRefineJobSummary {
|
|
7
|
+
jobId: string;
|
|
8
|
+
interactionId?: string;
|
|
9
|
+
status: MeshAsyncRefineJobStatus;
|
|
10
|
+
meshId?: string;
|
|
11
|
+
nodeId?: string;
|
|
12
|
+
targetNodeId?: string;
|
|
13
|
+
targetDaemonId?: string;
|
|
14
|
+
workspace?: string;
|
|
15
|
+
branch?: string;
|
|
16
|
+
into?: string;
|
|
17
|
+
startedAt?: string;
|
|
18
|
+
completedAt?: string;
|
|
19
|
+
retryOfJobId?: string;
|
|
20
|
+
lastEvent?: string;
|
|
21
|
+
lastLedgerKind?: string;
|
|
22
|
+
lastUpdatedAt?: string;
|
|
23
|
+
instruction: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readString(value: unknown): string | undefined {
|
|
27
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
|
31
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
32
|
+
? value as Record<string, unknown>
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function eventStatus(event: string | undefined, fallback?: string): MeshAsyncRefineJobStatus | undefined {
|
|
37
|
+
if (event === 'refine:accepted') return 'accepted';
|
|
38
|
+
if (event === 'refine:completed') return 'completed';
|
|
39
|
+
if (event === 'refine:failed') return 'failed';
|
|
40
|
+
if (fallback === 'completed' || fallback === 'failed' || fallback === 'accepted') return fallback;
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ledgerStatus(kind: string, fallback?: string): MeshAsyncRefineJobStatus {
|
|
45
|
+
if (kind === 'task_completed') return 'completed';
|
|
46
|
+
if (kind === 'task_failed') return 'failed';
|
|
47
|
+
if (fallback === 'accepted') return 'accepted';
|
|
48
|
+
return 'running';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function instructionForStatus(status: MeshAsyncRefineJobStatus): string {
|
|
52
|
+
if (status === 'accepted') return 'Refine job is accepted; wait for asyncRefineJobs or pendingCoordinatorEvents to report running/completed/failed.';
|
|
53
|
+
if (status === 'running') return 'Refine job is running; do not poll the ledger repeatedly. Watch asyncRefineJobs or pendingCoordinatorEvents for the terminal result.';
|
|
54
|
+
if (status === 'completed') return 'Refine job completed; inspect branch convergence and cleanup evidence before reporting final merge state.';
|
|
55
|
+
return 'Refine job failed; inspect result/finalBranchConvergenceState in mesh_task_history, fix the blocker, then rerun mesh_refine_node when ready.';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mergeJob(
|
|
59
|
+
jobs: Map<string, MeshAsyncRefineJobSummary>,
|
|
60
|
+
patch: Partial<MeshAsyncRefineJobSummary> & { jobId?: string },
|
|
61
|
+
): void {
|
|
62
|
+
const jobId = readString(patch.jobId);
|
|
63
|
+
if (!jobId) return;
|
|
64
|
+
const previous = jobs.get(jobId);
|
|
65
|
+
const status = patch.status || previous?.status || 'running';
|
|
66
|
+
const definedPatch = Object.fromEntries(
|
|
67
|
+
Object.entries(patch).filter(([, value]) => value !== undefined),
|
|
68
|
+
) as Partial<MeshAsyncRefineJobSummary>;
|
|
69
|
+
jobs.set(jobId, {
|
|
70
|
+
...previous,
|
|
71
|
+
...definedPatch,
|
|
72
|
+
jobId,
|
|
73
|
+
status,
|
|
74
|
+
instruction: instructionForStatus(status),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildMeshAsyncRefineJobs(args: {
|
|
79
|
+
meshId?: string;
|
|
80
|
+
ledgerEntries?: MeshLedgerEntry[];
|
|
81
|
+
pendingEvents?: PendingMeshCoordinatorEvent[];
|
|
82
|
+
}): MeshAsyncRefineJobSummary[] {
|
|
83
|
+
const jobs = new Map<string, MeshAsyncRefineJobSummary>();
|
|
84
|
+
|
|
85
|
+
for (const entry of args.ledgerEntries || []) {
|
|
86
|
+
const payload = readRecord(entry.payload);
|
|
87
|
+
if (payload?.source !== 'refine_mesh_node_async_job') continue;
|
|
88
|
+
const refineJob = readRecord(payload.refineJob);
|
|
89
|
+
const result = readRecord(payload.result);
|
|
90
|
+
const finalState = readRecord(payload.finalBranchConvergenceState) || readRecord(result?.finalBranchConvergenceState);
|
|
91
|
+
const jobId = readString(refineJob?.jobId);
|
|
92
|
+
if (!jobId) continue;
|
|
93
|
+
const status = ledgerStatus(entry.kind, readString(refineJob?.status));
|
|
94
|
+
mergeJob(jobs, {
|
|
95
|
+
jobId,
|
|
96
|
+
interactionId: readString(refineJob?.interactionId),
|
|
97
|
+
status,
|
|
98
|
+
meshId: readString(refineJob?.meshId) || args.meshId,
|
|
99
|
+
nodeId: readString(refineJob?.nodeId) || entry.nodeId,
|
|
100
|
+
targetNodeId: readString(refineJob?.nodeId) || entry.nodeId,
|
|
101
|
+
targetDaemonId: readString(refineJob?.targetDaemonId),
|
|
102
|
+
workspace: readString(refineJob?.workspace),
|
|
103
|
+
branch: readString(result?.branch) || readString(finalState?.branch),
|
|
104
|
+
into: readString(result?.into) || readString(finalState?.baseBranch),
|
|
105
|
+
startedAt: readString(refineJob?.startedAt),
|
|
106
|
+
completedAt: readString(refineJob?.completedAt),
|
|
107
|
+
retryOfJobId: readString(refineJob?.retryOfJobId) || readString(payload.retryOfJobId),
|
|
108
|
+
lastLedgerKind: entry.kind,
|
|
109
|
+
lastUpdatedAt: entry.timestamp,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const event of args.pendingEvents || []) {
|
|
114
|
+
const metadata = readRecord(event.metadataEvent);
|
|
115
|
+
if (metadata?.source !== 'refine_mesh_node_async_job') continue;
|
|
116
|
+
const result = readRecord(metadata.result);
|
|
117
|
+
const finalState = readRecord(result?.finalBranchConvergenceState);
|
|
118
|
+
const jobId = readString(metadata.jobId);
|
|
119
|
+
if (!jobId) continue;
|
|
120
|
+
const status = eventStatus(event.event, readString(metadata.status));
|
|
121
|
+
mergeJob(jobs, {
|
|
122
|
+
jobId,
|
|
123
|
+
interactionId: readString(metadata.interactionId),
|
|
124
|
+
...(status ? { status } : {}),
|
|
125
|
+
meshId: readString(metadata.meshId) || event.meshId || args.meshId,
|
|
126
|
+
nodeId: readString(metadata.nodeId) || event.nodeId,
|
|
127
|
+
targetNodeId: readString(metadata.nodeId) || event.nodeId,
|
|
128
|
+
targetDaemonId: readString(metadata.targetDaemonId),
|
|
129
|
+
workspace: readString(metadata.workspace) || event.workspace,
|
|
130
|
+
branch: readString(result?.branch) || readString(finalState?.branch),
|
|
131
|
+
into: readString(result?.into) || readString(finalState?.baseBranch),
|
|
132
|
+
startedAt: readString(metadata.startedAt),
|
|
133
|
+
completedAt: readString(metadata.completedAt),
|
|
134
|
+
retryOfJobId: readString(metadata.retryOfJobId),
|
|
135
|
+
lastEvent: event.event,
|
|
136
|
+
lastUpdatedAt: new Date(event.queuedAt).toISOString(),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return Array.from(jobs.values()).sort((a, b) => {
|
|
141
|
+
const aTime = new Date(a.lastUpdatedAt || a.startedAt || '').getTime();
|
|
142
|
+
const bTime = new Date(b.lastUpdatedAt || b.startedAt || '').getTime();
|
|
143
|
+
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export type PreviewFreshnessStatus = 'fresh' | 'stale' | 'unknown' | 'not_configured';
|
|
6
|
+
|
|
7
|
+
export interface PreviewFreshness {
|
|
8
|
+
status: PreviewFreshnessStatus;
|
|
9
|
+
lastPreviewCommit: string | null;
|
|
10
|
+
currentMainCommit: string | null;
|
|
11
|
+
currentMainCommitSource: 'origin/main' | 'HEAD' | 'unknown';
|
|
12
|
+
recordPath: string;
|
|
13
|
+
lastDeployedAt?: string;
|
|
14
|
+
lastTarget?: string;
|
|
15
|
+
previewVersion?: string;
|
|
16
|
+
targets: Record<'npm' | 'server' | 'web', {
|
|
17
|
+
commit: string | null;
|
|
18
|
+
deployedAt?: string;
|
|
19
|
+
status: PreviewFreshnessStatus;
|
|
20
|
+
}>;
|
|
21
|
+
nextAction: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PREVIEW_DEPLOY_RECORD = '.adhdev/preview-deploy.json';
|
|
25
|
+
|
|
26
|
+
function runGit(repoRoot: string, args: readonly string[]): string {
|
|
27
|
+
try {
|
|
28
|
+
return execFileSync('git', args, {
|
|
29
|
+
cwd: repoRoot,
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
32
|
+
timeout: 5000,
|
|
33
|
+
}).trim();
|
|
34
|
+
} catch {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readRecord(repoRoot: string): Record<string, unknown> | null {
|
|
40
|
+
const path = resolve(repoRoot, PREVIEW_DEPLOY_RECORD);
|
|
41
|
+
if (!existsSync(path)) return null;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
44
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
45
|
+
? parsed as Record<string, unknown>
|
|
46
|
+
: null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeCommit(value: unknown): string | null {
|
|
53
|
+
return typeof value === 'string' && /^[0-9a-f]{7,40}$/i.test(value.trim())
|
|
54
|
+
? value.trim()
|
|
55
|
+
: null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readTargetFreshness(record: Record<string, unknown> | null, currentCommit: string | null): PreviewFreshness['targets'] {
|
|
59
|
+
const targets = record?.targets && typeof record.targets === 'object' && !Array.isArray(record.targets)
|
|
60
|
+
? record.targets as Record<string, unknown>
|
|
61
|
+
: {};
|
|
62
|
+
const result = {} as PreviewFreshness['targets'];
|
|
63
|
+
for (const targetName of ['npm', 'server', 'web'] as const) {
|
|
64
|
+
const targetRecord = targets[targetName] && typeof targets[targetName] === 'object' && !Array.isArray(targets[targetName])
|
|
65
|
+
? targets[targetName] as Record<string, unknown>
|
|
66
|
+
: {};
|
|
67
|
+
const commit = normalizeCommit(targetRecord.commit);
|
|
68
|
+
result[targetName] = {
|
|
69
|
+
commit,
|
|
70
|
+
deployedAt: typeof targetRecord.deployedAt === 'string' ? targetRecord.deployedAt : undefined,
|
|
71
|
+
status: commit && currentCommit ? (commit === currentCommit ? 'fresh' : 'stale') : 'unknown',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readCurrentMainCommit(repoRoot: string): Pick<PreviewFreshness, 'currentMainCommit' | 'currentMainCommitSource'> {
|
|
78
|
+
const originMain = runGit(repoRoot, ['rev-parse', '--verify', 'origin/main^{commit}']);
|
|
79
|
+
if (originMain) {
|
|
80
|
+
return { currentMainCommit: originMain, currentMainCommitSource: 'origin/main' };
|
|
81
|
+
}
|
|
82
|
+
const head = runGit(repoRoot, ['rev-parse', '--verify', 'HEAD']);
|
|
83
|
+
if (head) {
|
|
84
|
+
return { currentMainCommit: head, currentMainCommitSource: 'HEAD' };
|
|
85
|
+
}
|
|
86
|
+
return { currentMainCommit: null, currentMainCommitSource: 'unknown' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildPreviewFreshness(repoRoot: string): PreviewFreshness {
|
|
90
|
+
const current = readCurrentMainCommit(repoRoot);
|
|
91
|
+
const record = readRecord(repoRoot);
|
|
92
|
+
const lastPreviewCommit = normalizeCommit(record?.lastPreviewCommit);
|
|
93
|
+
const targets = readTargetFreshness(record, current.currentMainCommit);
|
|
94
|
+
let status: PreviewFreshnessStatus = 'unknown';
|
|
95
|
+
let nextAction = 'Run npm run deploy:preview from the current main commit, then smoke preview.';
|
|
96
|
+
|
|
97
|
+
if (lastPreviewCommit && current.currentMainCommit) {
|
|
98
|
+
status = lastPreviewCommit === current.currentMainCommit ? 'fresh' : 'stale';
|
|
99
|
+
nextAction = status === 'fresh'
|
|
100
|
+
? 'No preview deploy action needed.'
|
|
101
|
+
: 'Run npm run deploy:preview from origin/main, then smoke preview.';
|
|
102
|
+
} else if (!current.currentMainCommit) {
|
|
103
|
+
nextAction = 'Resolve the current main commit before judging preview freshness.';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
status,
|
|
108
|
+
lastPreviewCommit,
|
|
109
|
+
currentMainCommit: current.currentMainCommit,
|
|
110
|
+
currentMainCommitSource: current.currentMainCommitSource,
|
|
111
|
+
recordPath: PREVIEW_DEPLOY_RECORD,
|
|
112
|
+
lastDeployedAt: typeof record?.updatedAt === 'string' ? record.updatedAt : undefined,
|
|
113
|
+
lastTarget: typeof record?.target === 'string' ? record.target : undefined,
|
|
114
|
+
previewVersion: typeof record?.previewVersion === 'string' ? record.previewVersion : undefined,
|
|
115
|
+
targets,
|
|
116
|
+
nextAction,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -13,6 +13,7 @@ export interface RepoMeshRefineValidationCommandConfig {
|
|
|
13
13
|
category?: MeshRefineValidationCategory;
|
|
14
14
|
cwd?: string;
|
|
15
15
|
timeoutMs?: number;
|
|
16
|
+
outputLimitBytes?: number;
|
|
16
17
|
env?: Record<string, string>;
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -44,6 +45,7 @@ export interface MeshRefineValidationCommandPlan {
|
|
|
44
45
|
source: string;
|
|
45
46
|
cwd?: string;
|
|
46
47
|
timeoutMs?: number;
|
|
48
|
+
outputLimitBytes?: number;
|
|
47
49
|
env?: Record<string, string>;
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -110,6 +112,7 @@ export const MESH_REFINE_CONFIG_SCHEMA = {
|
|
|
110
112
|
category: { enum: [...MESH_REFINE_VALIDATION_CATEGORIES, 'custom'] },
|
|
111
113
|
cwd: { type: 'string' },
|
|
112
114
|
timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
|
|
115
|
+
outputLimitBytes: { type: 'number', minimum: 1024, maximum: 1048576 },
|
|
113
116
|
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
114
117
|
},
|
|
115
118
|
},
|
|
@@ -127,6 +130,7 @@ export const MESH_REFINE_CONFIG_SCHEMA = {
|
|
|
127
130
|
category: { enum: [...MESH_REFINE_VALIDATION_CATEGORIES, 'custom'] },
|
|
128
131
|
cwd: { type: 'string' },
|
|
129
132
|
timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
|
|
133
|
+
outputLimitBytes: { type: 'number', minimum: 1024, maximum: 1048576 },
|
|
130
134
|
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
131
135
|
},
|
|
132
136
|
},
|
|
@@ -136,7 +140,7 @@ export const MESH_REFINE_CONFIG_SCHEMA = {
|
|
|
136
140
|
},
|
|
137
141
|
} as const;
|
|
138
142
|
|
|
139
|
-
function
|
|
143
|
+
export function isMeshConfigRecord(value: unknown): value is Record<string, unknown> {
|
|
140
144
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
141
145
|
}
|
|
142
146
|
|
|
@@ -158,8 +162,8 @@ function validateCategory(value: unknown): MeshRefineValidationCategory | 'custo
|
|
|
158
162
|
: 'custom';
|
|
159
163
|
}
|
|
160
164
|
|
|
161
|
-
function
|
|
162
|
-
if (!
|
|
165
|
+
export function normalizeMeshCommandConfig(entry: unknown, source: string): { command?: MeshRefineValidationCommandPlan; rejected?: Record<string, unknown> } {
|
|
166
|
+
if (!isMeshConfigRecord(entry) || typeof entry.command !== 'string') {
|
|
163
167
|
return { rejected: { source, reason: 'validation command must be an object with a command string' } };
|
|
164
168
|
}
|
|
165
169
|
|
|
@@ -189,7 +193,10 @@ function normalizeCommandConfig(entry: unknown, source: string): { command?: Mes
|
|
|
189
193
|
if (entry.timeoutMs !== undefined && (typeof entry.timeoutMs !== 'number' || !Number.isFinite(entry.timeoutMs) || entry.timeoutMs < 1000 || entry.timeoutMs > 600000)) {
|
|
190
194
|
return { rejected: { source, command: commandText, reason: 'timeoutMs must be between 1000 and 600000' } };
|
|
191
195
|
}
|
|
192
|
-
if (entry.
|
|
196
|
+
if (entry.outputLimitBytes !== undefined && (typeof entry.outputLimitBytes !== 'number' || !Number.isFinite(entry.outputLimitBytes) || entry.outputLimitBytes < 1024 || entry.outputLimitBytes > 1048576)) {
|
|
197
|
+
return { rejected: { source, command: commandText, reason: 'outputLimitBytes must be between 1024 and 1048576' } };
|
|
198
|
+
}
|
|
199
|
+
if (entry.env !== undefined && (!isMeshConfigRecord(entry.env) || !Object.values(entry.env).every(value => typeof value === 'string'))) {
|
|
193
200
|
return { rejected: { source, command: commandText, reason: 'env must be an object of string values' } };
|
|
194
201
|
}
|
|
195
202
|
|
|
@@ -202,11 +209,14 @@ function normalizeCommandConfig(entry: unknown, source: string): { command?: Mes
|
|
|
202
209
|
source,
|
|
203
210
|
...(typeof entry.cwd === 'string' && entry.cwd.trim() ? { cwd: entry.cwd.trim() } : {}),
|
|
204
211
|
...(typeof entry.timeoutMs === 'number' ? { timeoutMs: entry.timeoutMs } : {}),
|
|
205
|
-
...(
|
|
212
|
+
...(typeof entry.outputLimitBytes === 'number' ? { outputLimitBytes: entry.outputLimitBytes } : {}),
|
|
213
|
+
...(isMeshConfigRecord(entry.env) ? { env: entry.env as Record<string, string> } : {}),
|
|
206
214
|
},
|
|
207
215
|
};
|
|
208
216
|
}
|
|
209
217
|
|
|
218
|
+
const isRecord = isMeshConfigRecord;
|
|
219
|
+
|
|
210
220
|
export function validateMeshRefineConfig(config: unknown, source = 'inline'): { valid: boolean; errors: string[]; bootstrapCommands: MeshRefineValidationCommandPlan[]; commands: MeshRefineValidationCommandPlan[]; rejectedCommands: Array<Record<string, unknown>> } {
|
|
211
221
|
const errors: string[] = [];
|
|
212
222
|
const bootstrapCommands: MeshRefineValidationCommandPlan[] = [];
|
|
@@ -226,14 +236,14 @@ export function validateMeshRefineConfig(config: unknown, source = 'inline'): {
|
|
|
226
236
|
if (rawBootstrapCommands !== undefined && !Array.isArray(rawBootstrapCommands)) errors.push('validation.bootstrapCommands must be an array');
|
|
227
237
|
if (Array.isArray(rawBootstrapCommands)) {
|
|
228
238
|
rawBootstrapCommands.forEach((entry, index) => {
|
|
229
|
-
const normalized =
|
|
239
|
+
const normalized = normalizeMeshCommandConfig(entry, `${source}:validation.bootstrapCommands[${index}]`);
|
|
230
240
|
if (normalized.command) bootstrapCommands.push(normalized.command);
|
|
231
241
|
if (normalized.rejected) rejectedCommands.push(normalized.rejected);
|
|
232
242
|
});
|
|
233
243
|
}
|
|
234
244
|
if (Array.isArray(rawCommands)) {
|
|
235
245
|
rawCommands.forEach((entry, index) => {
|
|
236
|
-
const normalized =
|
|
246
|
+
const normalized = normalizeMeshCommandConfig(entry, `${source}:validation.commands[${index}]`);
|
|
237
247
|
if (normalized.command) commands.push(normalized.command);
|
|
238
248
|
if (normalized.rejected) rejectedCommands.push(normalized.rejected);
|
|
239
249
|
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, resolve as pathResolve } from 'path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import * as yaml from 'js-yaml';
|
|
6
|
+
import {
|
|
7
|
+
isMeshConfigRecord,
|
|
8
|
+
normalizeMeshCommandConfig,
|
|
9
|
+
type MeshRefineValidationCommandPlan,
|
|
10
|
+
type RepoMeshRefineValidationCommandConfig,
|
|
11
|
+
} from './refine-config.js';
|
|
12
|
+
|
|
13
|
+
export type WorktreeBootstrapStatus = 'ready' | 'running' | 'failed' | 'not_configured' | 'disabled' | 'stale';
|
|
14
|
+
|
|
15
|
+
export interface RepoMeshWorktreeBootstrapConfig {
|
|
16
|
+
version: 1;
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
runOnClone?: boolean;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
commands?: RepoMeshRefineValidationCommandConfig[];
|
|
21
|
+
staleInputs?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WorktreeBootstrapState {
|
|
25
|
+
status: WorktreeBootstrapStatus;
|
|
26
|
+
required: boolean;
|
|
27
|
+
configSource?: string;
|
|
28
|
+
configSourceType?: 'repo_file' | 'mesh_policy' | 'unavailable' | 'invalid';
|
|
29
|
+
startedAt?: string;
|
|
30
|
+
completedAt?: string;
|
|
31
|
+
lastCommand?: string;
|
|
32
|
+
exitCode?: number | null;
|
|
33
|
+
error?: string;
|
|
34
|
+
commandsRun?: Array<Record<string, unknown>>;
|
|
35
|
+
staleInputs?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WorktreeBootstrapConfigLoadResult {
|
|
39
|
+
config?: RepoMeshWorktreeBootstrapConfig;
|
|
40
|
+
source: string;
|
|
41
|
+
sourceType: 'repo_file' | 'mesh_policy' | 'unavailable' | 'invalid';
|
|
42
|
+
path?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const MESH_WORKTREE_BOOTSTRAP_CONFIG_LOCATIONS = [
|
|
47
|
+
'.adhdev/worktree_bootstrap.json',
|
|
48
|
+
'.adhdev/worktree_bootstrap.yaml',
|
|
49
|
+
'.adhdev/worktree_bootstrap.yml',
|
|
50
|
+
'.adhdev/worktree-bootstrap.json',
|
|
51
|
+
'.adhdev/worktree-bootstrap.yaml',
|
|
52
|
+
'.adhdev/worktree-bootstrap.yml',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export const MESH_WORKTREE_BOOTSTRAP_CONFIG_SCHEMA = {
|
|
56
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
57
|
+
title: 'ADHDev Repo Mesh Worktree Bootstrap Config',
|
|
58
|
+
type: 'object',
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
required: ['version'],
|
|
61
|
+
properties: {
|
|
62
|
+
version: { const: 1 },
|
|
63
|
+
enabled: { type: 'boolean', default: true },
|
|
64
|
+
runOnClone: { type: 'boolean', default: true },
|
|
65
|
+
required: { type: 'boolean', default: true },
|
|
66
|
+
staleInputs: { type: 'array', maxItems: 16, items: { type: 'string', minLength: 1 } },
|
|
67
|
+
commands: {
|
|
68
|
+
type: 'array',
|
|
69
|
+
minItems: 1,
|
|
70
|
+
maxItems: 4,
|
|
71
|
+
items: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
required: ['command'],
|
|
75
|
+
properties: {
|
|
76
|
+
command: { type: 'string', minLength: 1 },
|
|
77
|
+
args: { type: 'array', items: { type: 'string' } },
|
|
78
|
+
category: { enum: ['typecheck', 'test', 'lint', 'build', 'custom'] },
|
|
79
|
+
cwd: { type: 'string' },
|
|
80
|
+
timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
|
|
81
|
+
outputLimitBytes: { type: 'number', minimum: 1024, maximum: 1048576 },
|
|
82
|
+
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
} as const;
|
|
88
|
+
|
|
89
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
90
|
+
const DEFAULT_OUTPUT_LIMIT_BYTES = 128 * 1024;
|
|
91
|
+
const OUTPUT_SUMMARY_CHARS = 2_000;
|
|
92
|
+
|
|
93
|
+
function parseConfigText(path: string, text: string): unknown {
|
|
94
|
+
if (/\.json$/i.test(path)) return JSON.parse(text);
|
|
95
|
+
return yaml.load(text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function truncateOutput(value: unknown): string {
|
|
99
|
+
const text = typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
100
|
+
if (text.length <= OUTPUT_SUMMARY_CHARS) return text;
|
|
101
|
+
return `${text.slice(0, OUTPUT_SUMMARY_CHARS)}\n[truncated ${text.length - OUTPUT_SUMMARY_CHARS} chars]`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function validateMeshWorktreeBootstrapConfig(config: unknown, source = 'inline'): {
|
|
105
|
+
valid: boolean;
|
|
106
|
+
errors: string[];
|
|
107
|
+
commands: MeshRefineValidationCommandPlan[];
|
|
108
|
+
rejectedCommands: Array<Record<string, unknown>>;
|
|
109
|
+
} {
|
|
110
|
+
const errors: string[] = [];
|
|
111
|
+
const commands: MeshRefineValidationCommandPlan[] = [];
|
|
112
|
+
const rejectedCommands: Array<Record<string, unknown>> = [];
|
|
113
|
+
if (!isMeshConfigRecord(config)) return { valid: false, errors: ['config must be an object'], commands, rejectedCommands };
|
|
114
|
+
if (config.version !== 1) errors.push('version must be 1');
|
|
115
|
+
if (config.enabled !== undefined && typeof config.enabled !== 'boolean') errors.push('enabled must be a boolean when provided');
|
|
116
|
+
if (config.runOnClone !== undefined && typeof config.runOnClone !== 'boolean') errors.push('runOnClone must be a boolean when provided');
|
|
117
|
+
if (config.required !== undefined && typeof config.required !== 'boolean') errors.push('required must be a boolean when provided');
|
|
118
|
+
if (config.staleInputs !== undefined && (!Array.isArray(config.staleInputs) || !config.staleInputs.every(input => typeof input === 'string' && input.trim()))) {
|
|
119
|
+
errors.push('staleInputs must be an array of non-empty strings when provided');
|
|
120
|
+
}
|
|
121
|
+
if (config.commands !== undefined && !Array.isArray(config.commands)) errors.push('commands must be an array');
|
|
122
|
+
if (Array.isArray(config.commands)) {
|
|
123
|
+
config.commands.forEach((entry, index) => {
|
|
124
|
+
const normalized = normalizeMeshCommandConfig(entry, `${source}:commands[${index}]`);
|
|
125
|
+
if (normalized.command) commands.push(normalized.command);
|
|
126
|
+
if (normalized.rejected) rejectedCommands.push(normalized.rejected);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (config.enabled !== false && config.runOnClone !== false && commands.length === 0) errors.push('commands must contain at least one command when bootstrap is enabled');
|
|
130
|
+
if (rejectedCommands.length) errors.push('one or more bootstrap commands are invalid');
|
|
131
|
+
return { valid: errors.length === 0, errors, commands, rejectedCommands };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function loadMeshWorktreeBootstrapConfig(mesh: any, workspace: string): WorktreeBootstrapConfigLoadResult {
|
|
135
|
+
const inline = mesh?.worktreeBootstrapConfig || mesh?.policy?.worktreeBootstrapConfig || mesh?.policy?.worktreeBootstrap;
|
|
136
|
+
if (inline !== undefined) {
|
|
137
|
+
const validation = validateMeshWorktreeBootstrapConfig(inline, 'mesh.policy.worktreeBootstrapConfig');
|
|
138
|
+
if (!validation.valid) return { source: 'mesh.policy.worktreeBootstrapConfig', sourceType: 'invalid', error: String(validation.rejectedCommands[0]?.reason || validation.errors.join('; ')) };
|
|
139
|
+
return { config: inline as RepoMeshWorktreeBootstrapConfig, source: 'mesh.policy.worktreeBootstrapConfig', sourceType: 'mesh_policy' };
|
|
140
|
+
}
|
|
141
|
+
for (const relative of MESH_WORKTREE_BOOTSTRAP_CONFIG_LOCATIONS) {
|
|
142
|
+
const configPath = join(workspace, relative);
|
|
143
|
+
if (!existsSync(configPath)) continue;
|
|
144
|
+
try {
|
|
145
|
+
const parsed = parseConfigText(configPath, readFileSync(configPath, 'utf-8'));
|
|
146
|
+
const validation = validateMeshWorktreeBootstrapConfig(parsed, relative);
|
|
147
|
+
if (!validation.valid) return { source: relative, sourceType: 'invalid', path: configPath, error: String(validation.rejectedCommands[0]?.reason || validation.errors.join('; ')) };
|
|
148
|
+
return { config: parsed as RepoMeshWorktreeBootstrapConfig, source: relative, sourceType: 'repo_file', path: configPath };
|
|
149
|
+
} catch (error: any) {
|
|
150
|
+
return { source: relative, sourceType: 'invalid', path: configPath, error: error?.message || String(error) };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { source: 'unavailable', sourceType: 'unavailable', error: `No worktree bootstrap config found. Checked: ${MESH_WORKTREE_BOOTSTRAP_CONFIG_LOCATIONS.join(', ')}` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function runMeshWorktreeBootstrap(mesh: any, workspace: string): Promise<WorktreeBootstrapState> {
|
|
157
|
+
const loaded = loadMeshWorktreeBootstrapConfig(mesh, workspace);
|
|
158
|
+
if (!loaded.config) {
|
|
159
|
+
return { status: 'not_configured', required: false, configSource: loaded.source, configSourceType: loaded.sourceType, error: loaded.error };
|
|
160
|
+
}
|
|
161
|
+
const required = loaded.config.required !== false;
|
|
162
|
+
if (loaded.config.enabled === false || loaded.config.runOnClone === false) {
|
|
163
|
+
return { status: 'disabled', required, configSource: loaded.path || loaded.source, configSourceType: loaded.sourceType };
|
|
164
|
+
}
|
|
165
|
+
const validation = validateMeshWorktreeBootstrapConfig(loaded.config, loaded.source);
|
|
166
|
+
if (!validation.valid) {
|
|
167
|
+
return { status: 'failed', required, configSource: loaded.path || loaded.source, configSourceType: 'invalid', error: String(validation.rejectedCommands[0]?.reason || validation.errors.join('; ')), commandsRun: [] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const execFileAsync = promisify(execFile);
|
|
171
|
+
const state: WorktreeBootstrapState = {
|
|
172
|
+
status: 'running',
|
|
173
|
+
required,
|
|
174
|
+
configSource: loaded.path || loaded.source,
|
|
175
|
+
configSourceType: loaded.sourceType,
|
|
176
|
+
startedAt: new Date().toISOString(),
|
|
177
|
+
commandsRun: [],
|
|
178
|
+
staleInputs: loaded.config.staleInputs,
|
|
179
|
+
};
|
|
180
|
+
for (const command of validation.commands) {
|
|
181
|
+
const cwd = command.cwd ? pathResolve(workspace, command.cwd) : workspace;
|
|
182
|
+
const startedAt = Date.now();
|
|
183
|
+
state.lastCommand = command.displayCommand;
|
|
184
|
+
try {
|
|
185
|
+
const result = await execFileAsync(command.command, command.args, {
|
|
186
|
+
cwd,
|
|
187
|
+
encoding: 'utf8',
|
|
188
|
+
timeout: command.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
189
|
+
maxBuffer: command.outputLimitBytes || DEFAULT_OUTPUT_LIMIT_BYTES,
|
|
190
|
+
env: { ...process.env, CI: process.env.CI || '1', ...(command.env || {}) },
|
|
191
|
+
windowsHide: true,
|
|
192
|
+
});
|
|
193
|
+
state.commandsRun?.push({
|
|
194
|
+
command: command.command,
|
|
195
|
+
args: command.args,
|
|
196
|
+
displayCommand: command.displayCommand,
|
|
197
|
+
category: command.category,
|
|
198
|
+
source: command.source,
|
|
199
|
+
cwd,
|
|
200
|
+
passed: true,
|
|
201
|
+
durationMs: Date.now() - startedAt,
|
|
202
|
+
exitCode: 0,
|
|
203
|
+
stdout: truncateOutput(result.stdout),
|
|
204
|
+
stderr: truncateOutput(result.stderr),
|
|
205
|
+
});
|
|
206
|
+
} catch (error: any) {
|
|
207
|
+
const exitCode = typeof error?.code === 'number' ? error.code : null;
|
|
208
|
+
state.status = 'failed';
|
|
209
|
+
state.exitCode = exitCode;
|
|
210
|
+
state.error = error?.message || String(error);
|
|
211
|
+
state.completedAt = new Date().toISOString();
|
|
212
|
+
state.commandsRun?.push({
|
|
213
|
+
command: command.command,
|
|
214
|
+
args: command.args,
|
|
215
|
+
displayCommand: command.displayCommand,
|
|
216
|
+
category: command.category,
|
|
217
|
+
source: command.source,
|
|
218
|
+
cwd,
|
|
219
|
+
passed: false,
|
|
220
|
+
durationMs: Date.now() - startedAt,
|
|
221
|
+
exitCode,
|
|
222
|
+
signal: typeof error?.signal === 'string' ? error.signal : null,
|
|
223
|
+
timedOut: error?.killed === true || /timed out/i.test(String(error?.message || '')),
|
|
224
|
+
stdout: truncateOutput(error?.stdout),
|
|
225
|
+
stderr: truncateOutput(error?.stderr || error?.message),
|
|
226
|
+
});
|
|
227
|
+
return state;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
state.status = 'ready';
|
|
231
|
+
state.exitCode = 0;
|
|
232
|
+
state.completedAt = new Date().toISOString();
|
|
233
|
+
return state;
|
|
234
|
+
}
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -290,6 +290,20 @@ export interface LocalMeshNodeEntry {
|
|
|
290
290
|
worktreeBranch?: string;
|
|
291
291
|
/** Node ID this worktree was cloned from */
|
|
292
292
|
clonedFromNodeId?: string;
|
|
293
|
+
/** Repo-local preparation result for ADHDev-created worktree nodes. */
|
|
294
|
+
worktreeBootstrap?: {
|
|
295
|
+
status: 'ready' | 'running' | 'failed' | 'not_configured' | 'disabled' | 'stale';
|
|
296
|
+
required?: boolean;
|
|
297
|
+
configSource?: string;
|
|
298
|
+
configSourceType?: string;
|
|
299
|
+
startedAt?: string;
|
|
300
|
+
completedAt?: string;
|
|
301
|
+
lastCommand?: string;
|
|
302
|
+
exitCode?: number | null;
|
|
303
|
+
error?: string;
|
|
304
|
+
commandsRun?: Array<Record<string, unknown>>;
|
|
305
|
+
staleInputs?: string[];
|
|
306
|
+
};
|
|
293
307
|
/** Optional associated/external repos configured as node metadata. */
|
|
294
308
|
relatedRepos?: RepoMeshRelatedRepo[];
|
|
295
309
|
role?: RepoMeshDaemonRole;
|
|
@@ -361,6 +375,9 @@ export interface RepoMeshNodeStatus {
|
|
|
361
375
|
activeSessionDetails?: RepoMeshSessionStatus[];
|
|
362
376
|
providerPriority?: string[];
|
|
363
377
|
launchReady?: boolean;
|
|
378
|
+
worktreeBootstrap?: LocalMeshNodeEntry['worktreeBootstrap'];
|
|
379
|
+
launchBlockedReason?: string;
|
|
380
|
+
launchBlockedMessage?: string;
|
|
364
381
|
lastSeenAt?: string;
|
|
365
382
|
updatedAt?: string;
|
|
366
383
|
connection?: RepoMeshPeerConnectionStatus;
|