@dfsramos/foreman 0.0.0-stage → 0.3.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +385 -2
- package/dist/agent/adapters/agy.js +96 -0
- package/dist/agent/adapters/claude.js +152 -0
- package/dist/agent/adapters/codex.js +107 -0
- package/dist/agent/adapters/opencode.js +91 -0
- package/dist/agent/config.js +169 -0
- package/dist/agent/process.js +230 -0
- package/dist/agent/prompt.js +184 -0
- package/dist/agent/registry.js +57 -0
- package/dist/agent/types.js +12 -0
- package/dist/cli-args.js +70 -0
- package/dist/cli.js +184 -0
- package/dist/config.js +102 -0
- package/dist/daemon.js +109 -0
- package/dist/db.js +467 -0
- package/dist/git/taskstore.js +224 -0
- package/dist/git/worktree.js +248 -0
- package/dist/imports/connections.js +57 -0
- package/dist/imports/destination.js +2 -0
- package/dist/imports/linear.js +40 -0
- package/dist/imports/registry.js +4 -0
- package/dist/imports/types.js +1 -0
- package/dist/kaneo/client.js +47 -0
- package/dist/kaneo/issues.js +138 -0
- package/dist/kaneo/workspace.js +24 -0
- package/dist/linear/browse.js +55 -0
- package/dist/linear/client.js +48 -0
- package/dist/linear/issues.js +99 -0
- package/dist/linear/workspace.js +30 -0
- package/dist/paths.js +12 -0
- package/dist/pidfile.js +35 -0
- package/dist/projects.js +211 -0
- package/dist/review/decide.js +186 -0
- package/dist/review/loop.js +114 -0
- package/dist/runner.js +1281 -0
- package/dist/server/app.js +344 -0
- package/dist/server/imports.js +182 -0
- package/dist/server/tasks.js +94 -0
- package/dist/sources/branch.js +17 -0
- package/dist/sources/git-import.js +85 -0
- package/dist/sources/git.js +431 -0
- package/dist/sources/kaneo.js +156 -0
- package/dist/sources/lease.js +38 -0
- package/dist/sources/linear.js +186 -0
- package/dist/sources/options.js +26 -0
- package/dist/sources/registry.js +6 -0
- package/dist/sources/taskfile.js +210 -0
- package/dist/sources/types.js +1 -0
- package/package.json +63 -4
- package/public/agents.js +435 -0
- package/public/app.js +1200 -0
- package/public/imports.js +481 -0
- package/public/index.html +19 -0
- package/public/projects.js +481 -0
- package/public/style.css +1614 -0
- package/public/tasks.js +279 -0
- package/public/ui.js +165 -0
package/public/agents.js
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// The Agents tab: which agent CLIs are installed, which may take work, how many at once, in what
|
|
2
|
+
// order, and each one's own permission settings.
|
|
3
|
+
|
|
4
|
+
import { api, banner, button, currentWindows, fill, fmtPercent, fmtReset, fmtTime, h, icon, windowLabel } from './ui.js';
|
|
5
|
+
|
|
6
|
+
const MAX_CONCURRENT = 16;
|
|
7
|
+
|
|
8
|
+
// Unsaved edits live here so that re-rendering the tab does not lose them.
|
|
9
|
+
let draft = null;
|
|
10
|
+
let serverErrors = {};
|
|
11
|
+
let note = null;
|
|
12
|
+
|
|
13
|
+
/** Forgets unsaved edits; called when the user leaves the tab. */
|
|
14
|
+
export function resetAgentsDraft() {
|
|
15
|
+
draft = null;
|
|
16
|
+
serverErrors = {};
|
|
17
|
+
note = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const toDraft = (data) => ({
|
|
21
|
+
order: [...data.order],
|
|
22
|
+
agents: Object.fromEntries(
|
|
23
|
+
data.agents.map((agent) => [
|
|
24
|
+
agent.id,
|
|
25
|
+
{
|
|
26
|
+
enabled: agent.enabled,
|
|
27
|
+
maxConcurrent: String(agent.maxConcurrent),
|
|
28
|
+
bin: agent.bin ?? '',
|
|
29
|
+
maxUsagePercent: agent.maxUsagePercent === null ? '' : String(agent.maxUsagePercent),
|
|
30
|
+
allowOverage: agent.allowOverage,
|
|
31
|
+
settings: structuredClone(agent.settings),
|
|
32
|
+
},
|
|
33
|
+
]),
|
|
34
|
+
),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const concurrencyError = (value) =>
|
|
38
|
+
/^\d+$/.test(value.trim()) && Number(value) <= MAX_CONCURRENT ? null : `Must be a whole number between 0 and ${MAX_CONCURRENT}.`;
|
|
39
|
+
|
|
40
|
+
const usageLimitError = (value) => {
|
|
41
|
+
const text = value.trim();
|
|
42
|
+
if (text === '') return null;
|
|
43
|
+
return /^\d+$/.test(text) && Number(text) >= 1 && Number(text) <= 100 ? null : 'Must be a whole number between 1 and 100, or empty to never pause.';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function clientErrors() {
|
|
47
|
+
const errors = {};
|
|
48
|
+
for (const [id, entry] of Object.entries(draft.agents)) {
|
|
49
|
+
const error = concurrencyError(entry.maxConcurrent);
|
|
50
|
+
if (error) errors[`${id}.maxConcurrent`] = error;
|
|
51
|
+
const limitError = usageLimitError(entry.maxUsagePercent);
|
|
52
|
+
if (limitError) errors[`${id}.maxUsagePercent`] = limitError;
|
|
53
|
+
}
|
|
54
|
+
return errors;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One line summarising an agent's finished runs, so the routing order can rest on its record. */
|
|
58
|
+
function statsLine(stats) {
|
|
59
|
+
if (!stats || stats.work + stats.reviewRounds === 0) return h('span', { class: 'faint' }, 'Record: no finished runs yet.');
|
|
60
|
+
const parts = [`${stats.succeeded} of ${stats.work} ${stats.work === 1 ? 'issue' : 'issues'} opened a pull request`];
|
|
61
|
+
if (stats.failed > 0) parts.push(`${stats.failed} failed`);
|
|
62
|
+
if (stats.blocked > 0) parts.push(`${stats.blocked} needed input`);
|
|
63
|
+
if (stats.aborted > 0) parts.push(`${stats.aborted} aborted`);
|
|
64
|
+
if (stats.reviewRounds > 0) parts.push(`${stats.reviewRounds} review ${stats.reviewRounds === 1 ? 'round' : 'rounds'}`);
|
|
65
|
+
if (stats.medianMinutes !== null) parts.push(`median ${stats.medianMinutes} min per issue`);
|
|
66
|
+
if (stats.costUsd > 0) parts.push(`$${stats.costUsd.toFixed(2)} list price`);
|
|
67
|
+
return [h('span', { class: 'muted' }, 'Record: '), parts.join(' · ')];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One line describing an agent's plan usage, or why there is none. */
|
|
71
|
+
function usageLine(agent) {
|
|
72
|
+
const windows = currentWindows(agent.usage);
|
|
73
|
+
if (windows.length === 0) {
|
|
74
|
+
return h('span', { class: 'faint' }, agent.usage ? 'Plan usage: every reported window has reset since the last run.' : 'Plan usage: reported after this agent’s next run.');
|
|
75
|
+
}
|
|
76
|
+
return [
|
|
77
|
+
h('span', { class: 'muted' }, 'Plan usage: '),
|
|
78
|
+
windows.map(([key, window], index) => [
|
|
79
|
+
index > 0 ? ' · ' : null,
|
|
80
|
+
`${windowLabel(key, true)} `,
|
|
81
|
+
h('b', null, fmtPercent(window.utilization)),
|
|
82
|
+
h('span', { class: 'faint' }, ` (resets ${fmtReset(window.resetsAt)})`),
|
|
83
|
+
]),
|
|
84
|
+
agent.usage.usingOverage ? h('span', { class: 'text-fail' }, ' · spending overage') : null,
|
|
85
|
+
h('span', { class: 'faint' }, ` · as of ${fmtTime(agent.usage.at)}`),
|
|
86
|
+
agent.paused ? h('div', { class: 'text-fail' }, `Paused: ${agent.paused}`) : null,
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Refreshes the usage lines in place, leaving the form and its unsaved input alone. */
|
|
91
|
+
export function updateAgentsUsage(data) {
|
|
92
|
+
for (const agent of data.agents) {
|
|
93
|
+
const line = document.getElementById(`agent-${agent.id}-usage`);
|
|
94
|
+
if (line) fill(line, usageLine(agent));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function payload() {
|
|
99
|
+
return {
|
|
100
|
+
order: draft.order,
|
|
101
|
+
agents: Object.fromEntries(
|
|
102
|
+
Object.entries(draft.agents).map(([id, entry]) => [
|
|
103
|
+
id,
|
|
104
|
+
{
|
|
105
|
+
enabled: entry.enabled,
|
|
106
|
+
maxConcurrent: Number(entry.maxConcurrent),
|
|
107
|
+
maxUsagePercent: entry.maxUsagePercent.trim() === '' ? null : Number(entry.maxUsagePercent),
|
|
108
|
+
allowOverage: entry.allowOverage,
|
|
109
|
+
...(entry.bin.trim() ? { bin: entry.bin.trim() } : {}),
|
|
110
|
+
settings: entry.settings,
|
|
111
|
+
},
|
|
112
|
+
]),
|
|
113
|
+
),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function availabilityBadges(agent) {
|
|
118
|
+
const badges = [];
|
|
119
|
+
if (agent.available) {
|
|
120
|
+
badges.push(h('span', { class: 'badge tone-ok' }, icon('tick', 11), agent.version ? `Available · ${agent.version}` : 'Available'));
|
|
121
|
+
} else {
|
|
122
|
+
badges.push(
|
|
123
|
+
h('span', { class: 'badge tone-muted', title: agent.error ?? '' }, icon('x', 11), agent.error === 'not found' ? 'Not found' : 'Not available'),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!agent.verified) {
|
|
127
|
+
badges.push(
|
|
128
|
+
h(
|
|
129
|
+
'span',
|
|
130
|
+
{ class: 'badge tone-warn', title: 'Written from the CLI’s documentation; not yet confirmed against the real CLI.' },
|
|
131
|
+
icon('warning', 11),
|
|
132
|
+
'Unverified adapter',
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (agent.signedOut) {
|
|
137
|
+
badges.push(
|
|
138
|
+
h('span', { class: 'badge tone-fail', title: 'The CLI reports that it is not signed in. Sign it in; foreman checks again within a minute.' }, icon('x', 11), 'Not signed in'),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (agent.paused) badges.push(h('span', { class: 'badge tone-fail', title: agent.paused }, icon('pause', 10), 'Paused'));
|
|
142
|
+
if (agent.active > 0) badges.push(h('span', { class: 'badge tone-run' }, icon('spinner', 11), `${agent.active} running`));
|
|
143
|
+
return badges;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param ctx.data the `/api/agents` response currently known
|
|
148
|
+
* @param ctx.onChange called with a fresh response after a save or a re-check
|
|
149
|
+
* @param ctx.rerender rebuilds the tab from the current draft
|
|
150
|
+
*/
|
|
151
|
+
export function agentsTab(ctx) {
|
|
152
|
+
const { data } = ctx;
|
|
153
|
+
if (!draft) draft = toDraft(data);
|
|
154
|
+
const pristine = JSON.stringify(toDraft(data));
|
|
155
|
+
const byId = new Map(data.agents.map((agent) => [agent.id, agent]));
|
|
156
|
+
const saveBar = h('div', { class: 'save-bar' });
|
|
157
|
+
|
|
158
|
+
const errorFor = (key) => clientErrors()[key] ?? serverErrors[key] ?? null;
|
|
159
|
+
const touch = (key) => {
|
|
160
|
+
delete serverErrors[key];
|
|
161
|
+
note = null;
|
|
162
|
+
refreshBar();
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function refreshBar() {
|
|
166
|
+
const dirty = JSON.stringify(draft) !== pristine;
|
|
167
|
+
const errors = { ...serverErrors, ...clientErrors() };
|
|
168
|
+
const count = Object.keys(errors).length;
|
|
169
|
+
const notes = [];
|
|
170
|
+
if (dirty) notes.push(h('span', { class: 'note warn' }, h('span', { class: 'dot' }), 'Unsaved changes'));
|
|
171
|
+
if (count > 0) {
|
|
172
|
+
notes.push(h('span', { class: 'note fail' }, icon('warning', 13), `${count} ${count === 1 ? 'error' : 'errors'} to fix before saving`));
|
|
173
|
+
}
|
|
174
|
+
// Errors that belong to no visible field (e.g. the order) are spelled out.
|
|
175
|
+
for (const key of ['_', 'order']) {
|
|
176
|
+
if (errors[key]) notes.push(h('span', { class: 'note fail' }, `${key === '_' ? '' : `${key}: `}${errors[key]}`));
|
|
177
|
+
}
|
|
178
|
+
if (notes.length === 0 && note) notes.push(h('span', { class: 'note ok' }, icon('tick', 12), note));
|
|
179
|
+
fill(
|
|
180
|
+
saveBar,
|
|
181
|
+
notes,
|
|
182
|
+
h('span', { class: 'spacer' }),
|
|
183
|
+
button('Discard', {
|
|
184
|
+
disabled: !dirty,
|
|
185
|
+
onclick: () => {
|
|
186
|
+
resetAgentsDraft();
|
|
187
|
+
ctx.rerender();
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
button('Save changes', { variant: 'primary', disabled: !dirty || Object.keys(clientErrors()).length > 0, onclick: save }),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function save() {
|
|
195
|
+
const response = await api('PUT', '/api/agents', payload());
|
|
196
|
+
if (response.ok) {
|
|
197
|
+
resetAgentsDraft();
|
|
198
|
+
note = 'Saved. Applies from the next poll.';
|
|
199
|
+
ctx.onChange(response.data);
|
|
200
|
+
} else {
|
|
201
|
+
serverErrors = Object.fromEntries(
|
|
202
|
+
Object.entries(response.data?.errors ?? { _: `Save failed (${response.status})` }).map(([key, message]) => [key, `Server: ${message}`]),
|
|
203
|
+
);
|
|
204
|
+
ctx.rerender();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function recheck(event) {
|
|
209
|
+
const control = event.currentTarget;
|
|
210
|
+
control.disabled = true;
|
|
211
|
+
const response = await api('POST', '/api/agents/refresh');
|
|
212
|
+
if (response.ok) {
|
|
213
|
+
note = 'Checked which CLIs are installed.';
|
|
214
|
+
ctx.onChange(response.data);
|
|
215
|
+
} else {
|
|
216
|
+
control.disabled = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function field(label, key, control, help) {
|
|
221
|
+
const error = errorFor(key);
|
|
222
|
+
const helpEl = h('div', { class: 'help' }, error ? `⚠ ${error}` : (help ?? ''));
|
|
223
|
+
const wrap = h(
|
|
224
|
+
'div',
|
|
225
|
+
{ class: `field ${error ? 'error' : ''}` },
|
|
226
|
+
h('div', { class: 'label-row' }, h('label', { for: control.id }, label)),
|
|
227
|
+
control,
|
|
228
|
+
helpEl,
|
|
229
|
+
);
|
|
230
|
+
return { wrap, helpEl };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function textField(id, label, key, value, { help, placeholder, numeric, validate, onInput }) {
|
|
234
|
+
const input = h('input', {
|
|
235
|
+
id: `agent-${id}-${key}`,
|
|
236
|
+
class: 'input',
|
|
237
|
+
type: 'text',
|
|
238
|
+
inputmode: numeric ? 'numeric' : null,
|
|
239
|
+
autocomplete: 'off',
|
|
240
|
+
spellcheck: 'false',
|
|
241
|
+
placeholder,
|
|
242
|
+
value,
|
|
243
|
+
});
|
|
244
|
+
const { wrap, helpEl } = field(label, `${id}.${key}`, input, help);
|
|
245
|
+
input.addEventListener('input', () => {
|
|
246
|
+
onInput(input.value);
|
|
247
|
+
const error = validate?.(input.value) ?? null;
|
|
248
|
+
wrap.classList.toggle('error', Boolean(error));
|
|
249
|
+
helpEl.textContent = error ? `⚠ ${error}` : (help ?? '');
|
|
250
|
+
touch(`${id}.${key}`);
|
|
251
|
+
});
|
|
252
|
+
return wrap;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function selectField(id, spec, entry) {
|
|
256
|
+
const select = h(
|
|
257
|
+
'select',
|
|
258
|
+
{ id: `agent-${id}-${spec.key}`, class: 'input' },
|
|
259
|
+
spec.options.map((option) => h('option', { value: option, selected: entry.settings[spec.key] === option }, option)),
|
|
260
|
+
);
|
|
261
|
+
select.addEventListener('change', () => {
|
|
262
|
+
entry.settings[spec.key] = select.value;
|
|
263
|
+
touch(`${id}.settings.${spec.key}`);
|
|
264
|
+
});
|
|
265
|
+
return field(spec.label, `${id}.settings.${spec.key}`, select, spec.help).wrap;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function overageField(id, entry) {
|
|
269
|
+
const select = h(
|
|
270
|
+
'select',
|
|
271
|
+
{ id: `agent-${id}-allowOverage`, class: 'input' },
|
|
272
|
+
h('option', { value: 'no', selected: !entry.allowOverage }, 'Pause'),
|
|
273
|
+
h('option', { value: 'yes', selected: entry.allowOverage }, 'Keep working'),
|
|
274
|
+
);
|
|
275
|
+
select.addEventListener('change', () => {
|
|
276
|
+
entry.allowOverage = select.value === 'yes';
|
|
277
|
+
touch(`${id}.allowOverage`);
|
|
278
|
+
});
|
|
279
|
+
return field('When spending paid overage', `${id}.allowOverage`, select, 'Overage is usage billed beyond the plan’s allowance.').wrap;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function listField(id, spec, entry) {
|
|
283
|
+
const items = Array.isArray(entry.settings[spec.key]) ? entry.settings[spec.key] : (entry.settings[spec.key] = []);
|
|
284
|
+
const key = `${id}.settings.${spec.key}`;
|
|
285
|
+
const error = errorFor(key);
|
|
286
|
+
const input = h('input', {
|
|
287
|
+
id: `agent-${id}-${spec.key}`,
|
|
288
|
+
class: 'input',
|
|
289
|
+
type: 'text',
|
|
290
|
+
autocomplete: 'off',
|
|
291
|
+
spellcheck: 'false',
|
|
292
|
+
placeholder: 'Add an entry',
|
|
293
|
+
'aria-label': `Add to ${spec.label}`,
|
|
294
|
+
});
|
|
295
|
+
const add = () => {
|
|
296
|
+
const value = input.value.trim();
|
|
297
|
+
if (value === '' || items.includes(value)) return;
|
|
298
|
+
items.push(value);
|
|
299
|
+
delete serverErrors[key];
|
|
300
|
+
note = null;
|
|
301
|
+
ctx.rerender();
|
|
302
|
+
document.getElementById(input.id)?.focus();
|
|
303
|
+
};
|
|
304
|
+
input.addEventListener('keydown', (event) => {
|
|
305
|
+
if (event.key === 'Enter') add();
|
|
306
|
+
});
|
|
307
|
+
return h(
|
|
308
|
+
'div',
|
|
309
|
+
{ class: `field wide ${error ? 'error' : ''}` },
|
|
310
|
+
h('div', { class: 'label-row' }, h('label', { for: input.id }, spec.label)),
|
|
311
|
+
h(
|
|
312
|
+
'div',
|
|
313
|
+
{ class: 'tools' },
|
|
314
|
+
items.map((item, index) =>
|
|
315
|
+
h(
|
|
316
|
+
'div',
|
|
317
|
+
{ class: 'tool' },
|
|
318
|
+
h('span', { class: 'name', title: item }, item),
|
|
319
|
+
h(
|
|
320
|
+
'button',
|
|
321
|
+
{
|
|
322
|
+
class: 'icon-btn tiny',
|
|
323
|
+
type: 'button',
|
|
324
|
+
'aria-label': `Remove ${item}`,
|
|
325
|
+
onclick: () => {
|
|
326
|
+
items.splice(index, 1);
|
|
327
|
+
delete serverErrors[key];
|
|
328
|
+
note = null;
|
|
329
|
+
ctx.rerender();
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
icon('x', 12),
|
|
333
|
+
),
|
|
334
|
+
),
|
|
335
|
+
),
|
|
336
|
+
h('div', { class: 'tool-add' }, input, button('Add', { variant: 'tall', onclick: add })),
|
|
337
|
+
),
|
|
338
|
+
h('div', { class: 'help' }, error ? `⚠ ${error}` : (spec.help ?? '')),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function move(id, delta) {
|
|
343
|
+
const index = draft.order.indexOf(id);
|
|
344
|
+
const target = index + delta;
|
|
345
|
+
if (target < 0 || target >= draft.order.length) return;
|
|
346
|
+
[draft.order[index], draft.order[target]] = [draft.order[target], draft.order[index]];
|
|
347
|
+
delete serverErrors.order;
|
|
348
|
+
note = null;
|
|
349
|
+
ctx.rerender();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function card(id, position) {
|
|
353
|
+
const agent = byId.get(id);
|
|
354
|
+
const entry = draft.agents[id];
|
|
355
|
+
if (!agent || !entry) return null;
|
|
356
|
+
const toggle = h('input', { type: 'checkbox', id: `agent-${id}-enabled`, checked: entry.enabled });
|
|
357
|
+
toggle.addEventListener('change', () => {
|
|
358
|
+
entry.enabled = toggle.checked;
|
|
359
|
+
touch(`${id}.enabled`);
|
|
360
|
+
});
|
|
361
|
+
const arrow = (label, delta, disabled) =>
|
|
362
|
+
h('button', { class: 'icon-btn tiny', type: 'button', 'aria-label': `${label} ${agent.name}`, title: label, disabled, onclick: () => move(id, delta) }, delta < 0 ? '↑' : '↓');
|
|
363
|
+
|
|
364
|
+
const simple = agent.fields.filter((spec) => spec.type !== 'list');
|
|
365
|
+
const lists = agent.fields.filter((spec) => spec.type === 'list');
|
|
366
|
+
return h(
|
|
367
|
+
'section',
|
|
368
|
+
{ class: 'card agent-card', 'aria-label': agent.name },
|
|
369
|
+
h(
|
|
370
|
+
'div',
|
|
371
|
+
{ class: 'card-head agent-head' },
|
|
372
|
+
h('span', { class: 'order' }, arrow('Move up', -1, position === 0), arrow('Move down', 1, position === draft.order.length - 1)),
|
|
373
|
+
h('h2', null, agent.name),
|
|
374
|
+
availabilityBadges(agent),
|
|
375
|
+
h('span', { class: 'spacer' }),
|
|
376
|
+
h('label', { class: 'switch', for: toggle.id }, toggle, 'Enabled'),
|
|
377
|
+
),
|
|
378
|
+
agent.notes.length > 0 ? h('ul', { class: 'notes' }, agent.notes.map((text) => h('li', null, text))) : null,
|
|
379
|
+
agent.reportsUsage ? h('div', { class: 'usage-line', id: `agent-${id}-usage` }, usageLine(agent)) : null,
|
|
380
|
+
h('div', { class: 'usage-line' }, statsLine(agent.stats)),
|
|
381
|
+
h(
|
|
382
|
+
'div',
|
|
383
|
+
{ class: 'fields' },
|
|
384
|
+
textField(id, 'Run at once', 'maxConcurrent', entry.maxConcurrent, {
|
|
385
|
+
help: 'How many issues this agent may work on at the same time. 0 pauses it.',
|
|
386
|
+
numeric: true,
|
|
387
|
+
validate: concurrencyError,
|
|
388
|
+
onInput: (value) => (entry.maxConcurrent = value),
|
|
389
|
+
}),
|
|
390
|
+
textField(id, 'Binary', 'bin', entry.bin, {
|
|
391
|
+
help: 'Command name or absolute path. Empty uses the default.',
|
|
392
|
+
placeholder: agent.defaultBin,
|
|
393
|
+
onInput: (value) => (entry.bin = value),
|
|
394
|
+
}),
|
|
395
|
+
agent.reportsUsage
|
|
396
|
+
? [
|
|
397
|
+
textField(id, 'Pause above (% of plan)', 'maxUsagePercent', entry.maxUsagePercent, {
|
|
398
|
+
help: 'Take no new issues once any plan window reaches this. Empty never pauses.',
|
|
399
|
+
numeric: true,
|
|
400
|
+
validate: usageLimitError,
|
|
401
|
+
onInput: (value) => (entry.maxUsagePercent = value),
|
|
402
|
+
}),
|
|
403
|
+
overageField(id, entry),
|
|
404
|
+
]
|
|
405
|
+
: null,
|
|
406
|
+
simple.map((spec) =>
|
|
407
|
+
spec.type === 'select'
|
|
408
|
+
? selectField(id, spec, entry)
|
|
409
|
+
: textField(id, spec.label, `settings.${spec.key}`, entry.settings[spec.key] ?? '', {
|
|
410
|
+
help: spec.help,
|
|
411
|
+
onInput: (value) => (entry.settings[spec.key] = value),
|
|
412
|
+
}),
|
|
413
|
+
),
|
|
414
|
+
lists.map((spec) => listField(id, spec, entry)),
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
refreshBar();
|
|
420
|
+
return [
|
|
421
|
+
h(
|
|
422
|
+
'div',
|
|
423
|
+
{ class: 'config' },
|
|
424
|
+
banner(
|
|
425
|
+
'run',
|
|
426
|
+
null,
|
|
427
|
+
'An issue goes to the first enabled, installed agent with a free slot, in the order below. Foreman can tell that a CLI is installed, not that it is signed in; a sign-in problem shows up as a failed run.',
|
|
428
|
+
button('Check again', { onclick: recheck }),
|
|
429
|
+
true,
|
|
430
|
+
),
|
|
431
|
+
draft.order.map(card),
|
|
432
|
+
),
|
|
433
|
+
saveBar,
|
|
434
|
+
];
|
|
435
|
+
}
|