@atolis-hq/wake 0.1.0
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/LICENSE +201 -0
- package/README.md +140 -0
- package/dist/src/adapters/claude/claude-runner.js +388 -0
- package/dist/src/adapters/claude/prompt-templates.js +57 -0
- package/dist/src/adapters/codex/codex-runner.js +391 -0
- package/dist/src/adapters/cursor/cursor-runner.js +352 -0
- package/dist/src/adapters/docker/docker-cli.js +97 -0
- package/dist/src/adapters/fake/fake-artifact-verifier.js +14 -0
- package/dist/src/adapters/fake/fake-github-pull-request-activity-source.js +73 -0
- package/dist/src/adapters/fake/fake-resource-index.js +21 -0
- package/dist/src/adapters/fake/fake-runner.js +22 -0
- package/dist/src/adapters/fake/fake-ticketing-system.js +166 -0
- package/dist/src/adapters/fake/fake-workspace-manager.js +27 -0
- package/dist/src/adapters/fs/resource-index.js +84 -0
- package/dist/src/adapters/fs/self-update-ledger.js +17 -0
- package/dist/src/adapters/fs/state-store.js +364 -0
- package/dist/src/adapters/git/git-workspace-manager.js +168 -0
- package/dist/src/adapters/github/github-artifact-verifier.js +38 -0
- package/dist/src/adapters/github/github-auth.js +16 -0
- package/dist/src/adapters/github/github-client.js +100 -0
- package/dist/src/adapters/github/github-issues-work-source.js +410 -0
- package/dist/src/adapters/github/github-pull-request-activity-source.js +263 -0
- package/dist/src/adapters/http/ui-assets.js +302 -0
- package/dist/src/adapters/http/ui-data.js +389 -0
- package/dist/src/adapters/http/ui-server.js +151 -0
- package/dist/src/adapters/runner/cli-command.js +43 -0
- package/dist/src/adapters/runner/prompt-templates.js +76 -0
- package/dist/src/adapters/runner/runner-cli-adapter.js +112 -0
- package/dist/src/adapters/runner/runner-registry.js +141 -0
- package/dist/src/adapters/runner/stage-prompt.js +256 -0
- package/dist/src/adapters/runner/transcripts.js +25 -0
- package/dist/src/cli/correlate-command.js +62 -0
- package/dist/src/cli/init-command.js +11 -0
- package/dist/src/cli/locks-command.js +19 -0
- package/dist/src/cli/sandbox-command.js +191 -0
- package/dist/src/cli/sandbox-logging.js +30 -0
- package/dist/src/cli/sandbox-resume.js +77 -0
- package/dist/src/cli/scaffold-assets.js +173 -0
- package/dist/src/cli/self-update-command.js +158 -0
- package/dist/src/cli/startup-preflight.js +127 -0
- package/dist/src/cli/stop-command.js +42 -0
- package/dist/src/cli/ui-command.js +27 -0
- package/dist/src/config/defaults.js +11 -0
- package/dist/src/config/load-config.js +26 -0
- package/dist/src/core/contracts.js +1 -0
- package/dist/src/core/control-plane.js +127 -0
- package/dist/src/core/lifecycle-service.js +8 -0
- package/dist/src/core/policy-engine.js +200 -0
- package/dist/src/core/projection-updater.js +438 -0
- package/dist/src/core/quota-backoff.js +69 -0
- package/dist/src/core/sink-router.js +85 -0
- package/dist/src/core/tick-runner.js +1347 -0
- package/dist/src/domain/branch-naming.js +14 -0
- package/dist/src/domain/resource-uri.js +38 -0
- package/dist/src/domain/runner-routing.js +108 -0
- package/dist/src/domain/schema.js +685 -0
- package/dist/src/domain/sources.js +16 -0
- package/dist/src/domain/stages.js +39 -0
- package/dist/src/domain/types.js +1 -0
- package/dist/src/domain/workflows.js +83 -0
- package/dist/src/lib/clock.js +5 -0
- package/dist/src/lib/event-log.js +21 -0
- package/dist/src/lib/json-file.js +23 -0
- package/dist/src/lib/lock.js +118 -0
- package/dist/src/lib/paths.js +44 -0
- package/dist/src/lib/work-id.js +19 -0
- package/dist/src/main.js +523 -0
- package/dist/src/version.js +1 -0
- package/docker/Dockerfile +54 -0
- package/docker/entrypoint.sh +75 -0
- package/docker/log-command.sh +107 -0
- package/docker/setup.sh +72 -0
- package/package.json +52 -0
- package/prompts/implement.md +49 -0
- package/prompts/refine.md +44 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { wakeVersion } from '../../version.js';
|
|
2
|
+
export const indexHtml = `<!DOCTYPE html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8" />
|
|
6
|
+
<title>Wake control plane</title>
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<style>
|
|
9
|
+
:root { color-scheme: dark light; }
|
|
10
|
+
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #14161a; color: #e8e8e8; }
|
|
11
|
+
header { display: flex; align-items: center; gap: 1rem; padding: 0.6rem 1rem; background: #1d2026; border-bottom: 1px solid #2c313a; flex-wrap: wrap; }
|
|
12
|
+
header h1 { font-size: 1rem; margin: 0; font-weight: 600; }
|
|
13
|
+
.version { color: #9aa2ad; font-size: 0.78rem; }
|
|
14
|
+
.pill { padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
|
|
15
|
+
.pill-idle { background: #1f3d2c; color: #7fe3a3; }
|
|
16
|
+
.pill-ticking { background: #1f3350; color: #7fb3ff; }
|
|
17
|
+
.pill-paused { background: #4a3510; color: #ffcf7f; }
|
|
18
|
+
nav { display: flex; gap: 0.25rem; padding: 0.5rem 1rem; background: #191c22; border-bottom: 1px solid #2c313a; }
|
|
19
|
+
nav button { background: none; border: none; color: #9aa2ad; padding: 0.4rem 0.8rem; cursor: pointer; border-radius: 6px; font-size: 0.85rem; }
|
|
20
|
+
nav button.active { background: #262b33; color: #fff; }
|
|
21
|
+
main { padding: 1rem; }
|
|
22
|
+
.columns { display: grid; grid-template-columns: repeat(6, minmax(180px, 1fr)); gap: 0.6rem; overflow-x: auto; }
|
|
23
|
+
.col { background: #1a1d23; border-radius: 8px; padding: 0.5rem; min-height: 200px; }
|
|
24
|
+
.col h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #9aa2ad; margin: 0.2rem 0.4rem 0.5rem; }
|
|
25
|
+
.card { background: #22262e; border: 1px solid #2c313a; border-radius: 6px; padding: 0.5rem; margin-bottom: 0.5rem; cursor: pointer; font-size: 0.8rem; }
|
|
26
|
+
.card:hover { border-color: #4d5866; }
|
|
27
|
+
.card .title { font-weight: 600; margin-bottom: 0.25rem; }
|
|
28
|
+
.card .meta { color: #9aa2ad; font-size: 0.72rem; }
|
|
29
|
+
.chip { display: inline-block; background: #2c313a; border-radius: 4px; padding: 0.05rem 0.35rem; font-size: 0.68rem; margin-right: 0.2rem; }
|
|
30
|
+
table { border-collapse: collapse; width: 100%; font-size: 0.8rem; }
|
|
31
|
+
th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #2c313a; }
|
|
32
|
+
th { color: #9aa2ad; font-weight: 600; }
|
|
33
|
+
pre { background: #1a1d23; padding: 0.75rem; border-radius: 6px; overflow: auto; font-size: 0.75rem; }
|
|
34
|
+
.drawer { position: fixed; top: 0; right: 0; width: min(560px, 100%); height: 100%; background: #191c22; border-left: 1px solid #2c313a; overflow-y: auto; padding: 1rem; transform: translateX(100%); transition: transform 0.15s ease; }
|
|
35
|
+
.drawer.open { transform: translateX(0); }
|
|
36
|
+
.drawer .close { float: right; cursor: pointer; color: #9aa2ad; }
|
|
37
|
+
.tiles { display: flex; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
38
|
+
.tile { background: #1a1d23; border-radius: 8px; padding: 0.6rem 0.9rem; min-width: 120px; }
|
|
39
|
+
.tile .n { font-size: 1.3rem; font-weight: 700; }
|
|
40
|
+
.tile .l { color: #9aa2ad; font-size: 0.72rem; text-transform: uppercase; }
|
|
41
|
+
.amber { color: #ffcf7f; }
|
|
42
|
+
.red { color: #ff8f7f; }
|
|
43
|
+
.ok { color: #7fe3a3; }
|
|
44
|
+
input[type=text] { background: #1a1d23; border: 1px solid #2c313a; color: #e8e8e8; padding: 0.3rem 0.5rem; border-radius: 6px; margin-bottom: 0.6rem; width: 260px; }
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<header>
|
|
49
|
+
<h1>Wake control plane</h1>
|
|
50
|
+
<span class="version">${wakeVersion}</span>
|
|
51
|
+
<span id="loop-pill" class="pill">…</span>
|
|
52
|
+
<span id="status-summary" class="meta"></span>
|
|
53
|
+
</header>
|
|
54
|
+
<nav>
|
|
55
|
+
<button data-view="board" class="active">Board</button>
|
|
56
|
+
<button data-view="activity">Activity</button>
|
|
57
|
+
<button data-view="runs">Runs</button>
|
|
58
|
+
<button data-view="config">Config</button>
|
|
59
|
+
<button data-view="health">Health</button>
|
|
60
|
+
</nav>
|
|
61
|
+
<main id="main"></main>
|
|
62
|
+
<div id="drawer" class="drawer"><span class="close" id="drawer-close">close ✕</span><div id="drawer-body"></div></div>
|
|
63
|
+
<script>
|
|
64
|
+
const API = '/api/v1';
|
|
65
|
+
const CONDITIONS = ['needs-human', 'active', 'ready', 'waiting', 'stalled', 'finished'];
|
|
66
|
+
let currentView = 'board';
|
|
67
|
+
|
|
68
|
+
async function getJson(path) {
|
|
69
|
+
const res = await fetch(API + path);
|
|
70
|
+
if (!res.ok) throw new Error(path + ' -> ' + res.status);
|
|
71
|
+
return res.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fmtMs(ms) {
|
|
75
|
+
if (ms === undefined || ms === null) return '—';
|
|
76
|
+
const s = Math.floor(ms / 1000);
|
|
77
|
+
if (s < 60) return s + 's';
|
|
78
|
+
const m = Math.floor(s / 60);
|
|
79
|
+
if (m < 60) return m + 'm';
|
|
80
|
+
const h = Math.floor(m / 60);
|
|
81
|
+
if (h < 24) return h + 'h';
|
|
82
|
+
return Math.floor(h / 24) + 'd';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function el(tag, attrs, children) {
|
|
86
|
+
const node = document.createElement(tag);
|
|
87
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
88
|
+
if (k === 'text') node.textContent = v;
|
|
89
|
+
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
|
|
90
|
+
else node.setAttribute(k, v);
|
|
91
|
+
}
|
|
92
|
+
for (const child of children || []) node.appendChild(child);
|
|
93
|
+
return node;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function renderStatusBar() {
|
|
97
|
+
try {
|
|
98
|
+
const status = await getJson('/status');
|
|
99
|
+
const pill = document.getElementById('loop-pill');
|
|
100
|
+
pill.textContent = status.loopState;
|
|
101
|
+
pill.className = 'pill pill-' + status.loopState;
|
|
102
|
+
const freshness = status.sourceFreshness.level;
|
|
103
|
+
const summary = document.getElementById('status-summary');
|
|
104
|
+
summary.textContent =
|
|
105
|
+
'runs today: ' + status.runsToday + ' · failures today: ' + status.failuresToday +
|
|
106
|
+
' · cost today: $' + Number(status.costUsdToday ?? 0).toFixed(2) +
|
|
107
|
+
' · source freshness: ' + freshness +
|
|
108
|
+
(status.lastRun ? ' · last run: ' + status.lastRun.repo + '#' + status.lastRun.issueNumber + ' ' + status.lastRun.action + ' → ' + (status.lastRun.sentinel ?? status.lastRun.status) : '');
|
|
109
|
+
} catch (err) {
|
|
110
|
+
document.getElementById('status-summary').textContent = 'status unavailable: ' + err.message;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function renderBoard() {
|
|
115
|
+
const board = await getJson('/board');
|
|
116
|
+
const main = document.getElementById('main');
|
|
117
|
+
main.innerHTML = '';
|
|
118
|
+
const columns = el('div', { class: 'columns' }, CONDITIONS.map((cond) => {
|
|
119
|
+
const items = board.filter((c) => c.condition === cond);
|
|
120
|
+
const cards = items.map((item) => el('div', {
|
|
121
|
+
class: 'card',
|
|
122
|
+
onclick: () => openItem(item.repo, item.number),
|
|
123
|
+
}, [
|
|
124
|
+
el('div', { class: 'title', text: item.repo + '#' + item.number + ' ' + item.title }),
|
|
125
|
+
el('div', { class: 'meta' }, [
|
|
126
|
+
el('span', { class: 'chip', text: item.stage }),
|
|
127
|
+
document.createTextNode(fmtMs(item.timeInStageMs) + ' in stage'),
|
|
128
|
+
]),
|
|
129
|
+
el('div', { class: 'meta', text: item.lastRunSentinel ? 'last: ' + item.lastRunAction + ' → ' + item.lastRunSentinel : item.conditionReason }),
|
|
130
|
+
]));
|
|
131
|
+
return el('div', { class: 'col' }, [
|
|
132
|
+
el('h2', { text: cond + ' (' + items.length + ')' }),
|
|
133
|
+
...cards,
|
|
134
|
+
]);
|
|
135
|
+
}));
|
|
136
|
+
main.appendChild(columns);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function openItem(repo, number) {
|
|
140
|
+
const drawer = document.getElementById('drawer');
|
|
141
|
+
const body = document.getElementById('drawer-body');
|
|
142
|
+
body.innerHTML = 'Loading…';
|
|
143
|
+
drawer.classList.add('open');
|
|
144
|
+
const detail = await getJson('/items/' + encodeURIComponent(repo) + '/' + number);
|
|
145
|
+
if (!detail) { body.textContent = 'Not found'; return; }
|
|
146
|
+
body.innerHTML = '';
|
|
147
|
+
body.appendChild(el('h2', { text: repo + '#' + number }));
|
|
148
|
+
body.appendChild(el('p', { text: detail.item.issue.title }));
|
|
149
|
+
body.appendChild(el('p', { class: 'meta', text: 'stage: ' + detail.item.wake.stage + (detail.item.wake.sessionId ? ' · session: ' + detail.item.wake.sessionId : '') }));
|
|
150
|
+
if (detail.item.wake.workspacePath) {
|
|
151
|
+
body.appendChild(el('p', { class: 'meta', text: 'workspace: ' + detail.item.wake.workspacePath }));
|
|
152
|
+
}
|
|
153
|
+
body.appendChild(el('h3', { text: 'Runs' }));
|
|
154
|
+
const runsTable = el('table', {}, [
|
|
155
|
+
el('tr', {}, ['action', 'status', 'sentinel', 'started', 'runId'].map((h) => el('th', { text: h }))),
|
|
156
|
+
...detail.runs.map((r) => el('tr', {}, [
|
|
157
|
+
el('td', { text: r.action }), el('td', { text: r.status }), el('td', { text: r.sentinel || '' }),
|
|
158
|
+
el('td', { text: r.startedAt }), el('td', { text: r.runId }),
|
|
159
|
+
])),
|
|
160
|
+
]);
|
|
161
|
+
body.appendChild(runsTable);
|
|
162
|
+
body.appendChild(el('h3', { text: 'Context' }));
|
|
163
|
+
body.appendChild(el('pre', { text: JSON.stringify(detail.item.context, null, 2) }));
|
|
164
|
+
body.appendChild(el('h3', { text: 'Recent events' }));
|
|
165
|
+
body.appendChild(el('pre', { text: JSON.stringify(detail.events, null, 2) }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function renderActivity() {
|
|
169
|
+
const events = await getJson('/events?limit=200');
|
|
170
|
+
const main = document.getElementById('main');
|
|
171
|
+
main.innerHTML = '';
|
|
172
|
+
const table = el('table', {}, [
|
|
173
|
+
el('tr', {}, ['time', 'direction', 'type', 'work item'].map((h) => el('th', { text: h }))),
|
|
174
|
+
...events.map((ev) => el('tr', {}, [
|
|
175
|
+
el('td', { text: ev.ingestedAt }), el('td', { text: ev.direction }),
|
|
176
|
+
el('td', { text: ev.sourceEventType }), el('td', { text: ev.workItemKey }),
|
|
177
|
+
])),
|
|
178
|
+
]);
|
|
179
|
+
main.appendChild(table);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function fmtCost(usd) {
|
|
183
|
+
if (usd === undefined || usd === null) return '';
|
|
184
|
+
return '$' + Number(usd).toFixed(usd < 1 ? 4 : 2);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function fmtTokens(tokenUsage) {
|
|
188
|
+
if (!tokenUsage) return '';
|
|
189
|
+
const total =
|
|
190
|
+
(tokenUsage.inputTokens || 0) + (tokenUsage.outputTokens || 0) +
|
|
191
|
+
(tokenUsage.cacheCreationInputTokens || 0) + (tokenUsage.cacheReadInputTokens || 0);
|
|
192
|
+
return total >= 1000 ? (total / 1000).toFixed(1) + 'k' : String(total);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function renderRuns() {
|
|
196
|
+
const runs = await getJson('/runs');
|
|
197
|
+
const main = document.getElementById('main');
|
|
198
|
+
main.innerHTML = '';
|
|
199
|
+
const table = el('table', {}, [
|
|
200
|
+
el('tr', {}, ['repo#issue', 'action', 'status', 'sentinel', 'runner', 'tokens', 'cost', 'started', 'finished'].map((h) => el('th', { text: h }))),
|
|
201
|
+
...runs.map((r) => el('tr', {}, [
|
|
202
|
+
el('td', { text: r.repo + '#' + r.issueNumber }), el('td', { text: r.action }), el('td', { text: r.status }),
|
|
203
|
+
el('td', { text: r.sentinel || '' }), el('td', { text: r.routing ? r.routing.runnerName : '' }),
|
|
204
|
+
el('td', { text: fmtTokens(r.tokenUsage) }), el('td', { text: fmtCost(r.tokenUsage && r.tokenUsage.costUsd) }),
|
|
205
|
+
el('td', { text: r.startedAt }), el('td', { text: r.finishedAt || '' }),
|
|
206
|
+
])),
|
|
207
|
+
]);
|
|
208
|
+
main.appendChild(table);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function renderConfig() {
|
|
212
|
+
const data = await getJson('/config');
|
|
213
|
+
const main = document.getElementById('main');
|
|
214
|
+
main.innerHTML = '';
|
|
215
|
+
main.appendChild(el('h3', { text: 'Routing table' }));
|
|
216
|
+
main.appendChild(el('table', {}, [
|
|
217
|
+
el('tr', {}, ['stage', 'action', 'tier', 'runner', 'model', 'fallback order'].map((h) => el('th', { text: h }))),
|
|
218
|
+
...data.routingTable.map((r) => el('tr', {}, [
|
|
219
|
+
el('td', { text: r.stage }), el('td', { text: r.action || '' }), el('td', { text: r.tier || '' }),
|
|
220
|
+
el('td', { text: r.runnerName || '' }), el('td', { text: r.model || '' }),
|
|
221
|
+
el('td', {}, (r.candidates || []).map((c) => el('span', {
|
|
222
|
+
class: 'chip' + (c.paused ? ' amber' : ''),
|
|
223
|
+
text: c.runnerName + (c.paused ? ' (paused)' : ''),
|
|
224
|
+
}))),
|
|
225
|
+
])),
|
|
226
|
+
]));
|
|
227
|
+
main.appendChild(el('h3', { text: 'Effective config (redacted)' }));
|
|
228
|
+
main.appendChild(el('pre', { text: JSON.stringify(data.config, null, 2) }));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function renderHealth() {
|
|
232
|
+
const health = await getJson('/health');
|
|
233
|
+
const main = document.getElementById('main');
|
|
234
|
+
main.innerHTML = '';
|
|
235
|
+
const runnerNames = Object.keys(health.pause.runnerHealth || {});
|
|
236
|
+
main.appendChild(el('div', { class: 'tiles' }, [
|
|
237
|
+
tile('Tick lock', health.lock.present ? (health.lock.stale ? 'stale' : 'held') : 'free'),
|
|
238
|
+
tile('Paused', String(health.pause.paused)),
|
|
239
|
+
tile('Runners paused now', String(runnerNames.filter((name) => {
|
|
240
|
+
const until = health.pause.runnerHealth[name].pausedUntil;
|
|
241
|
+
return until && Date.parse(until) > Date.now();
|
|
242
|
+
}).length)),
|
|
243
|
+
tile('Integrity issues', String(health.integrityIssues.length)),
|
|
244
|
+
]));
|
|
245
|
+
main.appendChild(el('h3', { text: 'Runner health (quota fallback, #67)' }));
|
|
246
|
+
if (runnerNames.length === 0) {
|
|
247
|
+
main.appendChild(el('p', { class: 'meta', text: 'No quota failures recorded.' }));
|
|
248
|
+
} else {
|
|
249
|
+
main.appendChild(el('table', {}, [
|
|
250
|
+
el('tr', {}, ['runner', 'status', 'paused until', 'failure count', 'last failure'].map((h) => el('th', { text: h }))),
|
|
251
|
+
...runnerNames.map((name) => {
|
|
252
|
+
const entry = health.pause.runnerHealth[name];
|
|
253
|
+
const paused = entry.pausedUntil && Date.parse(entry.pausedUntil) > Date.now();
|
|
254
|
+
return el('tr', {}, [
|
|
255
|
+
el('td', { text: name }),
|
|
256
|
+
el('td', {}, [el('span', { class: 'chip' + (paused ? ' amber' : ' ok') , text: paused ? 'paused' : 'available' })]),
|
|
257
|
+
el('td', { text: entry.pausedUntil || '' }),
|
|
258
|
+
el('td', { text: String(entry.failureCount || 0) }),
|
|
259
|
+
el('td', { text: entry.lastFailureAt || '' }),
|
|
260
|
+
]);
|
|
261
|
+
}),
|
|
262
|
+
]));
|
|
263
|
+
}
|
|
264
|
+
main.appendChild(el('h3', { text: 'Storage' }));
|
|
265
|
+
main.appendChild(el('pre', { text: JSON.stringify(health.storage, null, 2) }));
|
|
266
|
+
main.appendChild(el('h3', { text: 'Source polling' }));
|
|
267
|
+
main.appendChild(el('pre', { text: JSON.stringify(health.sources, null, 2) }));
|
|
268
|
+
main.appendChild(el('h3', { text: 'Integrity issues' }));
|
|
269
|
+
main.appendChild(el('pre', { text: JSON.stringify(health.integrityIssues, null, 2) }));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function tile(label, value) {
|
|
273
|
+
return el('div', { class: 'tile' }, [el('div', { class: 'n', text: value }), el('div', { class: 'l', text: label })]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const renderers = { board: renderBoard, activity: renderActivity, runs: renderRuns, config: renderConfig, health: renderHealth };
|
|
277
|
+
|
|
278
|
+
function switchView(view) {
|
|
279
|
+
currentView = view;
|
|
280
|
+
for (const btn of document.querySelectorAll('nav button')) {
|
|
281
|
+
btn.classList.toggle('active', btn.dataset.view === view);
|
|
282
|
+
}
|
|
283
|
+
renderers[view]();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const btn of document.querySelectorAll('nav button')) {
|
|
287
|
+
btn.addEventListener('click', () => switchView(btn.dataset.view));
|
|
288
|
+
}
|
|
289
|
+
document.getElementById('drawer-close').addEventListener('click', () => {
|
|
290
|
+
document.getElementById('drawer').classList.remove('open');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
renderStatusBar();
|
|
294
|
+
switchView('board');
|
|
295
|
+
setInterval(() => {
|
|
296
|
+
renderStatusBar();
|
|
297
|
+
renderers[currentView]();
|
|
298
|
+
}, 7000);
|
|
299
|
+
</script>
|
|
300
|
+
</body>
|
|
301
|
+
</html>
|
|
302
|
+
`;
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildResourceUri } from '../../domain/resource-uri.js';
|
|
4
|
+
import { isTerminalStage } from '../../domain/stages.js';
|
|
5
|
+
import { workflowForProjection } from '../../domain/workflows.js';
|
|
6
|
+
function parseLockMetadata(raw) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(raw);
|
|
9
|
+
if (typeof parsed.pid !== 'number' || typeof parsed.acquiredAt !== 'string') {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return { pid: parsed.pid, acquiredAt: parsed.acquiredAt };
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function isPidAlive(pid) {
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return error.code !== 'ESRCH';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function readLockInfo(lockFile, now) {
|
|
28
|
+
try {
|
|
29
|
+
const metadata = parseLockMetadata(await readFile(lockFile, 'utf8'));
|
|
30
|
+
if (metadata === null) {
|
|
31
|
+
return { present: true };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
present: true,
|
|
35
|
+
pid: metadata.pid,
|
|
36
|
+
acquiredAt: metadata.acquiredAt,
|
|
37
|
+
ageMs: now.getTime() - Date.parse(metadata.acquiredAt),
|
|
38
|
+
pidAlive: isPidAlive(metadata.pid),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { present: false };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reproduces the sentinel-driven part of policy eligibility for display purposes only.
|
|
47
|
+
* It never decides anything the tick doesn't independently decide — this is a read model.
|
|
48
|
+
*/
|
|
49
|
+
function deriveCondition(item, lastRun, config) {
|
|
50
|
+
const stage = item.wake.stage;
|
|
51
|
+
if (isTerminalStage(stage) || item.issue.state === 'closed') {
|
|
52
|
+
return { condition: 'finished', reason: 'terminal stage' };
|
|
53
|
+
}
|
|
54
|
+
if (lastRun?.status === 'running') {
|
|
55
|
+
return { condition: 'active', reason: 'run in flight' };
|
|
56
|
+
}
|
|
57
|
+
if (lastRun?.status === 'blocked' ||
|
|
58
|
+
lastRun?.status === 'awaiting-approval' ||
|
|
59
|
+
lastRun?.sentinel === 'BLOCKED' ||
|
|
60
|
+
lastRun?.sentinel === 'AWAITING_APPROVAL') {
|
|
61
|
+
return { condition: 'needs-human', reason: `sentinel ${lastRun?.sentinel ?? stage}` };
|
|
62
|
+
}
|
|
63
|
+
const workflow = workflowForProjection(item, config);
|
|
64
|
+
const hasRoute = config.stages[stage] !== undefined || workflow?.stages[stage] !== undefined;
|
|
65
|
+
if (!hasRoute) {
|
|
66
|
+
return { condition: 'stalled', reason: `no route configured for stage "${stage}"` };
|
|
67
|
+
}
|
|
68
|
+
if (lastRun?.sentinel === 'FAILED') {
|
|
69
|
+
return { condition: 'waiting', reason: 'last run failed; awaiting operator/retry policy' };
|
|
70
|
+
}
|
|
71
|
+
return { condition: 'ready', reason: 'has a route and no blocking condition' };
|
|
72
|
+
}
|
|
73
|
+
function timeInStageMs(item, now) {
|
|
74
|
+
const lastChange = item.wake.stageHistory.at(-1)?.changedAt ?? item.wake.syncedAt;
|
|
75
|
+
return now.getTime() - Date.parse(lastChange);
|
|
76
|
+
}
|
|
77
|
+
export async function buildBoard(input) {
|
|
78
|
+
const items = await input.stateStore.listIssueStates({
|
|
79
|
+
archiveFreshnessDays: input.config.ui.archiveFreshnessDays,
|
|
80
|
+
now: input.now,
|
|
81
|
+
});
|
|
82
|
+
const lastRuns = await Promise.all(items.map((item) => item.wake.lastRunId === undefined
|
|
83
|
+
? Promise.resolve(null)
|
|
84
|
+
: input.stateStore.readRunRecord(item.wake.lastRunId)));
|
|
85
|
+
return items.map((item, index) => {
|
|
86
|
+
const lastRun = lastRuns[index] ?? null;
|
|
87
|
+
const { condition, reason } = deriveCondition(item, lastRun, input.config);
|
|
88
|
+
return {
|
|
89
|
+
repo: item.issue.repo,
|
|
90
|
+
number: item.issue.number,
|
|
91
|
+
title: item.issue.title,
|
|
92
|
+
url: item.issue.url,
|
|
93
|
+
stage: item.wake.stage,
|
|
94
|
+
condition,
|
|
95
|
+
conditionReason: reason,
|
|
96
|
+
timeInStageMs: timeInStageMs(item, input.now),
|
|
97
|
+
lastRunAction: lastRun?.action,
|
|
98
|
+
lastRunSentinel: lastRun?.sentinel,
|
|
99
|
+
lastRunStatus: lastRun?.status,
|
|
100
|
+
sessionId: item.wake.sessionId,
|
|
101
|
+
workspacePath: item.wake.workspacePath,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export async function buildStatus(input) {
|
|
106
|
+
const today = input.now.toISOString().slice(0, 10);
|
|
107
|
+
const [ledger, paused, recentEvents, todaysRuns, recentRuns, board] = await Promise.all([
|
|
108
|
+
input.stateStore.readLedger(),
|
|
109
|
+
input.stateStore.isPaused(),
|
|
110
|
+
input.stateStore.listRecentEventEnvelopes({ limit: 1 }),
|
|
111
|
+
input.stateStore.listRunRecordsForDate(today),
|
|
112
|
+
input.stateStore.listRecentRunRecords(1),
|
|
113
|
+
buildBoard(input),
|
|
114
|
+
]);
|
|
115
|
+
const lock = await readLockInfo(input.stateStore.paths.tickLockFile, input.now);
|
|
116
|
+
const lockLive = lock.present && lock.pidAlive === true;
|
|
117
|
+
// A quota-paused runner (#67) no longer stops the loop - routing falls
|
|
118
|
+
// sideways to another candidate in the tier, so only the manual pause file
|
|
119
|
+
// is a hard stop here. Per-runner health is surfaced separately below.
|
|
120
|
+
const loopState = paused
|
|
121
|
+
? 'paused'
|
|
122
|
+
: lockLive
|
|
123
|
+
? 'ticking'
|
|
124
|
+
: 'idle';
|
|
125
|
+
const runnerHealth = ledger?.runners ?? {};
|
|
126
|
+
const lastEvent = recentEvents[0];
|
|
127
|
+
const lastRun = recentRuns[0];
|
|
128
|
+
const counters = {
|
|
129
|
+
'needs-human': 0,
|
|
130
|
+
active: 0,
|
|
131
|
+
ready: 0,
|
|
132
|
+
waiting: 0,
|
|
133
|
+
stalled: 0,
|
|
134
|
+
finished: 0,
|
|
135
|
+
};
|
|
136
|
+
for (const card of board) {
|
|
137
|
+
counters[card.condition] += 1;
|
|
138
|
+
}
|
|
139
|
+
const intervalMs = input.config.scheduler.intervalMs;
|
|
140
|
+
const sourceStates = await listSourceStates(input.stateStore.paths.sourceStateRoot);
|
|
141
|
+
const worstAgeMs = sourceStates.length === 0
|
|
142
|
+
? undefined
|
|
143
|
+
: Math.max(...sourceStates.map((source) => input.now.getTime() - Date.parse(source.lastSuccessfulPollAt)));
|
|
144
|
+
const sourceFreshness = worstAgeMs === undefined
|
|
145
|
+
? { level: 'unknown' }
|
|
146
|
+
: {
|
|
147
|
+
ageMs: worstAgeMs,
|
|
148
|
+
level: worstAgeMs > intervalMs * 10 ? 'red' : worstAgeMs > intervalMs * 3 ? 'amber' : 'ok',
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
loopState,
|
|
152
|
+
paused,
|
|
153
|
+
runnerHealth,
|
|
154
|
+
lock,
|
|
155
|
+
lastEvent: lastEvent === undefined
|
|
156
|
+
? undefined
|
|
157
|
+
: { at: lastEvent.ingestedAt, type: lastEvent.sourceEventType, workItemKey: lastEvent.workItemKey },
|
|
158
|
+
lastRun: lastRun === undefined
|
|
159
|
+
? undefined
|
|
160
|
+
: {
|
|
161
|
+
repo: lastRun.repo,
|
|
162
|
+
issueNumber: lastRun.issueNumber,
|
|
163
|
+
action: lastRun.action,
|
|
164
|
+
sentinel: lastRun.sentinel,
|
|
165
|
+
status: lastRun.status,
|
|
166
|
+
},
|
|
167
|
+
sourceFreshness,
|
|
168
|
+
counters,
|
|
169
|
+
runsToday: countToday(todaysRuns.map((run) => run.startedAt), input.now),
|
|
170
|
+
failuresToday: countToday(todaysRuns.filter((run) => run.status === 'failed').map((run) => run.startedAt), input.now),
|
|
171
|
+
costUsdToday: sumToday(todaysRuns.map((run) => ({ at: run.startedAt, value: run.tokenUsage?.costUsd ?? 0 })), input.now),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function countToday(timestamps, now) {
|
|
175
|
+
const today = now.toISOString().slice(0, 10);
|
|
176
|
+
return timestamps.filter((ts) => ts.slice(0, 10) === today).length;
|
|
177
|
+
}
|
|
178
|
+
function sumToday(entries, now) {
|
|
179
|
+
const today = now.toISOString().slice(0, 10);
|
|
180
|
+
return entries
|
|
181
|
+
.filter((entry) => entry.at.slice(0, 10) === today)
|
|
182
|
+
.reduce((total, entry) => total + entry.value, 0);
|
|
183
|
+
}
|
|
184
|
+
async function listSourceStates(sourceStateRoot) {
|
|
185
|
+
try {
|
|
186
|
+
const sourceDirs = await readdir(sourceStateRoot);
|
|
187
|
+
const results = [];
|
|
188
|
+
for (const sourceDir of sourceDirs) {
|
|
189
|
+
const files = await readdir(join(sourceStateRoot, sourceDir)).catch(() => []);
|
|
190
|
+
for (const file of files) {
|
|
191
|
+
if (!file.endsWith('.json')) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const raw = JSON.parse(await readFile(join(sourceStateRoot, sourceDir, file), 'utf8'));
|
|
196
|
+
if (typeof raw.lastSuccessfulPollAt === 'string') {
|
|
197
|
+
results.push({
|
|
198
|
+
source: sourceDir,
|
|
199
|
+
key: file.replace(/\.json$/, ''),
|
|
200
|
+
lastSuccessfulPollAt: raw.lastSuccessfulPollAt,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return results;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export async function buildItemDetail(input) {
|
|
216
|
+
// The UI addresses items by the ticket a human recognizes them by; work ids
|
|
217
|
+
// are opaque (spec D3). The ticket's uri is *constructed* here (never parsed)
|
|
218
|
+
// and resolved through the reverse index in one shard read (spec D2), then
|
|
219
|
+
// everything downstream keys off the record's own workItemKey.
|
|
220
|
+
const uri = buildResourceUri(input.provider, 'issue', `${input.repo}#${input.issueNumber}`);
|
|
221
|
+
const workItemKey = await input.resourceIndex.resolve(uri);
|
|
222
|
+
if (workItemKey === undefined) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
const item = await input.stateStore.readIssueState(workItemKey);
|
|
226
|
+
if (item === null) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const allRuns = await input.stateStore.listRunRecords();
|
|
230
|
+
const runs = allRuns
|
|
231
|
+
.filter((run) => run.workItemKey === item.workItemKey)
|
|
232
|
+
.sort((left, right) => left.startedAt.localeCompare(right.startedAt));
|
|
233
|
+
const events = await input.stateStore.listEventEnvelopesForWorkItem(item.workItemKey, 50);
|
|
234
|
+
return { item, runs, events };
|
|
235
|
+
}
|
|
236
|
+
export async function buildEventsFeed(input) {
|
|
237
|
+
const limit = input.limit ?? 200;
|
|
238
|
+
return input.stateStore.listRecentEventEnvelopes({
|
|
239
|
+
limit,
|
|
240
|
+
...(input.workItemKey === undefined ? {} : { workItemKey: input.workItemKey }),
|
|
241
|
+
...(input.direction === undefined ? {} : { direction: input.direction }),
|
|
242
|
+
...(input.type === undefined ? {} : { type: input.type }),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
export async function buildRuns(input) {
|
|
246
|
+
const runs = await input.stateStore.listRunRecords();
|
|
247
|
+
return runs
|
|
248
|
+
.filter((run) => input.status === undefined || run.status === input.status)
|
|
249
|
+
.filter((run) => input.action === undefined || run.action === input.action)
|
|
250
|
+
.filter((run) => input.repo === undefined || run.repo === input.repo)
|
|
251
|
+
.sort((left, right) => right.startedAt.localeCompare(left.startedAt));
|
|
252
|
+
}
|
|
253
|
+
const secretKeyPattern = /token|secret|key|password/i;
|
|
254
|
+
function redact(value, keyHint = '') {
|
|
255
|
+
if (secretKeyPattern.test(keyHint) && typeof value === 'string') {
|
|
256
|
+
return '***redacted***';
|
|
257
|
+
}
|
|
258
|
+
if (Array.isArray(value)) {
|
|
259
|
+
return value.map((entry) => redact(entry));
|
|
260
|
+
}
|
|
261
|
+
if (value !== null && typeof value === 'object') {
|
|
262
|
+
const out = {};
|
|
263
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
264
|
+
out[key] = redact(entry, key);
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
export async function buildConfigView(input) {
|
|
271
|
+
const ledger = await input.stateStore.readLedger();
|
|
272
|
+
const runnerHealth = ledger?.runners ?? {};
|
|
273
|
+
const routingTable = Object.entries(input.config.stages).map(([stage, route]) => {
|
|
274
|
+
const tier = route.tier ?? input.config.defaultTier;
|
|
275
|
+
const candidates = input.config.tiers[tier] ?? [];
|
|
276
|
+
const runnerName = route.runner ?? candidates[0];
|
|
277
|
+
const runner = runnerName !== undefined ? input.config.runners[runnerName] : undefined;
|
|
278
|
+
// Full fallback order for the tier (#67), each candidate's current pause
|
|
279
|
+
// state so the UI can show not just who's active but who Wake would fall
|
|
280
|
+
// sideways to next, and rotate back to once a pause expires.
|
|
281
|
+
const candidateHealth = candidates.map((name) => {
|
|
282
|
+
const health = runnerHealth[name];
|
|
283
|
+
const pausedUntil = health?.pausedUntil;
|
|
284
|
+
const paused = pausedUntil !== undefined && Date.parse(pausedUntil) > input.now.getTime();
|
|
285
|
+
return { runnerName: name, paused, pausedUntil };
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
stage,
|
|
289
|
+
action: route.action,
|
|
290
|
+
tier,
|
|
291
|
+
runnerName,
|
|
292
|
+
runnerKind: runner?.kind,
|
|
293
|
+
model: runner !== undefined && runner.kind !== 'fake' ? runner.model : undefined,
|
|
294
|
+
timeoutMs: runner !== undefined && runner.kind !== 'fake' ? runner.timeoutMs : undefined,
|
|
295
|
+
candidates: candidateHealth,
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
return { config: redact(input.config), routingTable };
|
|
299
|
+
}
|
|
300
|
+
async function dirSize(path) {
|
|
301
|
+
let files = 0;
|
|
302
|
+
let bytes = 0;
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = await readdir(path);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return { files: 0, bytes: 0 };
|
|
309
|
+
}
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
const entryPath = join(path, entry);
|
|
312
|
+
const info = await stat(entryPath).catch(() => null);
|
|
313
|
+
if (info === null) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (info.isDirectory()) {
|
|
317
|
+
const nested = await dirSize(entryPath);
|
|
318
|
+
files += nested.files;
|
|
319
|
+
bytes += nested.bytes;
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
files += 1;
|
|
323
|
+
bytes += info.size;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { files, bytes };
|
|
327
|
+
}
|
|
328
|
+
export async function buildHealth(input) {
|
|
329
|
+
const lock = await readLockInfo(input.stateStore.paths.tickLockFile, input.now);
|
|
330
|
+
const staleAfterMs = 15 * 60 * 1000;
|
|
331
|
+
const paused = await input.stateStore.isPaused();
|
|
332
|
+
const ledger = await input.stateStore.readLedger();
|
|
333
|
+
const sourceStates = await listSourceStates(input.stateStore.paths.sourceStateRoot);
|
|
334
|
+
const storageDirs = ['events', 'state', 'runs', 'workspaces'];
|
|
335
|
+
const storageSizes = await Promise.all(storageDirs.map((dir) => dirSize(join(input.stateStore.paths.wakeRoot, dir))));
|
|
336
|
+
const storage = Object.fromEntries(storageDirs.map((dir, i) => [dir, storageSizes[i]]));
|
|
337
|
+
const items = await input.stateStore.listIssueStates();
|
|
338
|
+
const integrityChecks = await Promise.all(items
|
|
339
|
+
.filter((item) => item.wake.workspacePath !== undefined)
|
|
340
|
+
.map(async (item) => {
|
|
341
|
+
const exists = await stat(item.wake.workspacePath).then(() => true).catch(() => false);
|
|
342
|
+
return exists ? null : { path: item.wake.workspacePath, problem: `workspacePath missing for ${item.workItemKey}` };
|
|
343
|
+
}));
|
|
344
|
+
const integrityIssues = integrityChecks.filter((issue) => issue !== null);
|
|
345
|
+
return {
|
|
346
|
+
lock: {
|
|
347
|
+
...lock,
|
|
348
|
+
stale: lock.present && ((lock.ageMs ?? 0) >= staleAfterMs || lock.pidAlive === false),
|
|
349
|
+
staleAfterMs,
|
|
350
|
+
},
|
|
351
|
+
pause: { paused, runnerHealth: ledger?.runners ?? {} },
|
|
352
|
+
sources: sourceStates.map((source) => ({
|
|
353
|
+
...source,
|
|
354
|
+
ageMs: input.now.getTime() - Date.parse(source.lastSuccessfulPollAt),
|
|
355
|
+
})),
|
|
356
|
+
storage,
|
|
357
|
+
integrityIssues,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
export async function buildWorkspaces(input) {
|
|
361
|
+
const items = await input.stateStore.listIssueStates();
|
|
362
|
+
const byWorkspacePath = new Map(items.filter((item) => item.wake.workspacePath !== undefined).map((item) => [item.wake.workspacePath, item]));
|
|
363
|
+
const workspaceRoot = input.stateStore.paths.workspaceRoot;
|
|
364
|
+
const workspaces = [];
|
|
365
|
+
let repoDirs = [];
|
|
366
|
+
try {
|
|
367
|
+
repoDirs = await readdir(workspaceRoot);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
repoDirs = [];
|
|
371
|
+
}
|
|
372
|
+
for (const repoDir of repoDirs) {
|
|
373
|
+
const repoPath = join(workspaceRoot, repoDir);
|
|
374
|
+
const issueDirs = await readdir(repoPath).catch(() => []);
|
|
375
|
+
for (const issueDir of issueDirs) {
|
|
376
|
+
const fullPath = join(repoPath, issueDir);
|
|
377
|
+
const size = await dirSize(fullPath);
|
|
378
|
+
const matched = byWorkspacePath.get(fullPath);
|
|
379
|
+
workspaces.push({
|
|
380
|
+
path: fullPath,
|
|
381
|
+
repo: matched?.issue.repo,
|
|
382
|
+
issueNumber: matched?.issue.number,
|
|
383
|
+
size: size.bytes,
|
|
384
|
+
orphan: matched === undefined,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return workspaces;
|
|
389
|
+
}
|