@atolis-hq/wake 0.2.52 → 0.2.53
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.
|
@@ -1,17 +1,71 @@
|
|
|
1
1
|
import { mkdir, rm } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
export
|
|
3
|
+
export class FakeWorkspaceValidationError extends Error {
|
|
4
|
+
failureSource = 'wake-workspace-validation';
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'FakeWorkspaceValidationError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function createFakeWorkspaceManager(root, options = {}) {
|
|
11
|
+
function validation(input) {
|
|
12
|
+
if (options.failValidation === true) {
|
|
13
|
+
throw new FakeWorkspaceValidationError('fake workspace validation failed');
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
repo: input.repo,
|
|
17
|
+
expectedBranch: input.branch,
|
|
18
|
+
actualBranch: input.branch,
|
|
19
|
+
expectedRemoteUrl: input.remoteUrl,
|
|
20
|
+
actualRemoteUrl: input.remoteUrl,
|
|
21
|
+
baseRevision: 'fake-base',
|
|
22
|
+
headRevision: 'fake-head',
|
|
23
|
+
clean: true,
|
|
24
|
+
remoteAvailable: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
4
27
|
return {
|
|
5
|
-
async prepareWorkspace({ workId }) {
|
|
28
|
+
async prepareWorkspace({ workId, repo, issueNumber, }) {
|
|
6
29
|
// Keyed on the work id, symmetrically with the real git-backed manager.
|
|
7
30
|
const workspacePath = join(root, workId);
|
|
8
31
|
await mkdir(workspacePath, { recursive: true });
|
|
9
|
-
return {
|
|
32
|
+
return {
|
|
33
|
+
workspacePath,
|
|
34
|
+
mergeConflictDetected: false,
|
|
35
|
+
validation: validation({
|
|
36
|
+
repo,
|
|
37
|
+
branch: `wake/issue-${issueNumber}`,
|
|
38
|
+
remoteUrl: `https://github.com/${repo}.git`,
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
10
41
|
},
|
|
11
42
|
async prepareReadOnlyClone({ repo }) {
|
|
12
43
|
const workspacePath = join(root, repo.replace(/[\\/]/g, '__'), 'canonical');
|
|
13
44
|
await mkdir(workspacePath, { recursive: true });
|
|
14
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
workspacePath,
|
|
47
|
+
validation: validation({
|
|
48
|
+
repo,
|
|
49
|
+
branch: 'main',
|
|
50
|
+
remoteUrl: `https://github.com/${repo}.git`,
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
async recordWorkspaceBookkeeping() {
|
|
55
|
+
if (options.failBookkeeping === true) {
|
|
56
|
+
throw new Error('fake workspace bookkeeping failed');
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
branch: 'wake/issue-fake',
|
|
60
|
+
headRevision: 'fake-head-after-run',
|
|
61
|
+
diffSummary: '',
|
|
62
|
+
untrackedFiles: [],
|
|
63
|
+
unpushedCommits: {
|
|
64
|
+
hasUpstream: false,
|
|
65
|
+
count: 0,
|
|
66
|
+
commits: [],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
15
69
|
},
|
|
16
70
|
async cleanupWorkspace({ workspacePath }) {
|
|
17
71
|
// Retry on Windows EBUSY/EPERM (AV/indexer holding a brief handle) to
|
|
@@ -19,6 +19,28 @@ async function git(args, cwd) {
|
|
|
19
19
|
});
|
|
20
20
|
return { stdout: result.stdout.trim(), stderr: result.stderr.trim() };
|
|
21
21
|
}
|
|
22
|
+
async function gitExitCode(args, cwd) {
|
|
23
|
+
try {
|
|
24
|
+
await execFile('git', args, {
|
|
25
|
+
cwd,
|
|
26
|
+
env: process.env,
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
maxBuffer: 1024 * 1024 * 16,
|
|
29
|
+
});
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const maybeExit = error;
|
|
34
|
+
return typeof maybeExit.code === 'number' ? maybeExit.code : 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class WorkspaceValidationError extends Error {
|
|
38
|
+
failureSource = 'wake-workspace-validation';
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'WorkspaceValidationError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
22
44
|
async function detectDefaultBranch(repoPath) {
|
|
23
45
|
await git(['remote', 'set-head', 'origin', '--auto'], repoPath);
|
|
24
46
|
const { stdout } = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repoPath);
|
|
@@ -95,6 +117,76 @@ async function tryUpdateFromDefaultBranch(workspacePath) {
|
|
|
95
117
|
return { mergeConflictDetected: false };
|
|
96
118
|
}
|
|
97
119
|
}
|
|
120
|
+
async function validateWorkspace(input) {
|
|
121
|
+
await git(['fetch', 'origin'], input.workspacePath);
|
|
122
|
+
const { stdout: actualRemoteUrl } = await git(['remote', 'get-url', 'origin'], input.workspacePath);
|
|
123
|
+
const { stdout: actualBranch } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], input.workspacePath);
|
|
124
|
+
const { stdout: baseRevision } = await git(['merge-base', 'HEAD', input.expectedBaseRef], input.workspacePath);
|
|
125
|
+
const { stdout: headRevision } = await git(['rev-parse', 'HEAD'], input.workspacePath);
|
|
126
|
+
const { stdout: status } = await git(['status', '--porcelain'], input.workspacePath);
|
|
127
|
+
const remoteExit = await gitExitCode(['ls-remote', '--exit-code', 'origin', 'HEAD'], input.workspacePath);
|
|
128
|
+
const validation = {
|
|
129
|
+
repo: input.repo,
|
|
130
|
+
expectedBranch: input.expectedBranch,
|
|
131
|
+
actualBranch,
|
|
132
|
+
expectedRemoteUrl: input.expectedRemoteUrl,
|
|
133
|
+
actualRemoteUrl,
|
|
134
|
+
baseRevision,
|
|
135
|
+
headRevision,
|
|
136
|
+
clean: status.length === 0,
|
|
137
|
+
remoteAvailable: remoteExit === 0,
|
|
138
|
+
};
|
|
139
|
+
const failures = [];
|
|
140
|
+
if (actualRemoteUrl !== input.expectedRemoteUrl) {
|
|
141
|
+
failures.push(`expected origin ${input.expectedRemoteUrl}, found ${actualRemoteUrl}`);
|
|
142
|
+
}
|
|
143
|
+
if (actualBranch !== input.expectedBranch) {
|
|
144
|
+
failures.push(`expected branch ${input.expectedBranch}, found ${actualBranch}`);
|
|
145
|
+
}
|
|
146
|
+
if (!validation.clean) {
|
|
147
|
+
failures.push('working tree has uncommitted or untracked changes');
|
|
148
|
+
}
|
|
149
|
+
if (!validation.remoteAvailable) {
|
|
150
|
+
failures.push('origin remote is not available');
|
|
151
|
+
}
|
|
152
|
+
if (failures.length > 0) {
|
|
153
|
+
throw new WorkspaceValidationError(`Workspace validation failed: ${failures.join('; ')}`);
|
|
154
|
+
}
|
|
155
|
+
return validation;
|
|
156
|
+
}
|
|
157
|
+
async function recordWorkspaceBookkeeping(workspacePath) {
|
|
158
|
+
const { stdout: branch } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], workspacePath);
|
|
159
|
+
const { stdout: headRevision } = await git(['rev-parse', 'HEAD'], workspacePath);
|
|
160
|
+
const { stdout: diffSummary } = await git(['diff', '--stat', 'HEAD'], workspacePath);
|
|
161
|
+
const { stdout: untracked } = await git(['ls-files', '--others', '--exclude-standard'], workspacePath);
|
|
162
|
+
let hasUpstream = true;
|
|
163
|
+
let count;
|
|
164
|
+
let commits;
|
|
165
|
+
try {
|
|
166
|
+
const { stdout: upstream } = await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], workspacePath);
|
|
167
|
+
const { stdout: ahead } = await git(['rev-list', '--count', `${upstream}..HEAD`], workspacePath);
|
|
168
|
+
count = parseInt(ahead, 10);
|
|
169
|
+
const { stdout: commitLog } = await git(['log', '--pretty=format:%h %s', `${upstream}..HEAD`], workspacePath);
|
|
170
|
+
commits = commitLog.length === 0 ? [] : commitLog.split('\n');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
hasUpstream = false;
|
|
174
|
+
const { stdout: commitLog } = await git(['log', '--pretty=format:%h %s'], workspacePath);
|
|
175
|
+
commits = commitLog.length === 0 ? [] : commitLog.split('\n');
|
|
176
|
+
count = commits.length;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
branch,
|
|
180
|
+
headRevision,
|
|
181
|
+
diffSummary,
|
|
182
|
+
untrackedFiles: untracked.length === 0 ? [] : untracked.split('\n'),
|
|
183
|
+
unpushedCommits: {
|
|
184
|
+
hasUpstream,
|
|
185
|
+
count,
|
|
186
|
+
commits,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
98
190
|
export function createGitWorkspaceManager(options) {
|
|
99
191
|
const paths = createWakePaths(options.wakeRoot);
|
|
100
192
|
const remoteUrlForRepo = options.remoteUrlForRepo ?? defaultRemoteUrlForRepo;
|
|
@@ -131,7 +223,15 @@ export function createGitWorkspaceManager(options) {
|
|
|
131
223
|
const workspacePath = paths.workspaceDir(workId);
|
|
132
224
|
if (await pathExists(workspacePath)) {
|
|
133
225
|
const updateResult = await tryUpdateFromDefaultBranch(workspacePath);
|
|
134
|
-
|
|
226
|
+
const defaultBranch = await detectDefaultBranch(workspacePath);
|
|
227
|
+
const validation = await validateWorkspace({
|
|
228
|
+
workspacePath,
|
|
229
|
+
repo,
|
|
230
|
+
expectedBranch: branchNameForIssue(issueNumber),
|
|
231
|
+
expectedRemoteUrl: remoteUrlForRepo(repo),
|
|
232
|
+
expectedBaseRef: `origin/${defaultBranch}`,
|
|
233
|
+
});
|
|
234
|
+
return { workspacePath, ...updateResult, validation };
|
|
135
235
|
}
|
|
136
236
|
const { repoPath, defaultBranch } = await ensureCanonicalClone(repo);
|
|
137
237
|
const remoteUrl = remoteUrlForRepo(repo);
|
|
@@ -144,14 +244,32 @@ export function createGitWorkspaceManager(options) {
|
|
|
144
244
|
const branch = branchNameForIssue(issueNumber);
|
|
145
245
|
await git(['remote', 'set-url', 'origin', remoteUrl], workspacePath);
|
|
146
246
|
await git(['checkout', '-B', branch], workspacePath);
|
|
147
|
-
|
|
247
|
+
const validation = await validateWorkspace({
|
|
248
|
+
workspacePath,
|
|
249
|
+
repo,
|
|
250
|
+
expectedBranch: branch,
|
|
251
|
+
expectedRemoteUrl: remoteUrl,
|
|
252
|
+
expectedBaseRef: `origin/${defaultBranch}`,
|
|
253
|
+
});
|
|
254
|
+
return { workspacePath, mergeConflictDetected: false, validation };
|
|
148
255
|
},
|
|
149
|
-
async prepareReadOnlyClone({ repo }) {
|
|
256
|
+
async prepareReadOnlyClone({ repo, }) {
|
|
150
257
|
// Refine only reads the issue and, at most, the canonical clone -
|
|
151
258
|
// it never gets a per-issue branch/workspace of its own (only
|
|
152
259
|
// 'implement' pays that cost).
|
|
153
260
|
const { repoPath } = await ensureCanonicalClone(repo);
|
|
154
|
-
|
|
261
|
+
const defaultBranch = await detectDefaultBranch(repoPath);
|
|
262
|
+
const validation = await validateWorkspace({
|
|
263
|
+
workspacePath: repoPath,
|
|
264
|
+
repo,
|
|
265
|
+
expectedBranch: defaultBranch,
|
|
266
|
+
expectedRemoteUrl: remoteUrlForRepo(repo),
|
|
267
|
+
expectedBaseRef: `origin/${defaultBranch}`,
|
|
268
|
+
});
|
|
269
|
+
return { workspacePath: repoPath, validation };
|
|
270
|
+
},
|
|
271
|
+
async recordWorkspaceBookkeeping({ workspacePath, }) {
|
|
272
|
+
return recordWorkspaceBookkeeping(workspacePath);
|
|
155
273
|
},
|
|
156
274
|
async cleanupWorkspace({ workspacePath }) {
|
|
157
275
|
// On Windows, a just-exited git subprocess (or AV/indexer) can hold a brief
|
|
@@ -56,6 +56,9 @@ function hasConfirmedExternalSideEffect(projection) {
|
|
|
56
56
|
return projection.correlatedResources.some((resource) => /^[a-z0-9-]+:pr:/.test(resource.resourceUri));
|
|
57
57
|
}
|
|
58
58
|
function failurePhaseForRecord(record) {
|
|
59
|
+
if (record.metadata?.failureSource === 'wake-workspace-validation') {
|
|
60
|
+
return 'workspace-validation';
|
|
61
|
+
}
|
|
59
62
|
if (record.agentPid !== undefined || record.agentProcessStartedAt !== undefined) {
|
|
60
63
|
return 'running';
|
|
61
64
|
}
|
|
@@ -67,6 +70,12 @@ function failurePhaseForRecord(record) {
|
|
|
67
70
|
}
|
|
68
71
|
return 'unknown';
|
|
69
72
|
}
|
|
73
|
+
function isWorkspaceValidationFailure(error) {
|
|
74
|
+
return (error !== null &&
|
|
75
|
+
typeof error === 'object' &&
|
|
76
|
+
'failureSource' in error &&
|
|
77
|
+
error.failureSource === 'wake-workspace-validation');
|
|
78
|
+
}
|
|
70
79
|
function classifyFailedRun(input) {
|
|
71
80
|
const processStarted = input.record.agentPid !== undefined || input.record.agentProcessStartedAt !== undefined;
|
|
72
81
|
const workspaceChanged = input.workspacePath !== undefined;
|
|
@@ -1269,6 +1278,9 @@ export function createTickRunner(deps) {
|
|
|
1269
1278
|
...preparedRecord.metadata,
|
|
1270
1279
|
...(workspacePath === undefined ? {} : { workspacePath }),
|
|
1271
1280
|
workspaceMode,
|
|
1281
|
+
...(prepareResult.validation === undefined
|
|
1282
|
+
? {}
|
|
1283
|
+
: { workspaceValidation: prepareResult.validation }),
|
|
1272
1284
|
},
|
|
1273
1285
|
});
|
|
1274
1286
|
const recentEvents = await deps.stateStore.listEventEnvelopesForWorkItem(candidate.workItemKey, 6);
|
|
@@ -1398,6 +1410,22 @@ export function createTickRunner(deps) {
|
|
|
1398
1410
|
? null
|
|
1399
1411
|
: lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
|
|
1400
1412
|
const finishedAt = deps.clock.now().toISOString();
|
|
1413
|
+
let workspaceBookkeeping;
|
|
1414
|
+
if (workspacePath !== undefined) {
|
|
1415
|
+
try {
|
|
1416
|
+
workspaceBookkeeping = {
|
|
1417
|
+
status: 'recorded',
|
|
1418
|
+
result: await deps.workspaceManager.recordWorkspaceBookkeeping({ workspacePath }),
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
workspaceBookkeeping = {
|
|
1423
|
+
status: 'failed',
|
|
1424
|
+
failureSource: 'wake-workspace-bookkeeping',
|
|
1425
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1401
1429
|
let prReviewTargetResourceUri = null;
|
|
1402
1430
|
if (watcherRun) {
|
|
1403
1431
|
// Artifact correlation is intentionally scoped to any watcher-dispatched
|
|
@@ -1543,6 +1571,7 @@ export function createTickRunner(deps) {
|
|
|
1543
1571
|
...(runnerResult.tokenUsage === undefined ? {} : { tokenUsage: runnerResult.tokenUsage }),
|
|
1544
1572
|
metadata: {
|
|
1545
1573
|
...finalisingRecord.metadata,
|
|
1574
|
+
...(workspaceBookkeeping === undefined ? {} : { workspaceBookkeeping }),
|
|
1546
1575
|
...resultMetadata,
|
|
1547
1576
|
},
|
|
1548
1577
|
});
|
|
@@ -1680,6 +1709,9 @@ export function createTickRunner(deps) {
|
|
|
1680
1709
|
projection: candidate,
|
|
1681
1710
|
record: failedRecord,
|
|
1682
1711
|
failureClass: 'infra',
|
|
1712
|
+
...(isWorkspaceValidationFailure(err)
|
|
1713
|
+
? { failurePhase: 'workspace-validation' }
|
|
1714
|
+
: {}),
|
|
1683
1715
|
...(failedRecordWorkspacePath === undefined
|
|
1684
1716
|
? {}
|
|
1685
1717
|
: { workspacePath: failedRecordWorkspacePath }),
|
|
@@ -1696,6 +1728,9 @@ export function createTickRunner(deps) {
|
|
|
1696
1728
|
metadata: {
|
|
1697
1729
|
...failedRecord.metadata,
|
|
1698
1730
|
failureClass: 'infra',
|
|
1731
|
+
...(isWorkspaceValidationFailure(err)
|
|
1732
|
+
? { failureSource: 'wake-workspace-validation' }
|
|
1733
|
+
: {}),
|
|
1699
1734
|
...failureContext,
|
|
1700
1735
|
},
|
|
1701
1736
|
});
|
package/dist/src/version.js
CHANGED