@atolis-hq/wake 0.2.15 → 0.2.17

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.
@@ -77,6 +77,9 @@ export const indexHtml = `<!DOCTYPE html>
77
77
  a:hover { text-decoration: underline; }
78
78
  .resource-list { list-style: none; padding: 0; margin: 0 0 1rem; }
79
79
  .resource-list li { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.35rem; font-size: 0.8rem; }
80
+ .btn { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.18); color: #fff; border-radius: 6px; padding: 0.22rem 0.55rem; cursor: pointer; font-size: 0.78rem; margin-top: 0.4rem; }
81
+ .btn:hover:not(:disabled) { border-color: var(--accent-light); background: rgba(45, 212, 191, 0.16); }
82
+ .btn:disabled { cursor: default; opacity: 0.62; }
80
83
  </style>
81
84
  </head>
82
85
  <body>
@@ -229,6 +232,23 @@ async function openItem(repo, number) {
229
232
  if (detail.item.wake.workspacePath) {
230
233
  body.appendChild(el('p', { class: 'meta', text: 'workspace: ' + detail.item.wake.workspacePath }));
231
234
  }
235
+ const lastRun = detail.runs.at(-1);
236
+ if (lastRun && lastRun.sentinel === 'FAILED') {
237
+ const retryBtn = el('button', { type: 'button', class: 'btn', text: 'Retry' });
238
+ retryBtn.addEventListener('click', async () => {
239
+ retryBtn.disabled = true;
240
+ retryBtn.textContent = 'Queuing retry…';
241
+ try {
242
+ await postJson('/work-items/' + encodeURIComponent(detail.item.workItemKey) + '/retry');
243
+ retryBtn.textContent = 'Retry queued';
244
+ } catch (err) {
245
+ retryBtn.disabled = false;
246
+ retryBtn.textContent = 'Retry';
247
+ document.getElementById('status-summary').textContent = 'retry failed: ' + err.message;
248
+ }
249
+ });
250
+ body.appendChild(retryBtn);
251
+ }
232
252
  const resources = detail.item.correlatedResources || [];
233
253
  if (resources.length > 0) {
234
254
  body.appendChild(el('h3', { text: 'Resources' }));
@@ -1,6 +1,8 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { createProjectionUpdater } from '../../core/projection-updater.js';
3
4
  import { configuredTicketSource } from '../../domain/sources.js';
5
+ import { createEventEnvelope } from '../../lib/event-log.js';
4
6
  import { writeJsonFile } from '../../lib/json-file.js';
5
7
  import { indexHtml } from './ui-assets.js';
6
8
  import { buildBoard, buildConfigView, buildEventsFeed, buildHealth, buildItemDetail, buildRuns, buildStatus, buildWorkspaces, } from './ui-data.js';
@@ -50,13 +52,18 @@ function parseItemPath(segments) {
50
52
  }
51
53
  export function createUiServer(options) {
52
54
  const now = options.now ?? (() => new Date());
55
+ const projectionUpdater = createProjectionUpdater({
56
+ stateStore: options.stateStore,
57
+ resourceIndex: options.resourceIndex,
58
+ config: options.config,
59
+ });
53
60
  return createServer((req, res) => {
54
- void handleRequest(req, res, options, now).catch((error) => {
61
+ void handleRequest(req, res, options, now, projectionUpdater).catch((error) => {
55
62
  sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
56
63
  });
57
64
  });
58
65
  }
59
- async function handleRequest(req, res, options, now) {
66
+ async function handleRequest(req, res, options, now, projectionUpdater) {
60
67
  const url = new URL(req.url ?? '/', 'http://internal');
61
68
  // Whether a token is required is a bind-time decision — the caller (see
62
69
  // ui-command.ts) only ever supplies a token when it configured a
@@ -86,6 +93,47 @@ async function handleRequest(req, res, options, now) {
86
93
  .filter((part) => part.length > 0)
87
94
  .map((s) => decodeURIComponent(s));
88
95
  const resource = segments[0];
96
+ if (req.method === 'POST' &&
97
+ resource === 'work-items' &&
98
+ segments.length === 3 &&
99
+ segments[2] === 'retry') {
100
+ const workItemKey = segments[1] ?? '';
101
+ const item = await stateStore.readIssueState(workItemKey);
102
+ if (item === null) {
103
+ sendJson(res, 404, { error: 'work item not found' });
104
+ return;
105
+ }
106
+ const context = item.context;
107
+ if (context.lastRunSentinel !== 'FAILED') {
108
+ sendJson(res, 409, { error: 'work item last run is not failed' });
109
+ return;
110
+ }
111
+ const occurredAt = now().toISOString();
112
+ const retryId = `retry-${workItemKey}-${now().getTime()}`;
113
+ const retryEvent = createEventEnvelope({
114
+ eventId: retryId,
115
+ workItemKey,
116
+ streamScope: 'work-item',
117
+ direction: 'internal',
118
+ sourceSystem: 'wake',
119
+ sourceEventType: 'wake.retry.requested',
120
+ sourceRefs: { repo: item.issue.repo, issueNumber: item.issue.number },
121
+ occurredAt,
122
+ ingestedAt: occurredAt,
123
+ trigger: 'immediate',
124
+ payload: { requestedBy: 'ui' },
125
+ });
126
+ const appended = await stateStore.appendEventEnvelope(retryEvent);
127
+ await projectionUpdater.rebuildFromEvents([appended]);
128
+ const tickRequest = {
129
+ requestId: randomUUID(),
130
+ requestedAt: now().toISOString(),
131
+ requestedBy: 'ui:retry',
132
+ };
133
+ await writeJsonFile(stateStore.paths.tickRequestFile, tickRequest);
134
+ sendJson(res, 202, { workItemKey, retryEventId: retryId });
135
+ return;
136
+ }
89
137
  if (req.method === 'POST' && resource === 'tick' && segments.length === 1) {
90
138
  const request = {
91
139
  requestId: randomUUID(),
@@ -4,7 +4,7 @@ import { createRunnerCliAdapter } from '../adapters/runner/runner-cli-adapter.js
4
4
  import { runSandboxResumeCommand } from './sandbox-resume.js';
5
5
  import { runSelfUpdateCommand, runSelfUpdateLoop } from './self-update-command.js';
6
6
  import { runStopCommand } from './stop-command.js';
7
- import { wakeVersion } from '../version.js';
7
+ import { resolveWakeVersion, wakeVersion } from '../version.js';
8
8
  async function ensureDockerfile(input) {
9
9
  const targetPath = resolve(input.wakeRoot, 'docker', 'Dockerfile');
10
10
  try {
@@ -89,6 +89,7 @@ export async function runSandboxCommand(input) {
89
89
  throw new Error('Sandbox build requires config.dev.repoRoot');
90
90
  }
91
91
  const effectiveDevMode = input.config.dev?.mode ?? 'packaged';
92
+ const buildVersion = effectiveDevMode === 'source' ? resolveWakeVersion({ repoRoot }) : wakeVersion;
92
93
  await ensureDockerfile({
93
94
  wakeRoot: input.wakeRoot,
94
95
  devMode: effectiveDevMode,
@@ -98,7 +99,9 @@ export async function runSandboxCommand(input) {
98
99
  image: input.config.sandbox.image,
99
100
  dockerfile: resolve(input.wakeRoot, 'docker', 'Dockerfile'),
100
101
  contextDir: repoRoot,
101
- ...(effectiveDevMode === 'packaged' ? { buildArgs: { WAKE_VERSION: wakeVersion } } : {}),
102
+ buildArgs: effectiveDevMode === 'packaged'
103
+ ? { WAKE_VERSION: buildVersion }
104
+ : { WAKE_BUILD_TAG: buildVersion },
102
105
  });
103
106
  return;
104
107
  }
@@ -285,6 +285,21 @@ async function applyEvent(current, event, ctx, config) {
285
285
  },
286
286
  });
287
287
  }
288
+ if (event.sourceEventType === 'wake.retry.requested') {
289
+ const nextContext = { ...current.context };
290
+ delete nextContext.lastRunSentinel;
291
+ delete nextContext.lastFailureClass;
292
+ delete nextContext.blockedFromStage;
293
+ return parseIssueStateRecord({
294
+ ...current,
295
+ context: nextContext,
296
+ wake: {
297
+ ...current.wake,
298
+ syncedAt: event.ingestedAt,
299
+ recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
300
+ },
301
+ });
302
+ }
288
303
  if (event.sourceEventType === 'wake.workspace.cleaned') {
289
304
  return parseIssueStateRecord({
290
305
  ...current,
@@ -1 +1,127 @@
1
- export const wakeVersion = "0.2.15+g36c8953";
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const defaultRepoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
6
+ function gitOutputFromRoot(repoRoot, args) {
7
+ try {
8
+ return execFileSync('git', args, {
9
+ cwd: repoRoot,
10
+ encoding: 'utf8',
11
+ stdio: ['ignore', 'pipe', 'ignore'],
12
+ }).trim();
13
+ }
14
+ catch {
15
+ return '';
16
+ }
17
+ }
18
+ function defaultReadTextFile(path) {
19
+ try {
20
+ return readFileSync(path, 'utf8');
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ function defaultListTextFiles(path) {
27
+ if (!existsSync(path)) {
28
+ return [];
29
+ }
30
+ const files = [];
31
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
32
+ const entryPath = resolve(path, entry.name);
33
+ if (entry.isDirectory()) {
34
+ files.push(...defaultListTextFiles(entryPath));
35
+ }
36
+ else if (entry.isFile()) {
37
+ const content = defaultReadTextFile(entryPath);
38
+ if (content !== undefined) {
39
+ files.push({ path: entryPath, content });
40
+ }
41
+ }
42
+ }
43
+ return files;
44
+ }
45
+ function currentHeadHash(repoRoot, readTextFile) {
46
+ const head = readTextFile(resolve(repoRoot, '.git', 'HEAD'))?.trim();
47
+ if (head === undefined || head.length === 0) {
48
+ return '';
49
+ }
50
+ if (!head.startsWith('ref: ')) {
51
+ return head;
52
+ }
53
+ const refPath = head.slice('ref: '.length).trim();
54
+ return readTextFile(resolve(repoRoot, '.git', ...refPath.split('/')))?.trim() ?? '';
55
+ }
56
+ function looseTagRefs(repoRoot, listTextFiles) {
57
+ const tagsRoot = resolve(repoRoot, '.git', 'refs', 'tags');
58
+ return listTextFiles(tagsRoot).map((file) => ({
59
+ name: file.path.replaceAll('\\', '/').slice(tagsRoot.replaceAll('\\', '/').length + 1),
60
+ hash: file.content.trim(),
61
+ }));
62
+ }
63
+ function packedTagRefs(repoRoot, readTextFile) {
64
+ const packedRefs = readTextFile(resolve(repoRoot, '.git', 'packed-refs'));
65
+ if (packedRefs === undefined) {
66
+ return [];
67
+ }
68
+ const refs = [];
69
+ let previousTag;
70
+ for (const line of packedRefs.split(/\r?\n/)) {
71
+ if (line.length === 0 || line.startsWith('#')) {
72
+ continue;
73
+ }
74
+ if (line.startsWith('^')) {
75
+ if (previousTag !== undefined) {
76
+ previousTag.hash = line.slice(1).trim();
77
+ }
78
+ continue;
79
+ }
80
+ previousTag = undefined;
81
+ const [hash, ref] = line.split(' ');
82
+ if (hash !== undefined && ref?.startsWith('refs/tags/') === true) {
83
+ previousTag = { name: ref.slice('refs/tags/'.length), hash };
84
+ refs.push(previousTag);
85
+ }
86
+ }
87
+ return refs;
88
+ }
89
+ function exactTagFromGitFiles(repoRoot, readTextFile, listTextFiles) {
90
+ const headHash = currentHeadHash(repoRoot, readTextFile);
91
+ if (headHash.length === 0) {
92
+ return '';
93
+ }
94
+ const matchingTag = [
95
+ ...looseTagRefs(repoRoot, listTextFiles),
96
+ ...packedTagRefs(repoRoot, readTextFile),
97
+ ].find((tag) => tag.hash === headHash);
98
+ return matchingTag?.name ?? '';
99
+ }
100
+ export function resolveWakeVersion(options = {}) {
101
+ const repoRoot = options.repoRoot ?? defaultRepoRoot;
102
+ const gitOutput = options.gitOutput ?? ((args) => gitOutputFromRoot(repoRoot, args));
103
+ const readTextFile = options.readTextFile ?? defaultReadTextFile;
104
+ const listTextFiles = options.listTextFiles ?? defaultListTextFiles;
105
+ const exactTag = gitOutput(['describe', '--tags', '--exact-match', 'HEAD']);
106
+ if (exactTag.length > 0) {
107
+ return exactTag;
108
+ }
109
+ const exactFileTag = exactTagFromGitFiles(repoRoot, readTextFile, listTextFiles);
110
+ if (exactFileTag.length > 0) {
111
+ return exactFileTag;
112
+ }
113
+ const latestTag = gitOutput(['describe', '--tags', '--abbrev=0']);
114
+ const shortHash = gitOutput(['rev-parse', '--short', 'HEAD']);
115
+ if (latestTag.length > 0 && shortHash.length > 0) {
116
+ return `${latestTag}+g${shortHash}`;
117
+ }
118
+ if (shortHash.length > 0) {
119
+ return `g${shortHash}`;
120
+ }
121
+ const headHash = currentHeadHash(repoRoot, readTextFile);
122
+ if (headHash.length > 0) {
123
+ return `g${headHash.slice(0, 7)}`;
124
+ }
125
+ return '0.1.0-dev';
126
+ }
127
+ export const wakeVersion = "g743a5ea";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {