@atolis-hq/wake 0.2.66 → 0.2.68

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.
@@ -47,6 +47,12 @@ export const indexHtml = `<!DOCTYPE html>
47
47
  .pill-polling { background: #1f3350; color: #7fb3ff; }
48
48
  .pill-working { background: #2d1f50; color: #c79bff; }
49
49
  .pill-paused { background: #4a3510; color: #ffcf7f; }
50
+ .pill-ready { background: #1f3d2c; color: #7fe3a3; }
51
+ .pill-active { background: #2d1f50; color: #c79bff; }
52
+ .pill-scheduled { background: #1f3350; color: #7fb3ff; }
53
+ .pill-needs-human { background: #4a3510; color: #ffcf7f; }
54
+ .pill-error { background: #3d1f1f; color: #ff8f7f; }
55
+ .pill-finished { background: #252830; color: #9aa2ad; }
50
56
  nav { display: flex; gap: 0.25rem; padding: 0.4rem 1rem 0 0.3rem; background: var(--brand-darker); border-bottom: 1px solid #2c313a; }
51
57
  nav button { background: none; border: none; border-bottom: 2px solid transparent; color: rgba(255, 255, 255, 0.65); padding: 0.4rem 0.7rem 0.45rem; margin-bottom: -1px; cursor: pointer; font-size: 0.85rem; transition: color 0.12s ease; }
52
58
  nav button:hover { color: #fff; }
@@ -61,8 +67,15 @@ export const indexHtml = `<!DOCTYPE html>
61
67
  .card .meta { color: #9aa2ad; font-size: 0.72rem; }
62
68
  .child-run { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 0.45rem; align-items: center; margin-top: 0.45rem; padding: 0.4rem; border-radius: 6px; background: #181c22; border: 1px solid #334155; color: #cbd5e1; font-size: 0.72rem; }
63
69
  .child-run .dot { width: 0.48rem; height: 0.48rem; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.14); }
70
+ .child-run .dot-watch { background: #ffcf7f; box-shadow: 0 0 0 3px rgba(255, 207, 127, 0.14); }
64
71
  .child-run .run-title { font-weight: 650; color: #e5e7eb; }
65
72
  .child-run .run-meta { grid-column: 2; color: #94a3b8; }
73
+ .card-stats { color: #8892a0; font-size: 0.7rem; margin-top: 0.3rem; }
74
+ .card-ready { border-left: 3px solid #7fe3a3; }
75
+ .card-active { border-left: 3px solid #c79bff; }
76
+ .card-scheduled { border-left: 3px solid #7fb3ff; }
77
+ .card-needs-human { border-left: 3px solid #ffcf7f; }
78
+ .card-error { border-left: 3px solid #ff8f7f; }
66
79
  .chip { display: inline-block; background: #2c313a; border-radius: 4px; padding: 0.05rem 0.35rem; font-size: 0.68rem; margin-right: 0.2rem; }
67
80
  .chip-label { background: transparent; border: 1px solid #3a4150; color: #9aa2ad; margin-bottom: 0.2rem; }
68
81
  table { border-collapse: collapse; width: 100%; font-size: 0.8rem; }
@@ -116,8 +129,11 @@ export const indexHtml = `<!DOCTYPE html>
116
129
  a:hover { text-decoration: underline; }
117
130
  .resource-list { list-style: none; padding: 0; margin: 0 0 1rem; }
118
131
  .resource-list li { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.35rem; font-size: 0.8rem; }
119
- .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; }
132
+ .action-bar { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; margin: 0.6rem 0 0.2rem; }
133
+ .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; }
120
134
  .btn:hover:not(:disabled) { border-color: var(--accent-light); background: rgba(45, 212, 191, 0.16); }
135
+ .btn.danger { background: #5c1f1a; border-color: #ff8f7f; }
136
+ .btn.danger:hover:not(:disabled) { background: #733027; border-color: #ffd0c8; }
121
137
  .btn:disabled { cursor: default; opacity: 0.62; }
122
138
  </style>
123
139
  </head>
@@ -290,9 +306,24 @@ async function renderBoard(context) {
290
306
  const board = await getJson('/board', context.signal);
291
307
  if (!isActiveRequest(context.requestId)) return;
292
308
  const main = document.getElementById('main');
309
+ const COND_PILL = {
310
+ ready: 'pill-ready',
311
+ active: 'pill-active',
312
+ scheduled: 'pill-scheduled',
313
+ 'needs-human': 'pill-needs-human',
314
+ error: 'pill-error',
315
+ finished: 'pill-finished',
316
+ };
317
+ const COND_CARD = {
318
+ ready: 'card-ready',
319
+ active: 'card-active',
320
+ scheduled: 'card-scheduled',
321
+ 'needs-human': 'card-needs-human',
322
+ error: 'card-error',
323
+ };
293
324
  const renderChildRun = (run) => el('div', { class: 'child-run' }, [
294
- el('span', { class: 'dot' }),
295
- el('div', { class: 'run-title', text: run.action + ' running' }),
325
+ el('span', { class: 'dot' + (run.isWatcher ? ' dot-watch' : '') }),
326
+ el('div', { class: 'run-title', text: (run.isWatcher ? '⟳ ' : '') + run.action + ' running' }),
296
327
  el('div', {
297
328
  class: 'run-meta',
298
329
  text: [run.runnerName, run.tier, fmtMs(run.ageMs)].filter(Boolean).join(' · '),
@@ -301,24 +332,29 @@ async function renderBoard(context) {
301
332
  const columns = el('div', { class: 'columns' }, CONDITIONS.map((cond) => {
302
333
  const items = board.filter((c) => c.condition === cond);
303
334
  if (cond === 'finished') items.sort((a, b) => a.timeInStageMs - b.timeInStageMs);
304
- const cards = items.map((item) => el('div', {
305
- class: 'card',
306
- onclick: () => openItemModal(item.repo, item.number),
307
- }, [
308
- el('div', { class: 'title', text: item.repo + '#' + item.number + ' ' + item.title }),
309
- el('div', { class: 'meta' }, [
310
- el('span', { class: 'chip', text: item.stage }),
311
- ...(cond !== 'finished' ? [document.createTextNode(fmtMs(item.timeInStageMs) + ' in stage')] : []),
312
- ]),
313
- ...(item.labels && item.labels.length > 0
314
- ? [el('div', { class: 'meta' }, item.labels.map((label) => el('span', { class: 'chip chip-label', text: label })))]
315
- : []),
316
- ...(item.activeMainRun
317
- ? []
318
- : [el('div', { class: 'meta', text: item.lastRunSentinel ? 'last: ' + item.lastRunAction + ' → ' + item.lastRunSentinel : item.conditionReason })]),
319
- ...(item.activeMainRun ? [renderChildRun(item.activeMainRun)] : []),
320
- ...((item.activeChildRuns || []).map(renderChildRun)),
321
- ]));
335
+ const cards = items.map((item) => {
336
+ const nonWakeLabels = (item.labels || []).filter((l) => !l.startsWith('wake:'));
337
+ const pillClass = 'pill ' + (COND_PILL[item.condition] || 'pill-finished');
338
+ const cardCondClass = COND_CARD[item.condition] ?? '';
339
+ const statsText = item.totalRuns + ' runs | ' + fmtCost(item.totalCostUsd) + ' | ' + fmtCompact(item.totalTokens) + ' tokens';
340
+ const isFinished = item.condition === 'finished';
341
+ return el('div', {
342
+ class: 'card' + (cardCondClass ? ' ' + cardCondClass : ''),
343
+ onclick: () => openItemModal(item.repo, item.number),
344
+ }, [
345
+ el('div', { class: 'title', text: item.repo + '#' + item.number + ' ' + item.title }),
346
+ el('div', { class: 'meta', style: 'display:flex;align-items:center;gap:0.3rem;flex-wrap:wrap;margin-top:0.2rem;' }, [
347
+ el('span', { class: pillClass, text: item.condition }),
348
+ document.createTextNode('| ' + item.workflow + ' | ' + item.stage + (isFinished ? '' : ' | ' + fmtMs(item.timeInStageMs) + ' in stage')),
349
+ ]),
350
+ el('div', { class: 'card-stats', text: statsText }),
351
+ ...(nonWakeLabels.length > 0
352
+ ? [el('div', { class: 'meta', style: 'margin-top:0.2rem;' }, nonWakeLabels.map((label) => el('span', { class: 'chip chip-label', text: label })))]
353
+ : []),
354
+ ...(item.activeMainRun ? [renderChildRun(item.activeMainRun)] : []),
355
+ ...((item.activeChildRuns || []).map(renderChildRun)),
356
+ ]);
357
+ });
322
358
  return el('div', { class: 'col' + (items.length === 0 ? ' col-empty' : '') }, [
323
359
  el('h2', { text: cond + ' (' + items.length + ')' }),
324
360
  ...cards,
@@ -411,6 +447,8 @@ function renderItemDetails(detail) {
411
447
  if (detail.item.wake.workspacePath) {
412
448
  body.appendChild(el('p', { class: 'meta', text: 'workspace: ' + detail.item.wake.workspacePath }));
413
449
  }
450
+ const isFrozen = typeof detail.item.context.frozenAt === 'string';
451
+ const actionBar = el('div', { class: 'action-bar' });
414
452
  const lastRun = detail.runs.at(-1);
415
453
  if (lastRun && lastRun.sentinel === 'FAILED') {
416
454
  const retryBtn = el('button', { type: 'button', class: 'btn', text: 'Retry' });
@@ -426,7 +464,7 @@ function renderItemDetails(detail) {
426
464
  document.getElementById('status-summary').textContent = 'retry failed: ' + err.message;
427
465
  }
428
466
  });
429
- body.appendChild(retryBtn);
467
+ actionBar.appendChild(retryBtn);
430
468
  }
431
469
  if (detail.item.issue.labels.includes('wake:scheduled-workflow')) {
432
470
  const runNowBtn = el('button', { type: 'button', class: 'btn', text: 'Run now' });
@@ -442,7 +480,44 @@ function renderItemDetails(detail) {
442
480
  document.getElementById('status-summary').textContent = 'run request failed: ' + err.message;
443
481
  }
444
482
  });
445
- body.appendChild(runNowBtn);
483
+ actionBar.appendChild(runNowBtn);
484
+ }
485
+ const freezeBtn = el('button', { type: 'button', class: 'btn', text: isFrozen ? 'Unfreeze' : 'Freeze' });
486
+ freezeBtn.addEventListener('click', async () => {
487
+ const action = isFrozen ? 'unfreeze' : 'freeze';
488
+ freezeBtn.disabled = true;
489
+ freezeBtn.textContent = isFrozen ? 'Unfreezing...' : 'Freezing...';
490
+ try {
491
+ await postJson('/work-items/' + encodeURIComponent(detail.item.workItemKey) + '/' + action);
492
+ freezeBtn.textContent = isFrozen ? 'Unfrozen' : 'Frozen';
493
+ document.getElementById('status-summary').textContent = isFrozen ? 'work item unfrozen' : 'work item frozen';
494
+ } catch (err) {
495
+ freezeBtn.disabled = false;
496
+ freezeBtn.textContent = isFrozen ? 'Unfreeze' : 'Freeze';
497
+ document.getElementById('status-summary').textContent = action + ' failed: ' + err.message;
498
+ }
499
+ });
500
+ actionBar.appendChild(freezeBtn);
501
+
502
+ const deleteBtn = el('button', { type: 'button', class: 'btn danger', text: 'Delete' });
503
+ deleteBtn.addEventListener('click', async () => {
504
+ if (!confirm('Delete this work item from the board and remove its resource correlations?')) return;
505
+ deleteBtn.disabled = true;
506
+ deleteBtn.textContent = 'Deleting...';
507
+ try {
508
+ await postJson('/work-items/' + encodeURIComponent(detail.item.workItemKey) + '/delete');
509
+ deleteBtn.textContent = 'Deleted';
510
+ document.getElementById('status-summary').textContent = 'work item deleted';
511
+ switchView(currentView, { showLoading: false });
512
+ } catch (err) {
513
+ deleteBtn.disabled = false;
514
+ deleteBtn.textContent = 'Delete';
515
+ document.getElementById('status-summary').textContent = 'delete failed: ' + err.message;
516
+ }
517
+ });
518
+ actionBar.appendChild(deleteBtn);
519
+ if (actionBar.childNodes.length > 0) {
520
+ body.appendChild(actionBar);
446
521
  }
447
522
  const resources = detail.item.correlatedResources || [];
448
523
  if (resources.length > 0) {
@@ -529,6 +604,13 @@ function fmtTokens(tokenUsage) {
529
604
  return total >= 1000 ? (total / 1000).toFixed(1) + 'k' : String(total);
530
605
  }
531
606
 
607
+ function fmtCompact(value) {
608
+ const n = Number(value || 0);
609
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
610
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
611
+ return String(n);
612
+ }
613
+
532
614
  function fmtNumber(value) {
533
615
  return Number(value || 0).toLocaleString();
534
616
  }
@@ -2,7 +2,8 @@ import { readFile, readdir, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { buildResourceUri } from '../../domain/resource-uri.js';
4
4
  import { isTerminalStage } from '../../domain/stages.js';
5
- import { workflowForProjection } from '../../domain/workflows.js';
5
+ import { isWorkItemDeleted, isWorkItemFrozen } from '../../domain/work-item-lifecycle.js';
6
+ import { workflowForProjection, workflowNameForProjection } from '../../domain/workflows.js';
6
7
  function parseLockMetadata(raw) {
7
8
  try {
8
9
  const parsed = JSON.parse(raw);
@@ -47,6 +48,9 @@ async function readLockInfo(lockFile, now) {
47
48
  * It never decides anything the tick doesn't independently decide — this is a read model.
48
49
  */
49
50
  function deriveCondition(item, lastRun, config) {
51
+ if (isWorkItemFrozen(item)) {
52
+ return { condition: 'needs-human', reason: 'work item frozen' };
53
+ }
50
54
  const stage = item.wake.stage;
51
55
  if (isTerminalStage(stage) || item.issue.state === 'closed') {
52
56
  return { condition: 'finished', reason: 'terminal stage' };
@@ -89,6 +93,7 @@ function activeChildRunsForItem(item, runs, now) {
89
93
  status: run.status,
90
94
  startedAt: run.startedAt,
91
95
  ageMs: now.getTime() - Date.parse(run.startedAt),
96
+ isWatcher: run.metadata?.watcher === true,
92
97
  ...(run.routing?.runnerName === undefined ? {} : { runnerName: run.routing.runnerName }),
93
98
  ...(run.routing?.runnerKind === undefined ? {} : { runnerKind: run.routing.runnerKind }),
94
99
  ...(run.routing?.tier === undefined ? {} : { tier: run.routing.tier }),
@@ -106,7 +111,28 @@ export async function buildBoard(input) {
106
111
  // with items x run-history size instead of just run-history size.
107
112
  const runs = await input.stateStore.listRunRecordSummaries();
108
113
  const runsById = new Map(runs.map((run) => [run.runId, run]));
109
- return items.map((item) => {
114
+ const runTotalsByItem = new Map();
115
+ for (const run of runs) {
116
+ const existing = runTotalsByItem.get(run.workItemKey) ?? {
117
+ totalRuns: 0,
118
+ totalTokens: 0,
119
+ totalCostUsd: 0,
120
+ };
121
+ existing.totalRuns += 1;
122
+ const usage = run.tokenUsage;
123
+ if (usage !== undefined) {
124
+ existing.totalTokens +=
125
+ (usage.inputTokens ?? 0) +
126
+ (usage.outputTokens ?? 0) +
127
+ (usage.cacheCreationInputTokens ?? 0) +
128
+ (usage.cacheReadInputTokens ?? 0);
129
+ existing.totalCostUsd += usage.costUsd ?? 0;
130
+ }
131
+ runTotalsByItem.set(run.workItemKey, existing);
132
+ }
133
+ return items
134
+ .filter((item) => !isWorkItemDeleted(item))
135
+ .map((item) => {
110
136
  const lastRun = item.wake.lastRunId === undefined ? null : (runsById.get(item.wake.lastRunId) ?? null);
111
137
  const { condition, reason } = deriveCondition(item, lastRun, input.config);
112
138
  const activeChildRuns = activeChildRunsForItem(item, runs, input.now);
@@ -122,16 +148,25 @@ export async function buildBoard(input) {
122
148
  ...(lastRun.routing?.tier === undefined ? {} : { tier: lastRun.routing.tier }),
123
149
  }
124
150
  : undefined;
151
+ const runTotals = runTotalsByItem.get(item.workItemKey) ?? {
152
+ totalRuns: 0,
153
+ totalTokens: 0,
154
+ totalCostUsd: 0,
155
+ };
125
156
  return {
126
157
  repo: item.issue.repo,
127
158
  number: item.issue.number,
128
159
  title: item.issue.title,
129
160
  url: item.issue.url,
130
161
  stage: item.wake.stage,
162
+ workflow: workflowNameForProjection(item, input.config),
131
163
  labels: item.issue.labels,
132
164
  condition,
133
165
  conditionReason: reason,
134
166
  timeInStageMs: timeInStageMs(item, input.now),
167
+ totalRuns: runTotals.totalRuns,
168
+ totalTokens: runTotals.totalTokens,
169
+ totalCostUsd: runTotals.totalCostUsd,
135
170
  lastRunAction: lastRun?.action,
136
171
  lastRunSentinel: lastRun?.sentinel,
137
172
  lastRunStatus: lastRun?.status,
@@ -2,9 +2,13 @@ import { createServer } from 'node:http';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, rm, writeFile } from 'node:fs/promises';
4
4
  import { dirname } from 'node:path';
5
+ import { createLabelsEvent } from '../../core/event-builders.js';
5
6
  import { createProjectionUpdater } from '../../core/projection-updater.js';
6
- import { RETRY_REQUESTED_EVENT, RUN_REQUESTED_EVENT } from '../../domain/event-types.js';
7
+ import { CORRELATION_RETRACTED_EVENT, RETRY_REQUESTED_EVENT, RUN_REQUESTED_EVENT, WORK_ITEM_DELETED_EVENT, WORK_ITEM_FROZEN_EVENT, WORK_ITEM_UNFROZEN_EVENT, } from '../../domain/event-types.js';
7
8
  import { configuredTicketSource } from '../../domain/sources.js';
9
+ import { stageLabelForStage } from '../../domain/stages.js';
10
+ import { isWorkItemDeleted, isWorkItemFrozen } from '../../domain/work-item-lifecycle.js';
11
+ import { workflowLabelForWorkflowName, workflowNameForProjection } from '../../domain/workflows.js';
8
12
  import { createEventEnvelope } from '../../lib/event-log.js';
9
13
  import { writeJsonFile } from '../../lib/json-file.js';
10
14
  import { indexHtml } from './ui-assets.js';
@@ -53,6 +57,43 @@ function parseItemPath(segments) {
53
57
  ...(trailingIsEvents ? { suffix: 'events' } : {}),
54
58
  };
55
59
  }
60
+ async function writeTickRequest(stateStore, now, requestedBy) {
61
+ await writeJsonFile(stateStore.paths.tickRequestFile, {
62
+ requestId: randomUUID(),
63
+ requestedAt: now().toISOString(),
64
+ requestedBy,
65
+ });
66
+ }
67
+ function buildUiWorkItemEvent(input) {
68
+ return createEventEnvelope({
69
+ eventId: input.eventId,
70
+ workItemKey: input.item.workItemKey,
71
+ streamScope: 'work-item',
72
+ direction: 'internal',
73
+ sourceSystem: 'wake',
74
+ sourceEventType: input.sourceEventType,
75
+ sourceRefs: { repo: input.item.issue.repo, issueNumber: input.item.issue.number },
76
+ occurredAt: input.occurredAt,
77
+ ingestedAt: input.occurredAt,
78
+ trigger: 'immediate',
79
+ payload: { requestedBy: 'ui' },
80
+ });
81
+ }
82
+ async function appendLabelSyncEvent(input) {
83
+ const occurredAt = input.now().toISOString();
84
+ const workflowName = workflowNameForProjection(input.item, input.config);
85
+ const labelEvent = createLabelsEvent({
86
+ projection: input.item,
87
+ runId: `${input.action}-${input.item.workItemKey}-${input.now().getTime()}`,
88
+ statusLabel: 'wake:status.pending',
89
+ stageLabel: stageLabelForStage(input.item.wake.stage),
90
+ workflowLabel: workflowLabelForWorkflowName(workflowName),
91
+ occurredAt,
92
+ });
93
+ const appended = await input.stateStore.appendEventEnvelope(labelEvent);
94
+ await input.projectionUpdater.rebuildFromEvents([appended]);
95
+ return labelEvent.eventId;
96
+ }
56
97
  export function createUiServer(options) {
57
98
  const now = options.now ?? (() => new Date());
58
99
  const projectionUpdater = createProjectionUpdater({
@@ -96,6 +137,104 @@ async function handleRequest(req, res, options, now, projectionUpdater) {
96
137
  .filter((part) => part.length > 0)
97
138
  .map((s) => decodeURIComponent(s));
98
139
  const resource = segments[0];
140
+ if (req.method === 'POST' &&
141
+ resource === 'work-items' &&
142
+ segments.length === 3 &&
143
+ (segments[2] === 'freeze' || segments[2] === 'unfreeze')) {
144
+ const workItemKey = segments[1] ?? '';
145
+ const item = await stateStore.readIssueState(workItemKey);
146
+ if (item === null) {
147
+ sendJson(res, 404, { error: 'work item not found' });
148
+ return;
149
+ }
150
+ if (isWorkItemDeleted(item)) {
151
+ sendJson(res, 409, { error: 'work item is deleted' });
152
+ return;
153
+ }
154
+ const action = segments[2];
155
+ const alreadyInDesiredState = action === 'freeze' ? isWorkItemFrozen(item) : !isWorkItemFrozen(item);
156
+ if (alreadyInDesiredState) {
157
+ sendJson(res, 202, { workItemKey, changed: false });
158
+ return;
159
+ }
160
+ const occurredAt = now().toISOString();
161
+ const eventId = `${action}-${workItemKey}-${now().getTime()}`;
162
+ const event = buildUiWorkItemEvent({
163
+ item,
164
+ eventId,
165
+ sourceEventType: action === 'freeze' ? WORK_ITEM_FROZEN_EVENT : WORK_ITEM_UNFROZEN_EVENT,
166
+ occurredAt,
167
+ });
168
+ const appended = await stateStore.appendEventEnvelope(event);
169
+ await projectionUpdater.rebuildFromEvents([appended]);
170
+ const updated = (await stateStore.readIssueState(workItemKey)) ?? item;
171
+ const labelEventId = await appendLabelSyncEvent({
172
+ item: updated,
173
+ stateStore,
174
+ projectionUpdater,
175
+ config,
176
+ now,
177
+ action,
178
+ });
179
+ await writeTickRequest(stateStore, now, `ui:${action}`);
180
+ sendJson(res, 202, { workItemKey, eventId, labelEventId, changed: true });
181
+ return;
182
+ }
183
+ if (req.method === 'POST' &&
184
+ resource === 'work-items' &&
185
+ segments.length === 3 &&
186
+ segments[2] === 'delete') {
187
+ const workItemKey = segments[1] ?? '';
188
+ const item = await stateStore.readIssueState(workItemKey);
189
+ if (item === null) {
190
+ sendJson(res, 404, { error: 'work item not found' });
191
+ return;
192
+ }
193
+ if (isWorkItemDeleted(item)) {
194
+ sendJson(res, 202, { workItemKey, changed: false });
195
+ return;
196
+ }
197
+ const occurredAt = now().toISOString();
198
+ const deleteEventId = `delete-${workItemKey}-${now().getTime()}`;
199
+ const events = [
200
+ buildUiWorkItemEvent({
201
+ item,
202
+ eventId: deleteEventId,
203
+ sourceEventType: WORK_ITEM_DELETED_EVENT,
204
+ occurredAt,
205
+ }),
206
+ ...item.correlatedResources.map((resourceRef) => createEventEnvelope({
207
+ eventId: `${deleteEventId}-retract-${resourceRef.resourceUri.replace(/[^a-z0-9]+/gi, '-')}`,
208
+ workItemKey,
209
+ streamScope: 'work-item',
210
+ direction: 'internal',
211
+ sourceSystem: 'wake',
212
+ sourceEventType: CORRELATION_RETRACTED_EVENT,
213
+ sourceRefs: {
214
+ repo: item.issue.repo,
215
+ issueNumber: item.issue.number,
216
+ resourceUri: resourceRef.resourceUri,
217
+ },
218
+ occurredAt,
219
+ ingestedAt: occurredAt,
220
+ trigger: 'context-only',
221
+ payload: { resourceUri: resourceRef.resourceUri, requestedBy: 'ui' },
222
+ })),
223
+ ];
224
+ const appended = [];
225
+ for (const event of events) {
226
+ appended.push(await stateStore.appendEventEnvelope(event));
227
+ }
228
+ await projectionUpdater.rebuildFromEvents(appended);
229
+ await writeTickRequest(stateStore, now, 'ui:delete');
230
+ sendJson(res, 202, {
231
+ workItemKey,
232
+ deleteEventId,
233
+ retractedResources: item.correlatedResources.map((resourceRef) => resourceRef.resourceUri),
234
+ changed: true,
235
+ });
236
+ return;
237
+ }
99
238
  if (req.method === 'POST' &&
100
239
  resource === 'work-items' &&
101
240
  segments.length === 3 &&
@@ -1,6 +1,7 @@
1
- import { CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRELATION_RETRACTED_EVENT, RETRY_REQUESTED_EVENT, RUN_REQUESTED_EVENT, RUN_CLAIMED_EVENT, RUN_COMPLETED_EVENT, WORKFLOW_SELECTED_EVENT, WORKSPACE_CLEANED_EVENT, } from '../domain/event-types.js';
1
+ import { CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRELATION_RETRACTED_EVENT, RETRY_REQUESTED_EVENT, RUN_REQUESTED_EVENT, RUN_CLAIMED_EVENT, RUN_COMPLETED_EVENT, WORKFLOW_SELECTED_EVENT, WORK_ITEM_DELETED_EVENT, WORK_ITEM_FROZEN_EVENT, WORK_ITEM_UNFROZEN_EVENT, WORKSPACE_CLEANED_EVENT, } from '../domain/event-types.js';
2
2
  import { UNRESOLVED_WORK_ITEM_KEY, parseIssueStateRecord } from '../domain/schema.js';
3
3
  import { doneRunnerSentinel, stageFromLabels } from '../domain/stages.js';
4
+ import { FROZEN_WORK_ITEM_LABEL } from '../domain/work-item-lifecycle.js';
4
5
  import { builtInDefaultWorkflowDefinition, defaultWorkflowName, selectWorkflowForEvent, workflowStageVocabulary, } from '../domain/workflows.js';
5
6
  import { isCustomCommandAction } from '../domain/custom-commands.js';
6
7
  import { createEventEnvelope } from '../lib/event-log.js';
@@ -353,6 +354,60 @@ async function applyEvent(current, event, ctx, config) {
353
354
  },
354
355
  });
355
356
  }
357
+ if (event.sourceEventType === WORK_ITEM_DELETED_EVENT) {
358
+ return parseIssueStateRecord({
359
+ ...current,
360
+ context: {
361
+ ...current.context,
362
+ deletedAt: event.occurredAt,
363
+ deletedBy: typeof event.payload.requestedBy === 'string' ? event.payload.requestedBy : 'unknown',
364
+ },
365
+ wake: {
366
+ ...current.wake,
367
+ sessionId: undefined,
368
+ sessionCli: undefined,
369
+ syncedAt: event.ingestedAt,
370
+ recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
371
+ },
372
+ });
373
+ }
374
+ if (event.sourceEventType === WORK_ITEM_FROZEN_EVENT) {
375
+ return parseIssueStateRecord({
376
+ ...current,
377
+ issue: {
378
+ ...current.issue,
379
+ labels: Array.from(new Set([...current.issue.labels, FROZEN_WORK_ITEM_LABEL])),
380
+ },
381
+ context: {
382
+ ...current.context,
383
+ frozenAt: event.occurredAt,
384
+ frozenBy: typeof event.payload.requestedBy === 'string' ? event.payload.requestedBy : 'unknown',
385
+ },
386
+ wake: {
387
+ ...current.wake,
388
+ syncedAt: event.ingestedAt,
389
+ recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
390
+ },
391
+ });
392
+ }
393
+ if (event.sourceEventType === WORK_ITEM_UNFROZEN_EVENT) {
394
+ const nextContext = { ...current.context };
395
+ delete nextContext.frozenAt;
396
+ delete nextContext.frozenBy;
397
+ return parseIssueStateRecord({
398
+ ...current,
399
+ issue: {
400
+ ...current.issue,
401
+ labels: current.issue.labels.filter((label) => label !== FROZEN_WORK_ITEM_LABEL),
402
+ },
403
+ context: nextContext,
404
+ wake: {
405
+ ...current.wake,
406
+ syncedAt: event.ingestedAt,
407
+ recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
408
+ },
409
+ });
410
+ }
356
411
  if (event.sourceEventType === WORKSPACE_CLEANED_EVENT) {
357
412
  return parseIssueStateRecord({
358
413
  ...current,
@@ -9,6 +9,7 @@ import { CORRELATION_REGISTERED_EVENT, CORRELATION_PRIMARY_CONFLICT_EVENT, PR_AU
9
9
  import { parseRunnerArtifacts, parseRunnerResult } from '../domain/schema.js';
10
10
  import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
11
11
  import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/stages.js';
12
+ import { isWorkItemDeleted, isWorkItemRunnable } from '../domain/work-item-lifecycle.js';
12
13
  import { isMeaningfulRuntimeEvent } from '../domain/runtime-events.js';
13
14
  import { chooseAction as chooseWorkflowAction, entryStage as workflowEntryStage, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowLabelForWorkflowName, workflowNameForProjection, } from '../domain/workflows.js';
14
15
  import { createEventEnvelope } from '../lib/event-log.js';
@@ -571,7 +572,7 @@ export function createTickRunner(deps) {
571
572
  const seen = new Set();
572
573
  const watch = [];
573
574
  for (const projection of projections) {
574
- if (projection.issue.state !== 'open') {
575
+ if (projection.issue.state !== 'open' || isWorkItemDeleted(projection)) {
575
576
  continue;
576
577
  }
577
578
  for (const resource of projection.correlatedResources) {
@@ -593,7 +594,7 @@ export function createTickRunner(deps) {
593
594
  }
594
595
  }
595
596
  for (const projection of projections) {
596
- if (activeRunWorkItemKeys.has(projection.workItemKey)) {
597
+ if (!isWorkItemRunnable(projection) || activeRunWorkItemKeys.has(projection.workItemKey)) {
597
598
  continue;
598
599
  }
599
600
  const statusLabel = statusLabelForStage(projection.wake.stage);
@@ -929,7 +930,7 @@ export function createTickRunner(deps) {
929
930
  // other path that excludes closed issues. Without this, a closed issue
930
931
  // whose last-seen local status matches `watcher.while.status` (e.g.
931
932
  // awaiting-approval) re-fires its watcher workflow every tick forever.
932
- if (projection.issue.state !== 'open')
933
+ if (projection.issue.state !== 'open' || !isWorkItemRunnable(projection))
933
934
  continue;
934
935
  const parentWorkflow = workflowForProjection(projection, deps.config);
935
936
  if (parentWorkflow === null)
@@ -1075,7 +1076,8 @@ export function createTickRunner(deps) {
1075
1076
  if (await parkConfigDriftedProjections(projections)) {
1076
1077
  return { status: 'processed' };
1077
1078
  }
1078
- let candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
1079
+ let candidate = projections.find((issue) => isWorkItemRunnable(issue) &&
1080
+ policy.resolveNextEligibleAction(issue, deps.config) !== null);
1079
1081
  const watcherDispatch = candidate === undefined ? await nextWatcherDispatch(projections, tickStartedAt) : null;
1080
1082
  candidate ??= watcherDispatch?.projection;
1081
1083
  let watcherStateKeyForRun;
@@ -15,6 +15,9 @@ export const RUN_CLAIMED_EVENT = 'wake.run.claimed';
15
15
  export const RUN_COMPLETED_EVENT = 'wake.run.completed';
16
16
  export const WORKFLOW_SELECTED_EVENT = 'wake.workflow.selected';
17
17
  export const WORK_ITEM_CREATED_EVENT = 'wake.workitem.created';
18
+ export const WORK_ITEM_DELETED_EVENT = 'wake.workitem.deleted';
19
+ export const WORK_ITEM_FROZEN_EVENT = 'wake.workitem.frozen';
20
+ export const WORK_ITEM_UNFROZEN_EVENT = 'wake.workitem.unfrozen';
18
21
  export const WORKSPACE_CLEANED_EVENT = 'wake.workspace.cleaned';
19
22
  export const WORKSPACE_CLEANUP_FAILED_EVENT = 'wake.workspace.cleanup-failed';
20
23
  export const wakeEventTypeValues = [
@@ -35,6 +38,9 @@ export const wakeEventTypeValues = [
35
38
  RUN_COMPLETED_EVENT,
36
39
  WORKFLOW_SELECTED_EVENT,
37
40
  WORK_ITEM_CREATED_EVENT,
41
+ WORK_ITEM_DELETED_EVENT,
42
+ WORK_ITEM_FROZEN_EVENT,
43
+ WORK_ITEM_UNFROZEN_EVENT,
38
44
  WORKSPACE_CLEANED_EVENT,
39
45
  WORKSPACE_CLEANUP_FAILED_EVENT,
40
46
  ];
@@ -124,6 +130,21 @@ export const wakeEventTypeDefinitions = [
124
130
  description: 'Mints a Wake work item identity before resource correlation is registered.',
125
131
  payloadShape: '{}',
126
132
  },
133
+ {
134
+ type: WORK_ITEM_DELETED_EVENT,
135
+ description: 'Soft-deletes a work item and excludes it from board display and execution.',
136
+ payloadShape: '{ requestedBy }',
137
+ },
138
+ {
139
+ type: WORK_ITEM_FROZEN_EVENT,
140
+ description: 'Marks a work item as frozen so runner ticks will not execute it.',
141
+ payloadShape: '{ requestedBy }',
142
+ },
143
+ {
144
+ type: WORK_ITEM_UNFROZEN_EVENT,
145
+ description: 'Clears a work item freeze so runner ticks may execute it again.',
146
+ payloadShape: '{ requestedBy }',
147
+ },
127
148
  {
128
149
  type: WORKSPACE_CLEANED_EVENT,
129
150
  description: 'Records successful cleanup of a closed issue workspace.',
@@ -0,0 +1,10 @@
1
+ export const FROZEN_WORK_ITEM_LABEL = 'wake:frozen';
2
+ export function isWorkItemDeleted(item) {
3
+ return typeof item.context.deletedAt === 'string';
4
+ }
5
+ export function isWorkItemFrozen(item) {
6
+ return typeof item.context.frozenAt === 'string';
7
+ }
8
+ export function isWorkItemRunnable(item) {
9
+ return !isWorkItemDeleted(item) && !isWorkItemFrozen(item);
10
+ }
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "gda2b78e";
127
+ export const wakeVersion = "gd847502";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.66",
3
+ "version": "0.2.68",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {