@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 function createFakeWorkspaceManager(root) {
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 { workspacePath, mergeConflictDetected: false };
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 { workspacePath };
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
- return { workspacePath, ...updateResult };
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
- return { workspacePath, mergeConflictDetected: false };
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
- return { workspacePath: repoPath };
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
  });
@@ -327,6 +327,7 @@ const runLeaseSchema = z.object({
327
327
  expiresAt: isoTimestampSchema,
328
328
  });
329
329
  export const failurePhaseSchema = z.enum([
330
+ 'workspace-validation',
330
331
  'workspace-prep',
331
332
  'process-starting',
332
333
  'running',
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g572b993";
127
+ export const wakeVersion = "g6c20d18";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.52",
3
+ "version": "0.2.53",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {