@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.
- package/dist/src/adapters/http/ui-assets.js +105 -23
- package/dist/src/adapters/http/ui-data.js +37 -2
- package/dist/src/adapters/http/ui-server.js +140 -1
- package/dist/src/core/projection-updater.js +56 -1
- package/dist/src/core/tick-runner.js +6 -4
- package/dist/src/domain/event-types.js +21 -0
- package/dist/src/domain/work-item-lifecycle.js +10 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
.
|
|
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) =>
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
: [
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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) =>
|
|
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
|
+
}
|
package/dist/src/version.js
CHANGED