@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,488 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import type { Account } from "./account.js";
|
|
3
|
+
import { PROCESS_GROUPS, signalProcessGroup, stopProcessGroup } from "./process-group.js";
|
|
4
|
+
import type { AdapterEvent, TurnFailureReason, TurnHandle, TurnSandbox } from "./worker-adapter.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What every vendor Worker Adapter shares below the vendor protocol: the process seam, the
|
|
8
|
+
* environment that lets the Account alone name the Worker's identity (ADR-0013), and one turn's
|
|
9
|
+
* lifecycle over a child process, from spawn through line-by-line stdout to the exit that ends the
|
|
10
|
+
* turn without a result. A vendor adapter supplies the command line, the two variable names its
|
|
11
|
+
* binary reads, and a `handleMessage` that turns its own stream protocol into adapter events;
|
|
12
|
+
* everything about pipes, stall timers, kills and exit codes lives here once.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** How the process seam sees a running Worker; real processes and fixture replayers both fit. */
|
|
16
|
+
export interface SpawnedProcess {
|
|
17
|
+
stdout: AsyncIterable<string | Uint8Array>;
|
|
18
|
+
stderr: AsyncIterable<string | Uint8Array>;
|
|
19
|
+
/** Resolves when the process ends; rejects when it could not be started at all. */
|
|
20
|
+
exited: Promise<{ code: number | null; signal: string | null }>;
|
|
21
|
+
/** Starts stopping the Worker and what it spawned; the exit shows up on `exited`. */
|
|
22
|
+
kill(): void;
|
|
23
|
+
/** The OS pid, when there is a real process behind this (the Process Table records it). */
|
|
24
|
+
pid?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SpawnOptions {
|
|
28
|
+
cwd: string;
|
|
29
|
+
env: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ProcessSpawner = (command: string, args: string[], options: SpawnOptions) => SpawnedProcess;
|
|
33
|
+
|
|
34
|
+
/** How long a killed Worker gets to leave after SIGTERM before its whole group is SIGKILLed. */
|
|
35
|
+
export const KILL_GRACE_MS = 5_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The real seam: a child process with stdin closed and both output streams piped, in its own
|
|
39
|
+
* process group so that `kill()` reaches the shells the Worker started underneath it (a Codex
|
|
40
|
+
* `command_execution` runs under `/bin/zsh -lc`; a Claude Code `Bash` tool likewise). `stop()` must
|
|
41
|
+
* never leave a Worker's children running in the Workspace. `graceMs` is how long the group gets
|
|
42
|
+
* after SIGTERM; the tests shorten it.
|
|
43
|
+
*/
|
|
44
|
+
export function makeProcessSpawner(graceMs = KILL_GRACE_MS): ProcessSpawner {
|
|
45
|
+
return (command, args, options) => {
|
|
46
|
+
const child = nodeSpawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], detached: PROCESS_GROUPS });
|
|
47
|
+
let done = false;
|
|
48
|
+
const exited = new Promise<{ code: number | null; signal: string | null }>((resolve, reject) => {
|
|
49
|
+
child.once("error", (error) => {
|
|
50
|
+
done = true;
|
|
51
|
+
reject(error);
|
|
52
|
+
});
|
|
53
|
+
child.once("exit", (code, signal) => {
|
|
54
|
+
done = true;
|
|
55
|
+
resolve({ code, signal });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
// An unobserved rejection here would crash the process before the adapter reports launch_failed.
|
|
59
|
+
const settled = exited.catch(() => undefined);
|
|
60
|
+
let stopping: Promise<void> | undefined;
|
|
61
|
+
const stop = () => {
|
|
62
|
+
stopping ??= stopProcessGroup(child, settled, graceMs, () => done);
|
|
63
|
+
return stopping;
|
|
64
|
+
};
|
|
65
|
+
trackLiveProcess({ child, settled, stop });
|
|
66
|
+
return { stdout: child.stdout!, stderr: child.stderr!, exited, kill: () => void stop(), pid: child.pid };
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const spawnProcess: ProcessSpawner = makeProcessSpawner();
|
|
71
|
+
|
|
72
|
+
/** A detached child this process must not leave behind: a Worker, or the app a Verifier round
|
|
73
|
+
* started (run-recipe.ts `startApp`). */
|
|
74
|
+
export interface LiveProcess {
|
|
75
|
+
child: ReturnType<typeof nodeSpawn>;
|
|
76
|
+
/** Resolves once the child has exited; must never reject. */
|
|
77
|
+
settled: Promise<unknown>;
|
|
78
|
+
stop: () => Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A detached child outlives a parent that dies, so this process keeps a registry of the Workers
|
|
83
|
+
* (and the Verifier's apps) it has running and stops them when it goes away itself. This is not
|
|
84
|
+
* the Process Table (that is the durable, per-machine record the daemon keeps); it is the
|
|
85
|
+
* in-memory list of what this process spawned and has not yet seen exit. Two hooks, installed
|
|
86
|
+
* once on the first real spawn:
|
|
87
|
+
*
|
|
88
|
+
* - On SIGINT, SIGTERM or SIGHUP: stop every group (SIGTERM, then SIGKILL after the grace period),
|
|
89
|
+
* wait for them, then re-raise the signal, so a command without a handler of its own still ends
|
|
90
|
+
* the way Node would have ended it, only after its Workers are gone.
|
|
91
|
+
* - On `exit` (normal completion or `process.exit`): there is no time to be graceful, so anything
|
|
92
|
+
* still alive gets SIGKILL outright.
|
|
93
|
+
*/
|
|
94
|
+
const liveWorkers = new Set<LiveProcess>();
|
|
95
|
+
let hooksInstalled = false;
|
|
96
|
+
|
|
97
|
+
/** Puts a detached child on the registry until it exits; installs the hooks the first time. */
|
|
98
|
+
export function trackLiveProcess(worker: LiveProcess): void {
|
|
99
|
+
liveWorkers.add(worker);
|
|
100
|
+
worker.settled.then(() => liveWorkers.delete(worker));
|
|
101
|
+
if (hooksInstalled) return;
|
|
102
|
+
hooksInstalled = true;
|
|
103
|
+
installLiveWorkerHooks(process);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How many detached children this process still has on its registry; for tests. */
|
|
107
|
+
export function liveProcessCount(): number {
|
|
108
|
+
return liveWorkers.size;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The subset of `process` the hooks use, so a test can drive them with an emitter of its own. */
|
|
112
|
+
export interface HookableProcess {
|
|
113
|
+
pid: number;
|
|
114
|
+
on(event: NodeJS.Signals | "exit", listener: () => void): unknown;
|
|
115
|
+
listenerCount(event: NodeJS.Signals): number;
|
|
116
|
+
kill(pid: number, signal: NodeJS.Signals): unknown;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Installs the exit and signal hooks described on `liveWorkers`. Exported for the test only.
|
|
121
|
+
*
|
|
122
|
+
* Whether anyone else was listening is read at signal time, before the asynchronous stop: a
|
|
123
|
+
* daemon's `once` listener has already removed itself by the time the stop finishes, and counting
|
|
124
|
+
* then would re-raise the signal into the daemon mid-shutdown. The listener stays installed, so a
|
|
125
|
+
* repeat signal during the grace period does not fall through to Node's default exit (which skips
|
|
126
|
+
* the `exit` hook and the pending SIGKILL escalation); it SIGKILLs every live group and re-raises
|
|
127
|
+
* by the same rule.
|
|
128
|
+
*/
|
|
129
|
+
export function installLiveWorkerHooks(proc: HookableProcess): void {
|
|
130
|
+
proc.on("exit", () => {
|
|
131
|
+
for (const w of liveWorkers) signalProcessGroup(w.child, "SIGKILL");
|
|
132
|
+
});
|
|
133
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
134
|
+
let stopping = false;
|
|
135
|
+
proc.on(signal, () => {
|
|
136
|
+
const othersListening = proc.listenerCount(signal) > 1;
|
|
137
|
+
const reraise = () => {
|
|
138
|
+
if (!othersListening) proc.kill(proc.pid, signal);
|
|
139
|
+
};
|
|
140
|
+
if (stopping) {
|
|
141
|
+
for (const w of liveWorkers) signalProcessGroup(w.child, "SIGKILL");
|
|
142
|
+
reraise();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
stopping = true;
|
|
146
|
+
Promise.all([...liveWorkers].map((w) => w.stop())).finally(reraise);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Variables removed from the inherited environment and from request overrides before the Account's
|
|
153
|
+
* are set, so that the Account alone decides which identity the Worker runs as: a stray key in the
|
|
154
|
+
* Foreman's shell must not silently bill a subscription Account, and one vendor's Worker has no
|
|
155
|
+
* business seeing the other's key. `CLAUDECODE` and `CLAUDE_CODE_ENTRYPOINT` mark a nested run when
|
|
156
|
+
* the Foreman is itself started from inside Claude Code, which the binary treats differently.
|
|
157
|
+
* `CODEX_API_KEY` is the variable `codex exec` takes a key from ahead of any stored login and
|
|
158
|
+
* `CODEX_ACCESS_TOKEN` an agent-identity token (codex-rs/login/src/auth/manager.rs, `load_auth`).
|
|
159
|
+
*/
|
|
160
|
+
export const STRIPPED_ENV = [
|
|
161
|
+
"ANTHROPIC_API_KEY",
|
|
162
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
163
|
+
"CLAUDE_CONFIG_DIR",
|
|
164
|
+
"CLAUDECODE",
|
|
165
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
166
|
+
"OPENAI_API_KEY",
|
|
167
|
+
"CODEX_API_KEY",
|
|
168
|
+
"CODEX_ACCESS_TOKEN",
|
|
169
|
+
"CODEX_HOME",
|
|
170
|
+
] as const;
|
|
171
|
+
|
|
172
|
+
/** `source` with every identity variable (`STRIPPED_ENV`) and every unset entry removed: the one
|
|
173
|
+
* filter both a Worker's environment and a Gate's (core/gates.ts) start from, so a shell the
|
|
174
|
+
* Foreman starts on a Worker's behalf never inherits a vendor key from the Foreman's own shell. */
|
|
175
|
+
export function withoutIdentityEnv(source: Readonly<Record<string, string | undefined>>): Record<string, string> {
|
|
176
|
+
const env: Record<string, string> = {};
|
|
177
|
+
for (const [key, value] of Object.entries(source)) {
|
|
178
|
+
if (value !== undefined && !(STRIPPED_ENV as readonly string[]).includes(key)) env[key] = value;
|
|
179
|
+
}
|
|
180
|
+
return env;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What a `readonly-browser` turn (the Verifier) is denied on top of `STRIPPED_ENV`: the Foreman's
|
|
185
|
+
* own Opsee token, the code-host tokens `glab` and `gh` read, and every cloud credential by prefix.
|
|
186
|
+
* The Verifier's prompt quotes text from the Task and its browser reads pages the implementer
|
|
187
|
+
* wrote; it needs none of these, and an environment that has none of them is what keeps a
|
|
188
|
+
* journey that was talked into running a command from finding anything to send. Applied by
|
|
189
|
+
* `buildWorkerEnv` after the identity filter, so it cannot be overridden through the request.
|
|
190
|
+
*/
|
|
191
|
+
export const VERIFIER_STRIPPED_ENV = ["OPSEE_API_TOKEN", "GITLAB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] as const;
|
|
192
|
+
export const VERIFIER_STRIPPED_ENV_PREFIXES = ["AWS_", "GOOGLE_", "AZURE_"] as const;
|
|
193
|
+
|
|
194
|
+
/** `env` less everything `VERIFIER_STRIPPED_ENV` and its prefixes name. */
|
|
195
|
+
export function withoutVerifierEnv(env: Readonly<Record<string, string>>): Record<string, string> {
|
|
196
|
+
const kept: Record<string, string> = {};
|
|
197
|
+
for (const [key, value] of Object.entries(env)) {
|
|
198
|
+
if ((VERIFIER_STRIPPED_ENV as readonly string[]).includes(key)) continue;
|
|
199
|
+
if (VERIFIER_STRIPPED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) continue;
|
|
200
|
+
kept[key] = value;
|
|
201
|
+
}
|
|
202
|
+
return kept;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Thrown by `buildWorkerEnv` when the Account cannot be launched; the message never carries a value. */
|
|
206
|
+
export class LaunchError extends Error {}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A `LaunchError` the Foreman decided for itself: the Account's credential is not there to hand to
|
|
210
|
+
* the vendor at all (OPS-288). Separate from its parent because the two want different answers —
|
|
211
|
+
* a missing binary is worth another attempt, a key variable that is not set is not, and the Account
|
|
212
|
+
* it names is quarantined rather than retried.
|
|
213
|
+
*
|
|
214
|
+
* This is the one credential failure with no false positive in it. Every other one is a guess about
|
|
215
|
+
* a vendor's prose; this one is a fact the adapter established before spawning anything, which is
|
|
216
|
+
* why it needs no repeat before it counts (`CREDENTIAL_FAILURES_BEFORE_QUARANTINE`).
|
|
217
|
+
*/
|
|
218
|
+
export class CredentialError extends LaunchError {}
|
|
219
|
+
|
|
220
|
+
/** The two variables a vendor's binary reads its identity from. */
|
|
221
|
+
export interface IdentityEnv {
|
|
222
|
+
/** Names a subscription Account's config directory to the binary (`VENDOR_CONFIG_DIR_ENV`). */
|
|
223
|
+
configDir: string;
|
|
224
|
+
/** Carries an API-key Account's key to the binary. */
|
|
225
|
+
apiKey: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The Worker's environment for one Account: the inherited environment and the request's overrides
|
|
230
|
+
* with every identity variable removed, then the Account's one variable. Overrides are filtered like
|
|
231
|
+
* the inherited environment and applied before the Account's variable, so neither path can hand the
|
|
232
|
+
* Worker an identity other than the Account's. A key reaches the vendor under the one variable it
|
|
233
|
+
* reads and nothing else; the Foreman's own variable name is not the Worker's business. A
|
|
234
|
+
* `readonly-browser` turn loses `VERIFIER_STRIPPED_ENV` as well, before the Account's variable is
|
|
235
|
+
* set (that one it keeps: it is how the vendor bills the turn).
|
|
236
|
+
*/
|
|
237
|
+
export function buildWorkerEnv(
|
|
238
|
+
account: Account,
|
|
239
|
+
identity: IdentityEnv,
|
|
240
|
+
baseEnv: Readonly<Record<string, string | undefined>>,
|
|
241
|
+
overrides: Readonly<Record<string, string>> = {},
|
|
242
|
+
sandbox: TurnSandbox = "worker",
|
|
243
|
+
): Record<string, string> {
|
|
244
|
+
let env: Record<string, string> = { ...withoutIdentityEnv(baseEnv), ...withoutIdentityEnv(overrides) };
|
|
245
|
+
if (sandbox === "readonly-browser") env = withoutVerifierEnv(env);
|
|
246
|
+
if (account.type === "subscription") {
|
|
247
|
+
env[identity.configDir] = account.configDir;
|
|
248
|
+
} else {
|
|
249
|
+
const key = baseEnv[account.keyRef];
|
|
250
|
+
if (key === undefined || key === "") {
|
|
251
|
+
throw new CredentialError(`Account "${account.name}" needs $${account.keyRef} set in the Foreman's environment`);
|
|
252
|
+
}
|
|
253
|
+
delete env[account.keyRef];
|
|
254
|
+
env[identity.apiKey] = key;
|
|
255
|
+
}
|
|
256
|
+
return env;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function errorMessage(error: unknown): string {
|
|
260
|
+
return error instanceof Error ? error.message : String(error);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Why a launch that threw before the process existed failed. Only a `CredentialError` — which the
|
|
264
|
+
* Foreman raised itself, about its own Account record — is credential; everything else is a machine
|
|
265
|
+
* that could not start the binary, and guessing otherwise is how a working Account gets quarantined. */
|
|
266
|
+
export function launchFailureReason(error: unknown): TurnFailureReason {
|
|
267
|
+
return error instanceof CredentialError ? "credential_failed" : "launch_failed";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A turn that could not be launched at all: one `failed` event and nothing to stop. `launch_failed`
|
|
271
|
+
* unless the caller knows better; `launchFailureReason` is what both adapters ask. */
|
|
272
|
+
export function failedHandle(message: string, reason: TurnFailureReason = "launch_failed"): TurnHandle {
|
|
273
|
+
return {
|
|
274
|
+
events: (async function* () {
|
|
275
|
+
yield { type: "failed", reason, message } as AdapterEvent;
|
|
276
|
+
})(),
|
|
277
|
+
stop: async () => undefined,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A `rate_limited` event with `resetAt` only when the vendor named one. */
|
|
282
|
+
export function rateLimitedEvent(message: string, resetAt: string | undefined): AdapterEvent {
|
|
283
|
+
return resetAt === undefined ? { type: "rate_limited", message } : { type: "rate_limited", resetAt, message };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
class AsyncQueue<T> implements AsyncIterable<T> {
|
|
287
|
+
private readonly items: T[] = [];
|
|
288
|
+
private waiting: (() => void) | null = null;
|
|
289
|
+
private closed = false;
|
|
290
|
+
|
|
291
|
+
push(item: T): void {
|
|
292
|
+
if (this.closed) return;
|
|
293
|
+
this.items.push(item);
|
|
294
|
+
this.waiting?.();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
close(): void {
|
|
298
|
+
this.closed = true;
|
|
299
|
+
this.waiting?.();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
303
|
+
for (;;) {
|
|
304
|
+
if (this.items.length > 0) {
|
|
305
|
+
yield this.items.shift()!;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (this.closed) return;
|
|
309
|
+
await new Promise<void>((resolve) => {
|
|
310
|
+
this.waiting = resolve;
|
|
311
|
+
});
|
|
312
|
+
this.waiting = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface ProcessTurnSpec {
|
|
318
|
+
spawn: ProcessSpawner;
|
|
319
|
+
command: string;
|
|
320
|
+
args: string[];
|
|
321
|
+
options: SpawnOptions;
|
|
322
|
+
stallTimeoutMs: number | undefined;
|
|
323
|
+
/** Known before the process speaks when resuming; the vendor's first line confirms it. */
|
|
324
|
+
sessionId: string | undefined;
|
|
325
|
+
onLine: ((line: string) => void) | undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* One Worker process as the adapter's event stream. Subclasses translate the vendor's stream
|
|
330
|
+
* lines (`handleMessage`) and may release per-turn resources (`cleanup`); the base owns the
|
|
331
|
+
* process: it spawns, arms the stall timer on every chunk, reassembles lines split across chunks
|
|
332
|
+
* and multi-byte characters split across them, keeps a stderr tail for the exit that comes
|
|
333
|
+
* without a result, and reports `stalled`, `stopped` or `vendor_error` when the process ends
|
|
334
|
+
* before the subclass ended the turn.
|
|
335
|
+
*/
|
|
336
|
+
export abstract class ProcessTurn implements TurnHandle {
|
|
337
|
+
readonly events: AsyncIterable<AdapterEvent>;
|
|
338
|
+
protected sessionId: string | undefined;
|
|
339
|
+
private readonly queue = new AsyncQueue<AdapterEvent>();
|
|
340
|
+
private readonly decoder = new TextDecoder();
|
|
341
|
+
private process: SpawnedProcess | null = null;
|
|
342
|
+
private stallTimer: ReturnType<typeof setTimeout> | null = null;
|
|
343
|
+
private stoppedByCaller = false;
|
|
344
|
+
private stalled = false;
|
|
345
|
+
private ended = false;
|
|
346
|
+
private stderrTail = "";
|
|
347
|
+
|
|
348
|
+
protected constructor(protected readonly spec: ProcessTurnSpec) {
|
|
349
|
+
this.sessionId = spec.sessionId;
|
|
350
|
+
this.events = this.queue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Starts the process; called by the subclass constructor once its own fields are set. */
|
|
354
|
+
protected begin(): void {
|
|
355
|
+
this.run().catch((error: unknown) => {
|
|
356
|
+
this.end({ type: "failed", reason: "vendor_error", message: errorMessage(error), sessionId: this.sessionId });
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
get pid(): number | undefined {
|
|
361
|
+
return this.process?.pid;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Kills the Worker whether or not the turn has ended, so a process that lingers after its result
|
|
365
|
+
* is not orphaned; the exit handler in run() reports `stopped` if the turn was still open. */
|
|
366
|
+
async stop(): Promise<void> {
|
|
367
|
+
this.stoppedByCaller = true;
|
|
368
|
+
this.process?.kill();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** One parsed JSON line from the vendor's stdout. */
|
|
372
|
+
protected abstract handleMessage(line: unknown): void;
|
|
373
|
+
|
|
374
|
+
/** Called once, when the turn has ended for any reason. */
|
|
375
|
+
protected cleanup(): void {}
|
|
376
|
+
|
|
377
|
+
protected end(event: AdapterEvent): void {
|
|
378
|
+
if (this.ended) return;
|
|
379
|
+
this.ended = true;
|
|
380
|
+
this.clearStall();
|
|
381
|
+
this.queue.push(event);
|
|
382
|
+
this.queue.close();
|
|
383
|
+
this.cleanup();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
protected emit(event: AdapterEvent): void {
|
|
387
|
+
if (!this.ended) this.queue.push(event);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private armStall(): void {
|
|
391
|
+
if (this.spec.stallTimeoutMs === undefined) return;
|
|
392
|
+
this.clearStall();
|
|
393
|
+
this.stallTimer = setTimeout(() => {
|
|
394
|
+
this.stalled = true;
|
|
395
|
+
this.emit({ type: "stalled", silentMs: this.spec.stallTimeoutMs! });
|
|
396
|
+
this.process?.kill();
|
|
397
|
+
}, this.spec.stallTimeoutMs);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private clearStall(): void {
|
|
401
|
+
if (this.stallTimer) clearTimeout(this.stallTimer);
|
|
402
|
+
this.stallTimer = null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private decode(chunk: string | Uint8Array): string {
|
|
406
|
+
// One streaming decoder per turn: a multi-byte character may be split across two chunks.
|
|
407
|
+
return typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async run(): Promise<void> {
|
|
411
|
+
try {
|
|
412
|
+
this.process = this.spec.spawn(this.spec.command, this.spec.args, this.spec.options);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
this.end({ type: "failed", reason: "launch_failed", message: errorMessage(error) });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
this.armStall();
|
|
418
|
+
const stderr = this.drainStderr(this.process.stderr);
|
|
419
|
+
let remainder = "";
|
|
420
|
+
try {
|
|
421
|
+
for await (const chunk of this.process.stdout) {
|
|
422
|
+
this.armStall();
|
|
423
|
+
remainder += this.decode(chunk);
|
|
424
|
+
let newline = remainder.indexOf("\n");
|
|
425
|
+
while (newline !== -1) {
|
|
426
|
+
this.handleLine(remainder.slice(0, newline));
|
|
427
|
+
remainder = remainder.slice(newline + 1);
|
|
428
|
+
newline = remainder.indexOf("\n");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
remainder += this.decoder.decode();
|
|
432
|
+
if (remainder.trim() !== "") this.handleLine(remainder);
|
|
433
|
+
} catch {
|
|
434
|
+
// A destroyed pipe after kill(): the exit below says what happened.
|
|
435
|
+
}
|
|
436
|
+
this.clearStall();
|
|
437
|
+
let exit: { code: number | null; signal: string | null };
|
|
438
|
+
try {
|
|
439
|
+
exit = await this.process.exited;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
this.end({ type: "failed", reason: "launch_failed", message: `Could not start ${this.spec.command}: ${errorMessage(error)}` });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
await stderr;
|
|
445
|
+
if (this.ended) return;
|
|
446
|
+
if (this.stalled) {
|
|
447
|
+
this.end({ type: "failed", reason: "stalled", message: `No output for ${this.spec.stallTimeoutMs}ms; the Worker was stopped`, sessionId: this.sessionId });
|
|
448
|
+
} else if (this.stoppedByCaller) {
|
|
449
|
+
this.end({ type: "failed", reason: "stopped", message: "Stopped before the Worker reported", sessionId: this.sessionId });
|
|
450
|
+
} else {
|
|
451
|
+
const how = exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`;
|
|
452
|
+
this.end({
|
|
453
|
+
type: "failed",
|
|
454
|
+
reason: "vendor_error",
|
|
455
|
+
message: `${this.spec.command} exited with ${how} before a result`,
|
|
456
|
+
sessionId: this.sessionId,
|
|
457
|
+
details: this.stderrTail ? [this.stderrTail] : undefined,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async drainStderr(stream: AsyncIterable<string | Uint8Array>): Promise<void> {
|
|
463
|
+
const decoder = new TextDecoder();
|
|
464
|
+
try {
|
|
465
|
+
for await (const chunk of stream) {
|
|
466
|
+
const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
467
|
+
this.stderrTail = (this.stderrTail + text).slice(-2000).trim();
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
// Same as stdout: a killed process may destroy the pipe.
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private handleLine(raw: string): void {
|
|
475
|
+
const text = raw.trim();
|
|
476
|
+
if (text === "") return;
|
|
477
|
+
this.spec.onLine?.(text);
|
|
478
|
+
let line: unknown;
|
|
479
|
+
try {
|
|
480
|
+
line = JSON.parse(text);
|
|
481
|
+
} catch {
|
|
482
|
+
// Vendors occasionally print diagnostics on stdout; they are progress, not protocol.
|
|
483
|
+
this.emit({ type: "output", text });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
this.handleMessage(line);
|
|
487
|
+
}
|
|
488
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Code, ConnectError, type Client } from "@connectrpc/connect";
|
|
2
|
+
import type { UserService } from "@opsee/mcp-server/gen/api/v1/user_pb.js";
|
|
3
|
+
|
|
4
|
+
/** Who the stored token belongs to, as the backend reports it. */
|
|
5
|
+
export interface Identity {
|
|
6
|
+
userId: number;
|
|
7
|
+
fullName: string;
|
|
8
|
+
email: string;
|
|
9
|
+
companyId: number;
|
|
10
|
+
companyName: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The slice of the generated UserService client the CLI needs; tests supply a fake. */
|
|
14
|
+
export type IdentityClient = Pick<Client<typeof UserService>, "getMe">;
|
|
15
|
+
|
|
16
|
+
export async function fetchIdentity(users: IdentityClient): Promise<Identity> {
|
|
17
|
+
const res = await users.getMe({});
|
|
18
|
+
const u = res.user;
|
|
19
|
+
if (!u) {
|
|
20
|
+
throw new Error("Backend returned no user for this token");
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
userId: u.id,
|
|
24
|
+
fullName: u.fullName,
|
|
25
|
+
email: u.email,
|
|
26
|
+
companyId: u.companyId,
|
|
27
|
+
companyName: u.company?.name ?? "",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fetches the identity, or null when the backend says the token is not valid. Other failures
|
|
32
|
+
* (network, server errors) propagate: they are not evidence of a missing login. */
|
|
33
|
+
export async function identityIfAuthorized(users: IdentityClient): Promise<Identity | null> {
|
|
34
|
+
try {
|
|
35
|
+
return await fetchIdentity(users);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof ConnectError && error.code === Code.Unauthenticated) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatIdentity(id: Identity): string {
|
|
45
|
+
const company = id.companyName
|
|
46
|
+
? `${id.companyName} (company ${id.companyId})`
|
|
47
|
+
: `company ${id.companyId}`;
|
|
48
|
+
return `${id.fullName} <${id.email}> (user ${id.userId}) at ${company}`;
|
|
49
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** The ownership marker every whole file `opsee init` writes carries. It records the hash of the
|
|
4
|
+
* content Opsee generated so a re-run can tell "still what we wrote" from "the user edited it".
|
|
5
|
+
* Two comment styles, because a marker must be a comment in the file's own syntax. */
|
|
6
|
+
export type MarkerStyle = "markdown" | "yaml";
|
|
7
|
+
|
|
8
|
+
const MARKER_RE = /^(?:<!-- |# )opsee:managed sha256:([0-9a-f]{64})(?: -->)?$/;
|
|
9
|
+
|
|
10
|
+
export function hashOf(body: string): string {
|
|
11
|
+
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function markerLine(style: MarkerStyle, hash: string): string {
|
|
15
|
+
return style === "markdown" ? `<!-- opsee:managed sha256:${hash} -->` : `# opsee:managed sha256:${hash}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Where the marker goes: line 1, unless the file opens with YAML frontmatter, in which case it
|
|
19
|
+
* goes right after the closing `---` so the frontmatter parsers of Claude Code and Codex still
|
|
20
|
+
* see it first. The hash always covers the whole body, frontmatter included. */
|
|
21
|
+
function splitFrontmatter(body: string): [string, string] {
|
|
22
|
+
const match = /^---\n[\s\S]*?\n---\n/.exec(body);
|
|
23
|
+
return match ? [match[0], body.slice(match[0].length)] : ["", body];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Generated content plus its marker: what `opsee init` writes to disk. */
|
|
27
|
+
export function stamp(body: string, style: MarkerStyle): string {
|
|
28
|
+
const [front, rest] = splitFrontmatter(body);
|
|
29
|
+
return `${front}${markerLine(style, hashOf(body))}\n${rest}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Marked {
|
|
33
|
+
hash: string;
|
|
34
|
+
/** The file with its marker line removed: what the hash was taken over. */
|
|
35
|
+
body: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Reads the marker back out of a file on disk; null when the file carries none. */
|
|
39
|
+
export function parseMarker(content: string): Marked | null {
|
|
40
|
+
const lines = content.split("\n");
|
|
41
|
+
// The marker is on line 1 or directly after frontmatter; scanning the first few lines covers
|
|
42
|
+
// both without letting a marker quoted deep inside a document count.
|
|
43
|
+
const limit = Math.min(lines.length, 16);
|
|
44
|
+
for (let i = 0; i < limit; i++) {
|
|
45
|
+
const match = MARKER_RE.exec(lines[i]);
|
|
46
|
+
if (!match) continue;
|
|
47
|
+
if (i > 0 && !(lines[0] === "---" && lines[i - 1] === "---")) continue;
|
|
48
|
+
lines.splice(i, 1);
|
|
49
|
+
return { hash: match[1], body: lines.join("\n") };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type WriteAction =
|
|
55
|
+
| "create"
|
|
56
|
+
| "update"
|
|
57
|
+
| "unchanged"
|
|
58
|
+
/** The file carries a marker but its content no longer matches: the user edited it. */
|
|
59
|
+
| "kept-edited"
|
|
60
|
+
/** The file exists and carries no marker: it was never ours. */
|
|
61
|
+
| "kept-unmanaged"
|
|
62
|
+
/** The file exists but cannot be merged into safely; `reason` says why. */
|
|
63
|
+
| "kept-invalid";
|
|
64
|
+
|
|
65
|
+
/** What a re-run does with one path. Every writer (whole files, config merges, the pointer
|
|
66
|
+
* block) answers in this shape so the command reports them all the same way. */
|
|
67
|
+
export interface WritePlan {
|
|
68
|
+
action: WriteAction;
|
|
69
|
+
/** What to write; present only for create and update. */
|
|
70
|
+
content?: string;
|
|
71
|
+
reason?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Decides what a re-run does with one owned path. `existing` is the file's current content or
|
|
75
|
+
* null when absent; `generated` is the freshly rendered body without a marker. */
|
|
76
|
+
export function planWrite(existing: string | null, generated: string, style: MarkerStyle): WritePlan {
|
|
77
|
+
const content = stamp(generated, style);
|
|
78
|
+
if (existing === null) return { action: "create", content };
|
|
79
|
+
const marked = parseMarker(existing);
|
|
80
|
+
if (!marked) return { action: "kept-unmanaged" };
|
|
81
|
+
if (hashOf(marked.body) !== marked.hash) return { action: "kept-edited" };
|
|
82
|
+
if (existing === content) return { action: "unchanged" };
|
|
83
|
+
return { action: "update", content };
|
|
84
|
+
}
|