@opsee/cli 0.11.9
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/README.md +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { isVendor, VENDORS, type Vendor } from "./vendor.js";
|
|
5
|
+
import type { AccountStore } from "./account-store.js";
|
|
6
|
+
import { oneLine, printableOneLine } from "./core/text.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An Account (see ../../CONTEXT.md): one identity for one vendor, registered by path or key
|
|
10
|
+
* reference only. What is stored here is the whole of what the Foreman knows about a credential;
|
|
11
|
+
* the credential itself is never read (ADR-0013), and account-boundary.test.ts holds it to that.
|
|
12
|
+
*/
|
|
13
|
+
export type AccountType = "subscription" | "api_key";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Concurrency cap a new Account gets (story 27). Deliberately low: two Slots is one implementer and
|
|
17
|
+
* the reserved Verifier Slot beside it (core/scheduler.ts), which is what one person at one keyboard
|
|
18
|
+
* looks like to the vendor. Raising it is always an explicit act — `--cap` at `foreman account add`,
|
|
19
|
+
* or `foreman account set <name> --cap <n>` afterwards — never something the Foreman does itself.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_CAP = 2;
|
|
22
|
+
|
|
23
|
+
/** The most Slots one Account may be given. An Account is one vendor identity (ADR-0013) and the
|
|
24
|
+
* cap is what that identity may run at once, so a number far past this is a typo — a `--cap 100`
|
|
25
|
+
* meant as `--cap 10`, a millisecond value in the wrong flag — much more often than an intent, and
|
|
26
|
+
* nothing below would refuse it: the Run would obligingly put a hundred Workers on one login. */
|
|
27
|
+
export const MAX_CAP = 32;
|
|
28
|
+
|
|
29
|
+
/** The least a stall timeout may be set to. Below this a Worker that is only thinking, or running
|
|
30
|
+
* the project's test suite, is stopped as `stalled` every time, and the Task spends its attempts
|
|
31
|
+
* (`DEFAULT_ATTEMPT_RETRIES`) in seconds without a single turn having had a chance. */
|
|
32
|
+
export const MIN_STALL_TIMEOUT_MS = 10_000;
|
|
33
|
+
|
|
34
|
+
/** Set while the vendor has reported a rate limit (story 30): when the pause lifts, and what the
|
|
35
|
+
* vendor said. Written by the Run that saw the limit (core/run.ts) through the AccountStore, read
|
|
36
|
+
* back by every Run that fills Slots, so a pause one Foreman hit is a pause the next one honours. */
|
|
37
|
+
export interface PausedState {
|
|
38
|
+
until: string;
|
|
39
|
+
reason?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set once the Account's credential is judged dead (OPS-288): when it happened, and the vendor's
|
|
44
|
+
* own words or the Foreman's for why. Unlike `paused` it carries no reset, because there is nothing
|
|
45
|
+
* to wait for — a quarantine is left only when a human re-adds the Account or clears it by hand
|
|
46
|
+
* (`clearQuarantine`, `opsee foreman account resume`).
|
|
47
|
+
*/
|
|
48
|
+
export interface QuarantinedState {
|
|
49
|
+
at: string;
|
|
50
|
+
reason: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Credential failures in a row on this Account, and what the last one said (OPS-288). Reset by any
|
|
55
|
+
* turn that completes on it, so this counts a streak and never a total.
|
|
56
|
+
*
|
|
57
|
+
* In the store rather than in a Run because the fact outlives the Run, for the same reason `paused`
|
|
58
|
+
* is: a dead login is still dead after a restart, and the second failure that proves it may well be
|
|
59
|
+
* the next Foreman's. Written only while a streak is open, so an Account that is working pays no
|
|
60
|
+
* store write per turn.
|
|
61
|
+
*/
|
|
62
|
+
export interface CredentialFailures {
|
|
63
|
+
count: number;
|
|
64
|
+
at: string;
|
|
65
|
+
reason: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How many credential failures in a row quarantine an Account.
|
|
70
|
+
*
|
|
71
|
+
* Two, not one. Every pattern the adapters match is a guess about a vendor's prose, and a false
|
|
72
|
+
* positive costs more than a false negative here: it pulls a working Account out of rotation until
|
|
73
|
+
* a human re-adds it, which overnight is the whole Lane, where a miss only spends the Task's retry
|
|
74
|
+
* cap. Two consecutive failures on one Account is a shape a transient 401 does not have; a genuinely
|
|
75
|
+
* dead login has it within seconds, because the second Task is dispatched as soon as a Slot frees.
|
|
76
|
+
*
|
|
77
|
+
* A `CredentialError` does not count against this and quarantines at once: it is not a guess about
|
|
78
|
+
* prose but a fact the Foreman established about its own Account record before spawning anything.
|
|
79
|
+
*/
|
|
80
|
+
export const CREDENTIAL_FAILURES_BEFORE_QUARANTINE = 2;
|
|
81
|
+
|
|
82
|
+
/** A lift a human made that no Run has reported yet: when, and which state it ended. */
|
|
83
|
+
export interface ResumedState {
|
|
84
|
+
at: string;
|
|
85
|
+
/** What the Account was in when the human lifted it, for the event's reason line. */
|
|
86
|
+
from: "paused" | "quarantined" | "both";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** How long an Account is Paused when the vendor refused the turn without saying when the limit
|
|
90
|
+
* lifts. Long enough that the Foreman is not straight back at a limit it cannot see the shape of,
|
|
91
|
+
* short enough that a night is not lost to one refusal whose window was five minutes. */
|
|
92
|
+
export const DEFAULT_PAUSE_MS = 60 * 60_000;
|
|
93
|
+
|
|
94
|
+
/** The shortest a pause may be. A window of zero is not a short pause, it is no pause: `until`
|
|
95
|
+
* would equal the moment the limit was seen, which `pausedUntil` reads as an Account that was never
|
|
96
|
+
* Paused, and the Run would go straight back at the limit that had just refused it. */
|
|
97
|
+
export const MIN_PAUSE_MS = 1_000;
|
|
98
|
+
|
|
99
|
+
/** The furthest ahead a vendor's reported reset is taken at face value. `resetAt` is parsed out of
|
|
100
|
+
* the vendor's own text in places (claude-worker-adapter.ts `resetAtFromLimitText`), so a stray
|
|
101
|
+
* year in a message would otherwise Pause an Account past any night the Foreman is running. */
|
|
102
|
+
export const MAX_PAUSE_MS = 24 * 60 * 60_000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* When the Account's pause lifts, or undefined when it is not Paused at `now` (Paused, CONTEXT.md).
|
|
106
|
+
*
|
|
107
|
+
* Resume is time-based and takes no operator action: a stored pause whose reset has passed is not
|
|
108
|
+
* a pause, so the tick after it lapses fills the Account's Slots again by itself (story 30).
|
|
109
|
+
*/
|
|
110
|
+
export function pausedUntil(account: Pick<Account, "paused">, now: number): Date | undefined {
|
|
111
|
+
if (!account.paused) return undefined;
|
|
112
|
+
const until = new Date(account.paused.until);
|
|
113
|
+
// An `until` that is not a date at all — a hand-edited accounts file — would compare false
|
|
114
|
+
// against every clock and Pause the Account for ever; it is read as no pause instead.
|
|
115
|
+
if (!Number.isFinite(until.getTime())) return undefined;
|
|
116
|
+
return until.getTime() > now ? until : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isPaused(account: Pick<Account, "paused">, now: number): boolean {
|
|
120
|
+
return pausedUntil(account, now) !== undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** When a rate limit seen at `now` lifts: the vendor's own reset when it gave one that is ahead of
|
|
124
|
+
* now and inside `MAX_PAUSE_MS`, else `defaultMs` from now. A reset the vendor reported as already
|
|
125
|
+
* past is no reset: the turn was refused all the same, so the default window is used rather than
|
|
126
|
+
* going straight back at the limit. */
|
|
127
|
+
export function pauseUntilFrom(now: number, resetAt: string | undefined, defaultMs: number = DEFAULT_PAUSE_MS): Date {
|
|
128
|
+
const reported = resetAt === undefined ? Number.NaN : new Date(resetAt).getTime();
|
|
129
|
+
if (!Number.isFinite(reported) || reported <= now) return new Date(now + defaultMs);
|
|
130
|
+
return new Date(Math.min(reported, now + MAX_PAUSE_MS));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Marks the Account Paused until `until` (story 30). The store is re-read rather than written from
|
|
134
|
+
* a snapshot, so a cap this process set in between survives; an Account that has since been removed
|
|
135
|
+
* is a no-op and says so by returning undefined.
|
|
136
|
+
*
|
|
137
|
+
* Re-reading is not a lock: load and save are two calls, so a `foreman account set --cap` in
|
|
138
|
+
* another terminal between them is overwritten whole (FileAccountStore.save says what its atomic
|
|
139
|
+
* rename does and does not buy). Within one process — the daemon, where pauses are written — the
|
|
140
|
+
* two calls are synchronous with nothing between them, and the guarantee holds. */
|
|
141
|
+
export function pauseAccount(store: AccountStore, name: string, until: Date, reason: string): Account | undefined {
|
|
142
|
+
return updateAccount(store, name, (account) => ({ ...account, paused: { until: until.toISOString(), reason } }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Clears the Paused state. Resume is time-based, so this is for the operator's own hand and for a
|
|
146
|
+
* Run tidying a pause it has already outlived; nothing waits on it. */
|
|
147
|
+
export function resumeAccount(store: AccountStore, name: string): Account | undefined {
|
|
148
|
+
return updateAccount(store, name, (account) => ({ ...account, paused: null }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** True while the Account is quarantined (OPS-288). No `now`, unlike `isPaused`: a quarantine has no
|
|
152
|
+
* reset, so nothing about it changes with the clock. */
|
|
153
|
+
export function isQuarantined(account: Pick<Account, "quarantined">): boolean {
|
|
154
|
+
return account.quarantined != null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Quarantines the Account and closes its credential-failure streak, since the streak has done its
|
|
158
|
+
* job. Re-reads the store for the reason `pauseAccount` gives; an Account that has since been
|
|
159
|
+
* removed is a no-op and says so by returning undefined. */
|
|
160
|
+
export function quarantineAccount(store: AccountStore, name: string, at: Date, reason: string): Account | undefined {
|
|
161
|
+
return updateAccount(store, name, (account) => ({ ...account, quarantined: { at: at.toISOString(), reason }, credentialFailures: undefined }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Lifts a quarantine and forgets the streak behind it: what `opsee foreman account resume` does,
|
|
165
|
+
* and what re-registering the Account achieves by starting a fresh record. */
|
|
166
|
+
export function clearQuarantine(store: AccountStore, name: string): Account | undefined {
|
|
167
|
+
return updateAccount(store, name, (account) => ({ ...account, quarantined: null, credentialFailures: undefined }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Stamps the moment a human brought the Account back, for the next Run to put on the Run Record.
|
|
171
|
+
* Only the newest lift is kept: a second `account resume` before any Run has reported the first
|
|
172
|
+
* describes the same Account being available, and one event says that as well as two. */
|
|
173
|
+
export function noteResumed(store: AccountStore, name: string, at: Date, from: ResumedState["from"]): Account | undefined {
|
|
174
|
+
return updateAccount(store, name, (account) => ({ ...account, resumedAt: { at: at.toISOString(), from } }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Forgets a stamped lift once a Run has reported it. */
|
|
178
|
+
export function clearResumed(store: AccountStore, name: string): Account | undefined {
|
|
179
|
+
return updateAccount(store, name, (account) => ({ ...account, resumedAt: undefined }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Records one more credential failure in a row and returns the Account with the new count, so the
|
|
184
|
+
* caller can see whether `CREDENTIAL_FAILURES_BEFORE_QUARANTINE` is reached. Does not quarantine:
|
|
185
|
+
* deciding that is the Run's, which has the log and the Run Record to write alongside it.
|
|
186
|
+
*/
|
|
187
|
+
export function noteCredentialFailure(store: AccountStore, name: string, at: Date, reason: string): Account | undefined {
|
|
188
|
+
return updateAccount(store, name, (account) => ({
|
|
189
|
+
...account,
|
|
190
|
+
credentialFailures: { count: (account.credentialFailures?.count ?? 0) + 1, at: at.toISOString(), reason },
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Forgets an open credential-failure streak after a turn completed on the Account: the failures
|
|
195
|
+
* this counts are consecutive ones, and a turn the vendor accepted proves the credential is live.
|
|
196
|
+
* A no-op — and, deliberately, no store write at all — when there is no streak to clear, which is
|
|
197
|
+
* every turn of a healthy Account. */
|
|
198
|
+
export function clearCredentialFailures(store: AccountStore, name: string): Account | undefined {
|
|
199
|
+
const account = store.load().find((a) => a.name === name);
|
|
200
|
+
if (!account?.credentialFailures) return account;
|
|
201
|
+
return updateAccount(store, name, (a) => ({ ...a, credentialFailures: undefined }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function updateAccount(store: AccountStore, name: string, change: (account: Account) => Account): Account | undefined {
|
|
205
|
+
const existing = store.load();
|
|
206
|
+
const account = existing.find((a) => a.name === name);
|
|
207
|
+
if (!account) return undefined;
|
|
208
|
+
const updated = change(account);
|
|
209
|
+
store.save(existing.map((a) => (a.name === name ? updated : a)));
|
|
210
|
+
return updated;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface AccountBase {
|
|
214
|
+
name: string;
|
|
215
|
+
vendor: Vendor;
|
|
216
|
+
cap: number;
|
|
217
|
+
paused: PausedState | null;
|
|
218
|
+
/** Set while the Account's credential is dead (OPS-288). Optional rather than nullable, unlike
|
|
219
|
+
* `paused`, so that every accounts file written before this existed reads as a healthy Account
|
|
220
|
+
* instead of being refused for a missing field. */
|
|
221
|
+
quarantined?: QuarantinedState | null;
|
|
222
|
+
/** The open credential-failure streak, absent when there is none (`noteCredentialFailure`). */
|
|
223
|
+
credentialFailures?: CredentialFailures;
|
|
224
|
+
/**
|
|
225
|
+
* When a human last brought this Account back by hand, until a Run has put that on a Run Record
|
|
226
|
+
* (`RunAccountEvent`, state `active`).
|
|
227
|
+
*
|
|
228
|
+
* Stamped here rather than appended there because `foreman account resume` has no Initiative: it
|
|
229
|
+
* is a local command over the accounts file, and a Run Record belongs to an Initiative. So the
|
|
230
|
+
* moment is recorded where it happens and reported by the next Run that builds a Lane on this
|
|
231
|
+
* Account, carrying this timestamp as the event's `occurredAt` — which is what that field is for,
|
|
232
|
+
* the Run Record already expecting facts that reach it late.
|
|
233
|
+
*
|
|
234
|
+
* Cleared once reported. Until then the lift is simply missing from the Record, which is honest:
|
|
235
|
+
* nothing has read it yet.
|
|
236
|
+
*/
|
|
237
|
+
resumedAt?: ResumedState;
|
|
238
|
+
/** Cap on agentic turns within one Worker turn on this Account, where the vendor supports one
|
|
239
|
+
* (story 33). Unset leaves it to the Run's own `--max-turns`, and to the vendor's default past
|
|
240
|
+
* that. A subscription that bills by turn is what this is for. */
|
|
241
|
+
maxTurns?: number;
|
|
242
|
+
/** How long a Worker on this Account may go silent before its turn is stopped as `stalled`
|
|
243
|
+
* (story 33). Unset falls back to the Run's `--stall-timeout` and then to the CLI's default. */
|
|
244
|
+
stallTimeoutMs?: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A subscription the user has already signed into, isolated in its own config directory; the
|
|
248
|
+
* Worker Adapter points the vendor's config-directory variable at it. */
|
|
249
|
+
export interface SubscriptionAccount extends AccountBase {
|
|
250
|
+
type: "subscription";
|
|
251
|
+
configDir: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** An API key named by reference. In v1 `keyRef` is the name of an environment variable the
|
|
255
|
+
* Worker is launched with; its value never passes through the Foreman. */
|
|
256
|
+
export interface ApiKeyAccount extends AccountBase {
|
|
257
|
+
type: "api_key";
|
|
258
|
+
keyRef: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export type Account = SubscriptionAccount | ApiKeyAccount;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The only file-system access registration is allowed: metadata of the directory itself. There is
|
|
265
|
+
* deliberately no readdir, readFile or open on this seam, so the type system already rules out
|
|
266
|
+
* looking inside; the boundary test rules it out for the real fs behind it too.
|
|
267
|
+
*/
|
|
268
|
+
export interface ConfigDirFs {
|
|
269
|
+
statSync(path: string): { isDirectory(): boolean };
|
|
270
|
+
accessSync(path: string, mode: number): void;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A registration the user got wrong; reported as a plain message, never a stack. */
|
|
274
|
+
export class AccountError extends Error {}
|
|
275
|
+
|
|
276
|
+
/** What `account add` collects from the command line, before validation. */
|
|
277
|
+
export interface AccountSpec {
|
|
278
|
+
vendor: string;
|
|
279
|
+
name?: string;
|
|
280
|
+
configDir?: string;
|
|
281
|
+
keyRef?: string;
|
|
282
|
+
cap?: number;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
286
|
+
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
287
|
+
|
|
288
|
+
function expandHome(path: string): string {
|
|
289
|
+
return path === "~" || path.startsWith("~/") ? homedir() + path.slice(1) : path;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function describeSource(account: Account): string {
|
|
293
|
+
return account.type === "subscription" ? account.configDir : `$${account.keyRef}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Checks that `dir` is a directory the user can enter and read, touching nothing but the
|
|
297
|
+
* directory entry itself. Returns the absolute path that will be stored. */
|
|
298
|
+
export function validateConfigDir(fs: ConfigDirFs, dir: string): string {
|
|
299
|
+
const path = resolve(expandHome(dir));
|
|
300
|
+
let stat: { isDirectory(): boolean };
|
|
301
|
+
try {
|
|
302
|
+
stat = fs.statSync(path);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
305
|
+
throw new AccountError(`Config directory does not exist: ${path}`);
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (!stat.isDirectory()) {
|
|
310
|
+
throw new AccountError(`Config directory is not a directory: ${path}`);
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
fs.accessSync(path, constants.R_OK | constants.X_OK);
|
|
314
|
+
} catch {
|
|
315
|
+
throw new AccountError(`Config directory is not readable by you: ${path}`);
|
|
316
|
+
}
|
|
317
|
+
return path;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Validates the spec against the file system and the existing Accounts, then stores the new one.
|
|
321
|
+
* Names are unique across vendors; a directory or key may back only one Account per vendor. */
|
|
322
|
+
export function registerAccount(store: AccountStore, fs: ConfigDirFs, spec: AccountSpec): Account {
|
|
323
|
+
if (!isVendor(spec.vendor)) {
|
|
324
|
+
throw new AccountError(`Unknown vendor "${spec.vendor}". Vendors: ${VENDORS.join(", ")}`);
|
|
325
|
+
}
|
|
326
|
+
const vendor: Vendor = spec.vendor;
|
|
327
|
+
if ((spec.configDir === undefined) === (spec.keyRef === undefined)) {
|
|
328
|
+
throw new AccountError("An Account needs either --config-dir (subscription) or --key-env (API key), not both");
|
|
329
|
+
}
|
|
330
|
+
const cap = spec.cap ?? DEFAULT_CAP;
|
|
331
|
+
if (!Number.isInteger(cap) || cap < 1) {
|
|
332
|
+
throw new AccountError("--cap must be a positive integer");
|
|
333
|
+
}
|
|
334
|
+
if (cap > MAX_CAP) {
|
|
335
|
+
throw new AccountError(`--cap is at most ${MAX_CAP}; a cap is how many Workers one vendor identity runs at once`);
|
|
336
|
+
}
|
|
337
|
+
const name = spec.name ?? vendor;
|
|
338
|
+
if (!NAME_PATTERN.test(name)) {
|
|
339
|
+
throw new AccountError("Account names are letters, digits, '.', '_' and '-' and start with a letter or digit");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const existing = store.load();
|
|
343
|
+
if (existing.some((a) => a.name === name)) {
|
|
344
|
+
throw new AccountError(
|
|
345
|
+
spec.name === undefined
|
|
346
|
+
? `An Account named "${name}" already exists; pass --name to register a second ${vendor} Account`
|
|
347
|
+
: `An Account named "${name}" already exists`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// A registration is a fresh record, so re-adding an Account is how a human lifts a quarantine
|
|
352
|
+
// (OPS-288): the state and its streak are not carried over from anything.
|
|
353
|
+
const base = { name, vendor, cap, paused: null, quarantined: null };
|
|
354
|
+
let account: Account;
|
|
355
|
+
if (spec.configDir !== undefined) {
|
|
356
|
+
const configDir = validateConfigDir(fs, spec.configDir);
|
|
357
|
+
account = { ...base, type: "subscription", configDir };
|
|
358
|
+
} else {
|
|
359
|
+
const keyRef = spec.keyRef!;
|
|
360
|
+
if (!ENV_NAME_PATTERN.test(keyRef)) {
|
|
361
|
+
// Deliberately not echoed: the likeliest mistake is pasting the key itself here.
|
|
362
|
+
throw new AccountError("--key-env must be an environment variable name (letters, digits and '_'), not the key");
|
|
363
|
+
}
|
|
364
|
+
account = { ...base, type: "api_key", keyRef };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// One login or key is one identity. Two Accounts on it would each carry their own cap and so
|
|
368
|
+
// double the concurrency the vendor sees on that identity, which is what per-Account caps exist
|
|
369
|
+
// to keep person-shaped (ADR-0013).
|
|
370
|
+
const twin = existing.find((a) => a.vendor === vendor && describeSource(a) === describeSource(account));
|
|
371
|
+
if (twin) {
|
|
372
|
+
throw new AccountError(`${describeSource(account)} is already registered as Account "${twin.name}" for ${vendor}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
store.save([...existing, account]);
|
|
376
|
+
return account;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** What `account set` may change: the scheduling limits, never the identity behind the Account.
|
|
380
|
+
* A field left out is left alone; there is deliberately no way to clear one back to unset here,
|
|
381
|
+
* since every value has a meaning and "unset" is only ever the shape a fresh Account starts in. */
|
|
382
|
+
export interface AccountSettings {
|
|
383
|
+
cap?: number;
|
|
384
|
+
maxTurns?: number;
|
|
385
|
+
stallTimeoutMs?: number;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Changes an existing Account's scheduling limits (story 27): the cap that decides how many Slots
|
|
389
|
+
* it has, and the per-turn limits its Workers run under. Raising a cap is this call and nothing
|
|
390
|
+
* else — the Foreman never widens one on its own. */
|
|
391
|
+
export function setAccountLimits(store: AccountStore, name: string, settings: AccountSettings): Account {
|
|
392
|
+
const existing = store.load();
|
|
393
|
+
const account = existing.find((a) => a.name === name);
|
|
394
|
+
if (!account) {
|
|
395
|
+
throw new AccountError(`No Account named "${name}"`);
|
|
396
|
+
}
|
|
397
|
+
if (settings.cap === undefined && settings.maxTurns === undefined && settings.stallTimeoutMs === undefined) {
|
|
398
|
+
throw new AccountError("foreman account set needs at least one of --cap, --max-turns, --stall-timeout");
|
|
399
|
+
}
|
|
400
|
+
for (const [flag, value] of [
|
|
401
|
+
["--cap", settings.cap],
|
|
402
|
+
["--max-turns", settings.maxTurns],
|
|
403
|
+
["--stall-timeout", settings.stallTimeoutMs],
|
|
404
|
+
] as const) {
|
|
405
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 1)) {
|
|
406
|
+
throw new AccountError(`${flag} must be a positive integer`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Both ends of the range a number can be wrong at: a cap past what one identity should ever run,
|
|
410
|
+
// and a stall timeout so short that every turn is stopped as stalled.
|
|
411
|
+
if (settings.cap !== undefined && settings.cap > MAX_CAP) {
|
|
412
|
+
throw new AccountError(`--cap is at most ${MAX_CAP}; a cap is how many Workers one vendor identity runs at once`);
|
|
413
|
+
}
|
|
414
|
+
if (settings.stallTimeoutMs !== undefined && settings.stallTimeoutMs < MIN_STALL_TIMEOUT_MS) {
|
|
415
|
+
throw new AccountError(`--stall-timeout is at least ${MIN_STALL_TIMEOUT_MS}ms; below that a Worker that is only thinking is stopped as stalled`);
|
|
416
|
+
}
|
|
417
|
+
const updated: Account = {
|
|
418
|
+
...account,
|
|
419
|
+
cap: settings.cap ?? account.cap,
|
|
420
|
+
maxTurns: settings.maxTurns ?? account.maxTurns,
|
|
421
|
+
stallTimeoutMs: settings.stallTimeoutMs ?? account.stallTimeoutMs,
|
|
422
|
+
};
|
|
423
|
+
store.save(existing.map((a) => (a === account ? updated : a)));
|
|
424
|
+
return updated;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function removeAccount(store: AccountStore, name: string): Account {
|
|
428
|
+
const existing = store.load();
|
|
429
|
+
const account = existing.find((a) => a.name === name);
|
|
430
|
+
if (!account) {
|
|
431
|
+
throw new AccountError(`No Account named "${name}"`);
|
|
432
|
+
}
|
|
433
|
+
store.save(existing.filter((a) => a !== account));
|
|
434
|
+
return account;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** A fixed-width table, one line per Account. Keys appear by variable name only. The STATE column
|
|
438
|
+
* is the Paused state with its reset (story 30); a pause whose reset has passed reads as active,
|
|
439
|
+
* because it is — resume is time-based — with the lapsed reset kept in view so a reader can tell a
|
|
440
|
+
* rate-limited Account that has just come back from one that was never Paused.
|
|
441
|
+
*
|
|
442
|
+
* Quarantine (OPS-288) is reported ahead of a pause, and swallows it: an Account may well have been
|
|
443
|
+
* Paused before its credential died, and of the two facts only one still needs the reader to do
|
|
444
|
+
* something. It names the remedy for the same reason, since nothing lifts it by itself. */
|
|
445
|
+
export function formatAccountTable(accounts: Account[], now: number = Date.now()): string[] {
|
|
446
|
+
const state = (a: Account): string => {
|
|
447
|
+
if (a.quarantined) return `quarantined since ${a.quarantined.at} (${printableOneLine(a.quarantined.reason)}); re-add it or: opsee foreman account resume ${a.name}`;
|
|
448
|
+
const until = pausedUntil(a, now);
|
|
449
|
+
if (until) return `paused until ${a.paused!.until}${a.paused!.reason ? ` (${oneLine(a.paused!.reason)})` : ""}`;
|
|
450
|
+
return a.paused ? `active (pause lapsed ${a.paused.until})` : "active";
|
|
451
|
+
};
|
|
452
|
+
const rows = [
|
|
453
|
+
["NAME", "VENDOR", "TYPE", "SOURCE", "CAP", "STATE"],
|
|
454
|
+
...accounts.map((a) => [a.name, a.vendor, a.type, describeSource(a), String(a.cap), state(a)]),
|
|
455
|
+
];
|
|
456
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
|
|
457
|
+
return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell.padEnd(widths[i]))).join(" "));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function describeAccount(account: Account): string {
|
|
461
|
+
const source = account.type === "subscription" ? `config dir ${account.configDir}` : `key from $${account.keyRef}`;
|
|
462
|
+
return `${account.type} Account "${account.name}" for ${account.vendor} (${source}, ${describeLimits(account)})`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The Account's scheduling limits in words: the cap and how its Slots divide, plus the per-turn
|
|
466
|
+
* limits when it sets its own. */
|
|
467
|
+
export function describeLimits(account: Account): string {
|
|
468
|
+
const slots = account.cap > 1 ? `${account.cap - 1} implementer${account.cap - 1 === 1 ? "" : "s"} + 1 reserved for Verifiers` : "1 implementer, no Slot to reserve for Verifiers";
|
|
469
|
+
const perTurn = [
|
|
470
|
+
account.maxTurns === undefined ? "" : `max turns ${account.maxTurns}`,
|
|
471
|
+
account.stallTimeoutMs === undefined ? "" : `stall timeout ${account.stallTimeoutMs}ms`,
|
|
472
|
+
].filter(Boolean);
|
|
473
|
+
return `cap ${account.cap} (${slots})${perTurn.length ? `, ${perTurn.join(", ")}` : ""}`;
|
|
474
|
+
}
|