@onyx-robotics/agent 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 +202 -0
- package/README.md +72 -0
- package/bin/onyx.ts +4 -0
- package/package.json +52 -0
- package/scripts/install.sh +115 -0
- package/skills/onyx/SKILL.md +150 -0
- package/src/commands/agent.ts +23 -0
- package/src/commands/branch.ts +96 -0
- package/src/commands/exp.ts +432 -0
- package/src/commands/listen.ts +327 -0
- package/src/commands/login.ts +198 -0
- package/src/commands/profile.ts +112 -0
- package/src/commands/sync.ts +88 -0
- package/src/install.test.ts +38 -0
- package/src/lib/api.ts +227 -0
- package/src/lib/args.ts +68 -0
- package/src/lib/config.ts +148 -0
- package/src/lib/events.ts +97 -0
- package/src/lib/git.ts +57 -0
- package/src/lib/history.ts +272 -0
- package/src/lib/login.ts +233 -0
- package/src/lib/markdown.ts +148 -0
- package/src/lib/metrics.ts +41 -0
- package/src/lib/outbox.ts +173 -0
- package/src/lib/process.ts +73 -0
- package/src/lib/project.ts +42 -0
- package/src/lib/skill-content.ts +1 -0
- package/src/lib/skill.ts +50 -0
- package/src/lib/sync.ts +294 -0
- package/src/lib/tui.ts +364 -0
- package/src/main.ts +84 -0
- package/src/onyx.test.ts +952 -0
- package/src/onyx.ts +92 -0
- package/src/profile.test.ts +472 -0
- package/src/protocol/index.ts +2 -0
- package/src/protocol/local-research.ts +152 -0
- package/src/protocol/research.ts +75 -0
package/src/lib/git.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { commandOutput, runProcess } from "./process"
|
|
2
|
+
|
|
3
|
+
export async function git(args: string[], cwd?: string) {
|
|
4
|
+
return commandOutput("git", args, cwd)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function gitResult(args: string[], cwd?: string) {
|
|
8
|
+
return runProcess("git", args, { cwd })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function repoRoot(cwd = process.cwd()) {
|
|
12
|
+
return git(["rev-parse", "--show-toplevel"], cwd)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Absolute path to the repo's git directory (handles worktrees). */
|
|
16
|
+
export async function gitDir(root: string) {
|
|
17
|
+
return git(["rev-parse", "--absolute-git-dir"], root)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function currentBranch(cwd: string) {
|
|
21
|
+
return git(["branch", "--show-current"], cwd)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function currentCommit(cwd: string) {
|
|
25
|
+
return git(["rev-parse", "HEAD"], cwd)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function gitBranchForName(name: string) {
|
|
29
|
+
return `onyx/${name}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nameFromGitBranch(gitBranchName: string) {
|
|
33
|
+
return gitBranchName.startsWith("onyx/")
|
|
34
|
+
? gitBranchName.slice("onyx/".length)
|
|
35
|
+
: null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Pushes a branch to origin, setting upstream. Throws on failure. */
|
|
39
|
+
export async function pushBranch(root: string, branchName: string) {
|
|
40
|
+
await git(["push", "-u", "origin", branchName], root)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeRepositoryUrl(value: string) {
|
|
44
|
+
const ssh = value.match(/^git@github\.com:(.+?)\/(.+?)(?:\.git)?$/)
|
|
45
|
+
if (ssh)
|
|
46
|
+
return `https://github.com/${ssh[1]}/${ssh[2]!.replace(/\.git$/, "")}`
|
|
47
|
+
return value.replace(/\.git$/, "")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function repositoryUrl(
|
|
51
|
+
root: string,
|
|
52
|
+
explicitUrl?: string
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
if (explicitUrl) return normalizeRepositoryUrl(explicitUrl)
|
|
55
|
+
const origin = await git(["remote", "get-url", "origin"], root)
|
|
56
|
+
return normalizeRepositoryUrl(origin)
|
|
57
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
localResearchHistoryRecordSchema,
|
|
6
|
+
type LocalResearchExperimentLoggedRecord,
|
|
7
|
+
type LocalResearchHistoryRecord,
|
|
8
|
+
} from "../protocol"
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
getProjectTree,
|
|
12
|
+
resolveProject,
|
|
13
|
+
type ApiTreeBranch,
|
|
14
|
+
type ApiTreeExperiment,
|
|
15
|
+
} from "./api"
|
|
16
|
+
import type { Args } from "./args"
|
|
17
|
+
import { onyxStateDir, readOutbox, readState } from "./outbox"
|
|
18
|
+
|
|
19
|
+
export async function historyPath(root: string) {
|
|
20
|
+
return join(await onyxStateDir(root), "history.jsonl")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function appendHistory(
|
|
24
|
+
root: string,
|
|
25
|
+
record: LocalResearchHistoryRecord
|
|
26
|
+
) {
|
|
27
|
+
const validated = localResearchHistoryRecordSchema.parse(record)
|
|
28
|
+
await writeFile(await historyPath(root), `${JSON.stringify(validated)}\n`, {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
flag: "a",
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads the local history cache. Corrupt or partially-written lines are
|
|
36
|
+
* skipped and counted rather than thrown, mirroring the outbox.
|
|
37
|
+
*/
|
|
38
|
+
export async function readHistory(
|
|
39
|
+
root: string
|
|
40
|
+
): Promise<{ records: LocalResearchHistoryRecord[]; corrupt: number }> {
|
|
41
|
+
let text = ""
|
|
42
|
+
try {
|
|
43
|
+
text = await readFile(await historyPath(root), "utf8")
|
|
44
|
+
} catch {
|
|
45
|
+
return { records: [], corrupt: 0 }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const records: LocalResearchHistoryRecord[] = []
|
|
49
|
+
let corrupt = 0
|
|
50
|
+
|
|
51
|
+
for (const line of text.split("\n")) {
|
|
52
|
+
const trimmed = line.trim()
|
|
53
|
+
if (!trimmed) continue
|
|
54
|
+
let parsed: unknown
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(trimmed)
|
|
57
|
+
} catch {
|
|
58
|
+
corrupt += 1
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const result = localResearchHistoryRecordSchema.safeParse(parsed)
|
|
62
|
+
if (result.success) {
|
|
63
|
+
records.push(result.data)
|
|
64
|
+
} else {
|
|
65
|
+
corrupt += 1
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { records, corrupt }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Atomically replaces the history cache. */
|
|
73
|
+
export async function rewriteHistory(
|
|
74
|
+
root: string,
|
|
75
|
+
records: LocalResearchHistoryRecord[]
|
|
76
|
+
) {
|
|
77
|
+
const path = await historyPath(root)
|
|
78
|
+
const body = records.map((record) => JSON.stringify(record)).join("\n")
|
|
79
|
+
const tmp = `${path}.tmp`
|
|
80
|
+
await writeFile(tmp, body ? `${body}\n` : "", "utf8")
|
|
81
|
+
await rename(tmp, path)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Provisional history record for a just-logged experiment. */
|
|
85
|
+
export function experimentRecordToHistory(
|
|
86
|
+
record: LocalResearchExperimentLoggedRecord
|
|
87
|
+
): LocalResearchHistoryRecord {
|
|
88
|
+
return {
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
source: "local",
|
|
91
|
+
branchName: record.branchName,
|
|
92
|
+
gitBranchName: record.gitBranchName,
|
|
93
|
+
runRef: record.runRef,
|
|
94
|
+
commitSha: record.commitSha,
|
|
95
|
+
status: record.status,
|
|
96
|
+
name: record.name,
|
|
97
|
+
description: record.description ?? null,
|
|
98
|
+
primaryMetricName: record.primaryMetricName,
|
|
99
|
+
primaryMetricValue: record.primaryMetricValue,
|
|
100
|
+
metrics: record.metrics,
|
|
101
|
+
agentNotes: record.agentNotes,
|
|
102
|
+
checks: record.checks ?? null,
|
|
103
|
+
durationMs: record.durationMs ?? null,
|
|
104
|
+
outputSummary: record.outputSummary ?? null,
|
|
105
|
+
startedAt: record.startedAt ?? null,
|
|
106
|
+
completedAt: record.completedAt ?? null,
|
|
107
|
+
createdAt: record.createdAt,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Canonical history record from a tree-endpoint experiment DTO. */
|
|
112
|
+
export function apiExperimentToHistory(
|
|
113
|
+
branch: ApiTreeBranch,
|
|
114
|
+
experiment: ApiTreeExperiment
|
|
115
|
+
): LocalResearchHistoryRecord | null {
|
|
116
|
+
const metrics: Record<string, number> = {}
|
|
117
|
+
for (const [key, value] of Object.entries(experiment.secondaryMetrics)) {
|
|
118
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
119
|
+
metrics[key] = value
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (experiment.primaryMetricValue !== null) {
|
|
123
|
+
metrics[experiment.primaryMetricName] = experiment.primaryMetricValue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = localResearchHistoryRecordSchema.safeParse({
|
|
127
|
+
schemaVersion: 1,
|
|
128
|
+
source: "api",
|
|
129
|
+
branchName: branch.name,
|
|
130
|
+
gitBranchName: branch.gitBranchName ?? undefined,
|
|
131
|
+
runRef: experiment.runRef,
|
|
132
|
+
commitSha: experiment.commitSha,
|
|
133
|
+
status: experiment.status,
|
|
134
|
+
name: experiment.name.slice(0, 160),
|
|
135
|
+
description: experiment.description?.slice(0, 2000) ?? null,
|
|
136
|
+
primaryMetricName: experiment.primaryMetricName,
|
|
137
|
+
primaryMetricValue: experiment.primaryMetricValue,
|
|
138
|
+
metrics,
|
|
139
|
+
agentNotes: experiment.agentNotes,
|
|
140
|
+
checks: experiment.checks
|
|
141
|
+
? {
|
|
142
|
+
status: experiment.checks.status,
|
|
143
|
+
durationMs: experiment.checks.durationMs,
|
|
144
|
+
outputSummary: experiment.checks.outputSummary?.slice(0, 4000),
|
|
145
|
+
}
|
|
146
|
+
: null,
|
|
147
|
+
durationMs: experiment.durationMs,
|
|
148
|
+
outputSummary: experiment.outputSummary?.slice(0, 4000) ?? null,
|
|
149
|
+
startedAt: experiment.startedAt,
|
|
150
|
+
completedAt: experiment.completedAt,
|
|
151
|
+
createdAt: experiment.createdAt,
|
|
152
|
+
experimentId: experiment.id,
|
|
153
|
+
branchId: experiment.branchId,
|
|
154
|
+
sequenceNumber: experiment.sequenceNumber,
|
|
155
|
+
})
|
|
156
|
+
return result.success ? result.data : null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Merges canonical API records with still-pending local records. Keyed by
|
|
161
|
+
* runRef (globally unique: `local/{branch}/{uuid}`); canonical wins so
|
|
162
|
+
* server-updated statuses (accepted/rejected) replace provisional rows.
|
|
163
|
+
*/
|
|
164
|
+
export function mergeHistory(
|
|
165
|
+
canonical: LocalResearchHistoryRecord[],
|
|
166
|
+
localCandidates: LocalResearchHistoryRecord[]
|
|
167
|
+
): LocalResearchHistoryRecord[] {
|
|
168
|
+
const byRunRef = new Map<string, LocalResearchHistoryRecord>()
|
|
169
|
+
for (const record of localCandidates) byRunRef.set(record.runRef, record)
|
|
170
|
+
for (const record of canonical) byRunRef.set(record.runRef, record)
|
|
171
|
+
|
|
172
|
+
return [...byRunRef.values()].sort((a, b) => {
|
|
173
|
+
if (a.branchName !== b.branchName) {
|
|
174
|
+
return a.branchName < b.branchName ? -1 : 1
|
|
175
|
+
}
|
|
176
|
+
// Server-sequenced records first in order; pending local records after.
|
|
177
|
+
if (a.sequenceNumber !== undefined && b.sequenceNumber !== undefined) {
|
|
178
|
+
return a.sequenceNumber - b.sequenceNumber
|
|
179
|
+
}
|
|
180
|
+
if (a.sequenceNumber !== undefined) return -1
|
|
181
|
+
if (b.sequenceNumber !== undefined) return 1
|
|
182
|
+
return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type HistorySyncUpdate = {
|
|
187
|
+
sequenceNumber: number
|
|
188
|
+
experimentId: string
|
|
189
|
+
branchId: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Stamps server-assigned fields onto matching history records right after a
|
|
194
|
+
* flush, so `onyx listen` and `exp list` show sequence numbers immediately
|
|
195
|
+
* instead of waiting for the next full hydrate. Matched records become
|
|
196
|
+
* canonical (`source: "api"`).
|
|
197
|
+
*/
|
|
198
|
+
export async function applyHistorySyncUpdates(
|
|
199
|
+
root: string,
|
|
200
|
+
updates: Map<string, HistorySyncUpdate>
|
|
201
|
+
) {
|
|
202
|
+
if (updates.size === 0) return
|
|
203
|
+
const { records } = await readHistory(root)
|
|
204
|
+
let changed = false
|
|
205
|
+
const next = records.map((record) => {
|
|
206
|
+
const update = updates.get(record.runRef)
|
|
207
|
+
if (!update) return record
|
|
208
|
+
changed = true
|
|
209
|
+
return {
|
|
210
|
+
...record,
|
|
211
|
+
source: "api" as const,
|
|
212
|
+
sequenceNumber: update.sequenceNumber,
|
|
213
|
+
experimentId: update.experimentId,
|
|
214
|
+
branchId: update.branchId,
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
if (changed) await rewriteHistory(root, next)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export type HydrateResult = {
|
|
221
|
+
experiments: number
|
|
222
|
+
branches: number
|
|
223
|
+
pendingLocal: number
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Rewrites `.git/onyx/history.jsonl` to the canonical API state, keeping
|
|
228
|
+
* local records the server doesn't know yet (offline-logged, unflushed).
|
|
229
|
+
* Throws on network/auth failure; callers treat hydration as best-effort.
|
|
230
|
+
*/
|
|
231
|
+
export async function hydrateHistoryFromApi(
|
|
232
|
+
root: string,
|
|
233
|
+
args: Args
|
|
234
|
+
): Promise<HydrateResult> {
|
|
235
|
+
const state = await readState(root)
|
|
236
|
+
const projectId = state.projectId ?? (await resolveProject(root, args)).id
|
|
237
|
+
const tree = await getProjectTree(projectId, args)
|
|
238
|
+
|
|
239
|
+
const canonical: LocalResearchHistoryRecord[] = []
|
|
240
|
+
for (const branch of tree.branches) {
|
|
241
|
+
for (const experiment of branch.experiments) {
|
|
242
|
+
const record = apiExperimentToHistory(branch, experiment)
|
|
243
|
+
if (record) canonical.push(record)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const canonicalRunRefs = new Set(canonical.map((record) => record.runRef))
|
|
247
|
+
|
|
248
|
+
// Local records the server doesn't have yet: provisional history rows plus
|
|
249
|
+
// anything still queued in the outbox (covers a failed earlier append).
|
|
250
|
+
const { records: existing } = await readHistory(root)
|
|
251
|
+
const localCandidates = existing.filter(
|
|
252
|
+
(record) =>
|
|
253
|
+
record.source === "local" && !canonicalRunRefs.has(record.runRef)
|
|
254
|
+
)
|
|
255
|
+
const seen = new Set(localCandidates.map((record) => record.runRef))
|
|
256
|
+
const { records: outbox } = await readOutbox(root)
|
|
257
|
+
for (const record of outbox) {
|
|
258
|
+
if (record.type !== "experiment_logged") continue
|
|
259
|
+
if (canonicalRunRefs.has(record.runRef) || seen.has(record.runRef)) continue
|
|
260
|
+
localCandidates.push(experimentRecordToHistory(record))
|
|
261
|
+
seen.add(record.runRef)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const merged = mergeHistory(canonical, localCandidates)
|
|
265
|
+
await rewriteHistory(root, merged)
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
experiments: merged.length,
|
|
269
|
+
branches: new Set(merged.map((record) => record.branchName)).size,
|
|
270
|
+
pendingLocal: localCandidates.length,
|
|
271
|
+
}
|
|
272
|
+
}
|
package/src/lib/login.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http"
|
|
2
|
+
|
|
3
|
+
import { runProcess } from "./process"
|
|
4
|
+
|
|
5
|
+
export async function openBrowser(url: string) {
|
|
6
|
+
const command =
|
|
7
|
+
process.platform === "darwin"
|
|
8
|
+
? "open"
|
|
9
|
+
: process.platform === "win32"
|
|
10
|
+
? "cmd"
|
|
11
|
+
: "xdg-open"
|
|
12
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]
|
|
13
|
+
try {
|
|
14
|
+
const result = await runProcess(command, args)
|
|
15
|
+
if (result.code === 0) return
|
|
16
|
+
} catch {
|
|
17
|
+
// Fall through to printing the URL for headless or minimal systems.
|
|
18
|
+
}
|
|
19
|
+
console.log(`Open this URL to log in:\n${url}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CliLoginResult = {
|
|
23
|
+
apiKey?: string
|
|
24
|
+
apiKeyId?: string
|
|
25
|
+
apiUrl?: string
|
|
26
|
+
teamId: string
|
|
27
|
+
teamName: string
|
|
28
|
+
profileName?: string
|
|
29
|
+
alreadyConfigured: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const LOGIN_COMPLETE_MESSAGE =
|
|
33
|
+
"Onyx CLI login complete. You can close this tab."
|
|
34
|
+
|
|
35
|
+
const ONYX_MARK_SVG = `<svg width="381" height="509" viewBox="0 0 381 509" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M381 382.14L233 508.018V424.447L298.75 368.525L190.501 151.379L82.25 368.526L149 425.297V508.867L0 382.14L190.501 0L381 382.14Z" fill="currentColor"/></svg>`
|
|
36
|
+
|
|
37
|
+
export function cliLoginCompleteHtml() {
|
|
38
|
+
return `<!doctype html>
|
|
39
|
+
<html lang="en">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="utf-8">
|
|
42
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
43
|
+
<title>Onyx CLI login complete</title>
|
|
44
|
+
<style>
|
|
45
|
+
:root {
|
|
46
|
+
color-scheme: light dark;
|
|
47
|
+
--background: oklch(0.96 0 0);
|
|
48
|
+
--foreground: oklch(0.145 0 0);
|
|
49
|
+
--card: oklch(0.96 0 0);
|
|
50
|
+
--card-foreground: oklch(0.145 0 0);
|
|
51
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
52
|
+
--border: oklch(0.922 0 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@media (prefers-color-scheme: dark) {
|
|
56
|
+
:root {
|
|
57
|
+
--background: oklch(0.145 0 0);
|
|
58
|
+
--foreground: oklch(0.985 0 0);
|
|
59
|
+
--card: oklch(0.205 0 0);
|
|
60
|
+
--card-foreground: oklch(0.985 0 0);
|
|
61
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
62
|
+
--border: oklch(1 0 0 / 10%);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
* {
|
|
67
|
+
box-sizing: border-box;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
body {
|
|
71
|
+
margin: 0;
|
|
72
|
+
min-height: 100vh;
|
|
73
|
+
display: grid;
|
|
74
|
+
place-items: center;
|
|
75
|
+
padding: 24px;
|
|
76
|
+
background: var(--background);
|
|
77
|
+
color: var(--foreground);
|
|
78
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
main {
|
|
82
|
+
width: min(100%, 448px);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.brand {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
gap: 32px;
|
|
90
|
+
margin-bottom: 32px;
|
|
91
|
+
font-size: 48px;
|
|
92
|
+
line-height: 1;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
letter-spacing: 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.brand svg {
|
|
98
|
+
width: 60px;
|
|
99
|
+
height: 80px;
|
|
100
|
+
flex: none;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.card {
|
|
104
|
+
border: 1px solid var(--border);
|
|
105
|
+
border-radius: 8px;
|
|
106
|
+
background: var(--card);
|
|
107
|
+
color: var(--card-foreground);
|
|
108
|
+
padding: 24px;
|
|
109
|
+
text-align: center;
|
|
110
|
+
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
h1 {
|
|
114
|
+
margin: 0;
|
|
115
|
+
font-size: 16px;
|
|
116
|
+
line-height: 24px;
|
|
117
|
+
font-weight: 600;
|
|
118
|
+
letter-spacing: 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
p {
|
|
122
|
+
margin: 8px 0 0;
|
|
123
|
+
color: var(--muted-foreground);
|
|
124
|
+
font-size: 14px;
|
|
125
|
+
line-height: 22px;
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
128
|
+
</head>
|
|
129
|
+
<body>
|
|
130
|
+
<main>
|
|
131
|
+
<div class="brand">${ONYX_MARK_SVG}<span>Onyx</span></div>
|
|
132
|
+
<section class="card" aria-labelledby="title">
|
|
133
|
+
<h1 id="title">${LOGIN_COMPLETE_MESSAGE}</h1>
|
|
134
|
+
<p>Your CLI profile is ready to use.</p>
|
|
135
|
+
</section>
|
|
136
|
+
</main>
|
|
137
|
+
</body>
|
|
138
|
+
</html>`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function closeServer(server: Server) {
|
|
142
|
+
return new Promise<void>((resolveClose) => {
|
|
143
|
+
const forceClose = setTimeout(() => {
|
|
144
|
+
server.closeAllConnections()
|
|
145
|
+
resolveClose()
|
|
146
|
+
}, 1_000)
|
|
147
|
+
forceClose.unref()
|
|
148
|
+
|
|
149
|
+
server.close(() => {
|
|
150
|
+
clearTimeout(forceClose)
|
|
151
|
+
resolveClose()
|
|
152
|
+
})
|
|
153
|
+
server.closeIdleConnections()
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function waitForCliLogin({
|
|
158
|
+
port,
|
|
159
|
+
state,
|
|
160
|
+
timeoutMs,
|
|
161
|
+
}: {
|
|
162
|
+
port: number
|
|
163
|
+
state: string
|
|
164
|
+
timeoutMs: number
|
|
165
|
+
}): Promise<CliLoginResult> {
|
|
166
|
+
let server: Server | null = null
|
|
167
|
+
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
168
|
+
const login = new Promise<CliLoginResult>((resolveLogin, reject) => {
|
|
169
|
+
server = createServer((request, response) => {
|
|
170
|
+
response.setHeader("connection", "close")
|
|
171
|
+
response.on("finish", () => request.socket.destroy())
|
|
172
|
+
|
|
173
|
+
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`)
|
|
174
|
+
if (url.pathname !== "/callback") {
|
|
175
|
+
response.writeHead(404).end("Not found")
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (url.searchParams.get("state") !== state) {
|
|
180
|
+
response.writeHead(400).end("Invalid state")
|
|
181
|
+
reject(new Error("Login callback returned an invalid state."))
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const teamId = url.searchParams.get("team_id")
|
|
186
|
+
const teamName = url.searchParams.get("team_name")
|
|
187
|
+
if (!teamId || !teamName) {
|
|
188
|
+
response.writeHead(400).end("Missing team")
|
|
189
|
+
reject(new Error("Login callback did not include a team."))
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const alreadyConfigured =
|
|
194
|
+
url.searchParams.get("already_configured") === "true"
|
|
195
|
+
const key = url.searchParams.get("api_key")
|
|
196
|
+
if (!alreadyConfigured && !key) {
|
|
197
|
+
response.writeHead(400).end("Missing API key")
|
|
198
|
+
reject(new Error("Login callback did not include an API key."))
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
response
|
|
203
|
+
.writeHead(200, { "content-type": "text/html; charset=utf-8" })
|
|
204
|
+
.end(cliLoginCompleteHtml())
|
|
205
|
+
resolveLogin({
|
|
206
|
+
apiKey: key ?? undefined,
|
|
207
|
+
apiKeyId: url.searchParams.get("api_key_id") ?? undefined,
|
|
208
|
+
apiUrl: url.searchParams.get("api_url") ?? undefined,
|
|
209
|
+
teamId,
|
|
210
|
+
teamName,
|
|
211
|
+
profileName: url.searchParams.get("profile_name") ?? undefined,
|
|
212
|
+
alreadyConfigured,
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
server.listen(port, "127.0.0.1")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
return await Promise.race([
|
|
220
|
+
login,
|
|
221
|
+
new Promise<never>((_, reject) => {
|
|
222
|
+
timeout = setTimeout(
|
|
223
|
+
() => reject(new Error("Timed out waiting for browser login.")),
|
|
224
|
+
timeoutMs
|
|
225
|
+
)
|
|
226
|
+
timeout.unref()
|
|
227
|
+
}),
|
|
228
|
+
])
|
|
229
|
+
} finally {
|
|
230
|
+
if (timeout) clearTimeout(timeout)
|
|
231
|
+
if (server) await closeServer(server)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
import { currentBranch, nameFromGitBranch } from "./git"
|
|
4
|
+
import { readState } from "./outbox"
|
|
5
|
+
import { branchStateKey, onyxPath } from "./project"
|
|
6
|
+
|
|
7
|
+
export type MetricDirection = "maximize" | "minimize"
|
|
8
|
+
|
|
9
|
+
export type BranchMetadata = {
|
|
10
|
+
name: string
|
|
11
|
+
description: string | null
|
|
12
|
+
gitBranchName: string
|
|
13
|
+
baseCommitSha: string | null
|
|
14
|
+
metricName: string
|
|
15
|
+
metricUnit: string | null
|
|
16
|
+
metricDirection: MetricDirection
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function resolveBranchName(root: string, branchOption?: string) {
|
|
20
|
+
if (branchOption) return branchOption
|
|
21
|
+
|
|
22
|
+
const gitBranchName = await currentBranch(root)
|
|
23
|
+
const name = nameFromGitBranch(gitBranchName)
|
|
24
|
+
if (name) return name
|
|
25
|
+
|
|
26
|
+
throw new Error("Missing --branch and no local onyx/* branch is checked out.")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markdownField(section: string, name: string) {
|
|
30
|
+
return section.match(new RegExp(`^${name}:\\s*(.*)$`, "m"))?.[1]?.trim()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function branchFromMarkdown({
|
|
34
|
+
root,
|
|
35
|
+
projectPath,
|
|
36
|
+
name,
|
|
37
|
+
gitBranchName,
|
|
38
|
+
}: {
|
|
39
|
+
root: string
|
|
40
|
+
projectPath: string
|
|
41
|
+
name: string
|
|
42
|
+
gitBranchName: string
|
|
43
|
+
}): Promise<BranchMetadata | null> {
|
|
44
|
+
let text = ""
|
|
45
|
+
try {
|
|
46
|
+
text = await readFile(onyxPath(root, projectPath, "onyx.md"), "utf8")
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const start = text.indexOf(`### ${name}`)
|
|
52
|
+
if (start === -1) return null
|
|
53
|
+
const rest = text.slice(start)
|
|
54
|
+
const next = rest.slice(1).search(/\n###\s+/)
|
|
55
|
+
const section = next === -1 ? rest : rest.slice(0, next + 1)
|
|
56
|
+
const metricName = markdownField(section, "Metric") || "score"
|
|
57
|
+
const unit = markdownField(section, "Unit")
|
|
58
|
+
const direction = markdownField(section, "Direction")
|
|
59
|
+
const baseCommitSha = markdownField(section, "Base commit")
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name,
|
|
63
|
+
description: markdownField(section, "Description") || null,
|
|
64
|
+
gitBranchName,
|
|
65
|
+
baseCommitSha: baseCommitSha || null,
|
|
66
|
+
metricName,
|
|
67
|
+
metricUnit: unit || null,
|
|
68
|
+
metricDirection: direction === "minimize" ? "minimize" : "maximize",
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function branchMetadata({
|
|
73
|
+
root,
|
|
74
|
+
projectPath,
|
|
75
|
+
branchName,
|
|
76
|
+
gitBranchName,
|
|
77
|
+
}: {
|
|
78
|
+
root: string
|
|
79
|
+
projectPath: string
|
|
80
|
+
branchName: string
|
|
81
|
+
gitBranchName: string
|
|
82
|
+
}): Promise<BranchMetadata> {
|
|
83
|
+
const state = await readState(root)
|
|
84
|
+
const stored = state.branches[branchStateKey(projectPath, branchName)]
|
|
85
|
+
if (stored?.metricName) {
|
|
86
|
+
return {
|
|
87
|
+
name: branchName,
|
|
88
|
+
description: stored.description ?? null,
|
|
89
|
+
gitBranchName: stored.gitBranchName ?? gitBranchName,
|
|
90
|
+
baseCommitSha: stored.baseCommitSha ?? null,
|
|
91
|
+
metricName: stored.metricName,
|
|
92
|
+
metricUnit: stored.metricUnit ?? null,
|
|
93
|
+
metricDirection:
|
|
94
|
+
stored.metricDirection === "minimize" ? "minimize" : "maximize",
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
(await branchFromMarkdown({
|
|
100
|
+
root,
|
|
101
|
+
projectPath,
|
|
102
|
+
name: branchName,
|
|
103
|
+
gitBranchName,
|
|
104
|
+
})) ?? {
|
|
105
|
+
name: branchName,
|
|
106
|
+
description: null,
|
|
107
|
+
gitBranchName,
|
|
108
|
+
baseCommitSha: null,
|
|
109
|
+
metricName: "score",
|
|
110
|
+
metricUnit: null,
|
|
111
|
+
metricDirection: "maximize",
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function appendBranchToMarkdown({
|
|
117
|
+
root,
|
|
118
|
+
projectPath,
|
|
119
|
+
name,
|
|
120
|
+
description,
|
|
121
|
+
baseCommitSha,
|
|
122
|
+
metricName,
|
|
123
|
+
metricUnit,
|
|
124
|
+
metricDirection,
|
|
125
|
+
}: {
|
|
126
|
+
root: string
|
|
127
|
+
projectPath: string
|
|
128
|
+
name: string
|
|
129
|
+
description?: string | null
|
|
130
|
+
baseCommitSha?: string | null
|
|
131
|
+
metricName: string
|
|
132
|
+
metricUnit?: string | null
|
|
133
|
+
metricDirection: MetricDirection
|
|
134
|
+
}) {
|
|
135
|
+
const file = onyxPath(root, projectPath, "onyx.md")
|
|
136
|
+
const marker = `### ${name}`
|
|
137
|
+
let text = ""
|
|
138
|
+
try {
|
|
139
|
+
text = await readFile(file, "utf8")
|
|
140
|
+
} catch {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (text.includes(marker)) return
|
|
145
|
+
|
|
146
|
+
const addition = `\n### ${name}\n\nDescription: ${description ?? ""}\n\nMetric: ${metricName}\nUnit: ${metricUnit ?? ""}\nDirection: ${metricDirection}\nBase commit: ${baseCommitSha ?? ""}\n\nPlan:\n\nSuccess criteria:\n\nNext to try:\n\n- Add hypotheses here as a queue. Each iteration pops one and appends a row to Attempts.\n\nAttempts:\n\n| commit | hypothesis | metric | status | learning |\n| ------ | ---------- | ------ | ------ | -------- |\n`
|
|
147
|
+
await writeFile(file, `${text.trimEnd()}\n${addition}`, "utf8")
|
|
148
|
+
}
|